diff --git a/.editorconfig b/.editorconfig index d03e4647..c6c8b362 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,9 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 29654a41..196f69dc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,3 @@ updates: schedule: interval: "daily" target-branch: "master" - ignore: - - dependency-name: "@biomejs/biome" - - dependency-name: "@vitest/coverage-v8" - - dependency-name: "vitest" diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index abd058db..5aae454c 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - node-version: [18, 20, 22] + node-version: [20, 22, 24] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 57db1ae9..06b88530 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ -.idea -_temp* -.vscode -coverage node_modules +dist +*.tgz +coverage +.vscode +.idea +Thumbs.db +.DS_Store npm-debug.log yarn-error.log diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d18afd..2bba4fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,186 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.0-14] - 2025-07-17 + +### Fixed + +- fix: replace css properties 9f37941 + +## [6.0.0-13] - 2025-07-17 + +### Added + +- feat: replace css properties transformer 8e9b6af + +### Fixed + +- fix: default posthtml options for the prettify transformer a5916ec +- fix: ensure user posthtml options are used by all transformers 2fa7e1c + +### Changed + +- refactor: cache posthtml options in inliner 173aa3f + +## [6.0.0-12] - 2025-07-17 + +### Fixed + +- fix: @​container class safelisting b4d02f1 + +## [6.0.0-11] - 2025-07-16 + +### Fixed + +- fix: return posthtml tree from inliner 5d78370 + +## [6.0.0-10] - 2025-07-16 + +### Fixed + +- fix: resolve props default options typo 8673a1e + +## [6.0.0-9] - 2025-07-16 + +### Changed + +- refactor: resolving css props acacc14 + +## [6.0.0-8] - 2025-07-16 + +### Changed + +- refactor: css inlining d443c31 + +## [6.0.0-7] - 2025-07-15 + +This release integrates the `v5.2.2` patch release, which fixes an issue where both local and production `build.content` paths were used when building for production. When specified in a `config.{env}.js` file, build.content must not be merged with the one in the base `config.js`. + +### Fixed + +- fix: ensure content paths are unique to each build environment 46c292b + +## [6.0.0-6] - 2025-07-14 + +### Fixed + +- use extension when importing file 99835d3 + +## [6.0.0-5] - 2025-07-14 + +### Changed + +- run css compilation before components too 272bbdb + +## [6.0.0-4] - 2025-07-14 + +### Added + +- added support for skipping CSS compilation on individual ` + ``` + +There are still things missign and/or broken: + +- inline CSS like the `line-height` on spacers is broken/missing +- `tailwindcss-email-variants` and `tailwindcss-mso` are not ported yet +- some CSS duplication and artifacts still present in the final build + +--- + +- feat: add tailwindcss v4 support 8b5596e + +https://github.com/maizzle/framework/compare/v5.2.1...v6.0.0-0 + ## [5.2.1] - 2025-06-25 This is just a maintenance release to update dependencies. diff --git a/bin/maizzle b/bin/maizzle deleted file mode 100644 index 80c09cbe..00000000 --- a/bin/maizzle +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node - -import bootstrap from '@maizzle/cli' - -await bootstrap() diff --git a/bin/maizzle.mjs b/bin/maizzle.mjs new file mode 100755 index 00000000..853e1a7e --- /dev/null +++ b/bin/maizzle.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { createJiti } from 'jiti' +import { fileURLToPath } from 'node:url' + +const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true }) +const { default: bootstrap } = await jiti.import('maizzle') +const framework = await jiti.import('../dist/index.mjs') + +await bootstrap(framework) diff --git a/biome.json b/biome.json deleted file mode 100644 index e6ae151e..00000000 --- a/biome.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json", - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noForEach": "off" - }, - "style": { - "useTemplate": "off", - "noParameterAssign": "off" - } - } - } -} diff --git a/components.json b/components.json new file mode 100644 index 00000000..cb0e77f1 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/server/ui/main.css", + "baseColor": "gray", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} diff --git a/package-lock.json b/package-lock.json index 1c0d0269..bdcc9acf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,75 +1,81 @@ { "name": "@maizzle/framework", - "version": "5.5.0", + "version": "6.0.0-rc.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@maizzle/framework", - "version": "5.5.0", + "version": "6.0.0-rc.5", + "bundleDependencies": [ + "maizzle" + ], "license": "MIT", "dependencies": { - "@maizzle/cli": "^2.0.0", - "cheerio": "1.0.0", - "chokidar": "^3.6.0", - "cli-table3": "^0.6.5", - "color-shorthand-hex-to-six-digit": "^5.0.16", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@unhead/vue": "^2.1.4", + "@vitejs/plugin-vue": "^6.0.4", + "@vitest/coverage-v8": "^4.0.18", + "@vueuse/core": "^14.2.1", + "caniemail": "^1.0.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "css-select": "^6.0.0", "defu": "^6.1.4", - "email-comb": "^7.0.21", - "express": "^4.21.0", - "fast-glob": "^3.3.2", - "gray-matter": "^4.0.3", - "html-crush": "^6.0.19", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "email-comb": "^7.1.3", + "html-crush": "^6.1.3", + "htmlparser2": "^10.1.0", "is-url-superb": "^6.1.0", - "istextorbinary": "^9.5.0", - "juice": "^11.1.0", - "lodash-es": "^4.17.21", - "morphdom": "^2.7.4", - "ora": "^8.1.0", - "pathe": "^2.0.0", - "postcss": "^8.4.49", - "postcss-calc": "^10.0.2", - "postcss-color-functional-notation": "^7.0.10", - "postcss-css-variables": "^0.19.0", - "postcss-import": "^16.1.0", - "postcss-safe-parser": "^7.0.0", + "jiti": "^2.6.1", + "juice": "^11.1.1", + "lucide-vue-next": "^1.0.0", + "maizzle": "latest", + "ora": "^9.3.0", + "oxfmt": "^0.35.0", + "postcss": "^8.5.6", + "postcss-calc": "^10.1.1", + "postcss-custom-properties": "^15.0.0", + "postcss-merge-longhand": "^7.0.5", + "postcss-safe-parser": "^7.0.1", "postcss-sort-media-queries": "^5.2.0", - "posthtml": "^0.16.6", - "posthtml-attrs-parser": "^1.1.1", - "posthtml-base-url": "^3.1.8", - "posthtml-component": "^2.1.0", - "posthtml-content": "^2.1.0", - "posthtml-expressions": "^1.11.4", - "posthtml-extra-attributes": "^3.1.0", - "posthtml-fetch": "^4.0.0", - "posthtml-markdownit": "^3.1.0", - "posthtml-mso": "^3.1.0", - "posthtml-parser": "^0.12.1", - "posthtml-postcss": "^1.0.2", - "posthtml-postcss-merge-longhand": "^3.1.2", - "posthtml-render": "^3.0.0", - "posthtml-safe-class-names": "^4.1.0", - "posthtml-url-parameters": "^3.1.0", - "posthtml-widows": "^1.0.0", - "pretty": "^2.0.0", - "string-strip-html": "^13.4.8", - "tailwindcss": "^3.4.16", - "ws": "^8.18.0" + "postcss-value-parser": "^4.2.0", + "query-string": "^9.3.1", + "reka-ui": "^2.9.3", + "shiki": "^4.0.2", + "string-strip-html": "^13.5.3", + "tailwind-merge": "^3.5.0", + "tinyglobby": "^0.2.15", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^31.0.0", + "unplugin-vue-markdown": "^29.2.0", + "vite": "^7.3.1", + "vue": "^3.5.28", + "vue-router": "^5.0.2" }, "bin": { - "maizzle": "bin/maizzle" + "maizzle": "bin/maizzle.mjs" }, "devDependencies": { - "@biomejs/biome": "2.2.7", "@types/js-beautify": "^1.14.3", - "@types/markdown-it": "^14.1.2", - "@vitest/coverage-v8": "^3.0.4", - "supertest": "^7.0.0", - "vitest": "^3.0.3" - }, - "engines": { - "node": ">=18.20" - } + "@types/node": "^25.2.3", + "@types/postcss-safe-parser": "^5.0.4", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.6.3", + "oxlint": "^1.50.0", + "tsdown": "^0.20.3", + "vitest": "^4.0.18" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "license": "MIT" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -83,25 +89,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -111,20 +147,18 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -133,11 +167,42 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -151,824 +216,653 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@biomejs/biome": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.7.tgz", - "integrity": "sha512-1a8j0UP1vXVUf3UzMZEJ/zS2VgAG6wU6Cuh/I764sUGI+MCnJs/9WaojHYBDCxCMLTgU60/WqnYof85emXmSBA==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" + "node_modules/@clack/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", + "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", + "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.2.0", + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@csstools/cascade-layer-name-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-3.0.0.tgz", + "integrity": "sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/utilities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-3.0.0.tgz", + "integrity": "sha512-etDqA/4jYvOGBM6yfKCOsEXfH96BKztZdgGmGqKi2xHnDe0ILIBraRspwgYatJH9JsCZ5HCGoCst8w18EKOAdg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.2.7", - "@biomejs/cli-darwin-x64": "2.2.7", - "@biomejs/cli-linux-arm64": "2.2.7", - "@biomejs/cli-linux-arm64-musl": "2.2.7", - "@biomejs/cli-linux-x64": "2.2.7", - "@biomejs/cli-linux-x64-musl": "2.2.7", - "@biomejs/cli-win32-arm64": "2.2.7", - "@biomejs/cli-win32-x64": "2.2.7" + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.7.tgz", - "integrity": "sha512-xBUUsebnO2/Qj1v7eZmKUy2ZcFkZ4/jLUkxN02Qup1RPoRaiW9AKXHrqS3L7iX6PzofHY2xuZ+Pb9kAcpoe0qA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ - "arm64" + "ppc64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "aix" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.7.tgz", - "integrity": "sha512-vsY4NhmxqgfLJufr9XUnC+yGUPJiXAc1mz6FcjaAmuIuLwfghN4uQO7hnW2AneGyoi2mNe9Jbvf6Qtq4AjzrFg==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ - "x64" + "arm" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.7.tgz", - "integrity": "sha512-nUdco104rjV9dULi1VssQ5R/kX2jE/Z2sDjyqS+siV9sTQda0DwmEUixFNRCWvZJRRiZUWhgiDFJ4n7RowO8Mg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.7.tgz", - "integrity": "sha512-FrTwvKO/7t5HbVTvhlMOTOVQLAcR7r4O4iFQhEpZXUtBfosHqrX/JJlX7daPawoe14MDcCu9CDg0zLVpTuDvuQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ - "arm64" + "x64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.7.tgz", - "integrity": "sha512-tPTcGAIEOOZrj2tQ7fdraWlaxNKApBw6l4In8wQQV1IyxnAexqi0hykHzKEX8hKKctf5gxGBfNCzyIvqpj4CFQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ - "x64" + "arm64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.7.tgz", - "integrity": "sha512-MnsysF5s/iLC5wnYvuMseOy+m8Pd4bWG1uwlVyy2AUbfjAVUgtbYbboc5wMXljFrDY7e6rLjLTR4S2xqDpGlQg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.7.tgz", - "integrity": "sha512-h5D1jhwA2b7cFXerYiJfXHSzzAMFFoEDL5Mc2BgiaEw0iaSgSso/3Nc6FbOR55aTQISql+IpB4PS7JoV26Gdbw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.7.tgz", - "integrity": "sha512-URqAJi0kONyBKG4V9NVafHLDtm6IHmF4qPYi/b6x7MD6jxpWeJiTCO6R5+xDlWckX2T/OGv6Yq3nkz6s0M8Ykw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@clack/core": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", - "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" + "node": ">=18" } }, - "node_modules/@clack/prompts": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", - "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", - "bundleDependencies": [ - "is-unicode-supported" + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" ], "license": "MIT", - "dependencies": { - "@clack/core": "^0.3.3", - "is-unicode-supported": "*", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts/node_modules/is-unicode-supported": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.1.90" + "node": ">=18" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "optional": true, + "os": [ + "linux" ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, "engines": { "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "optional": true, + "os": [ + "linux" ], - "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.2.1.tgz", - "integrity": "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" ], - "license": "MIT-0", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/utilities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz", - "integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" ], - "license": "MIT-0", "engines": { "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "aix" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ - "arm" + "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ - "arm64" + "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "openbsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "openbsd" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ - "ia32" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ - "loong64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ - "mips64el" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -982,39 +876,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1025,6 +886,16 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1050,388 +921,403 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@maizzle/cli": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@maizzle/cli/-/cli-2.0.0.tgz", - "integrity": "sha512-ZTvEM58L5ndRtnxtBykMAFUjsFYZU2gpYEC9eSs2mqIAJ3ogT10p7ehq+YacwAXOHpCm5uk04pJRNvg6TcA40w==", + "node_modules/@mdit-vue/plugin-component": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@mdit-vue/plugin-component/-/plugin-component-3.0.2.tgz", + "integrity": "sha512-Fu53MajrZMOAjOIPGMTdTXgHLgGU9KwTqKtYc6WNYtFZNKw04euSfJ/zFg8eBY/2MlciVngkF7Gyc2IL7e8Bsw==", "license": "MIT", "dependencies": { - "@clack/prompts": "^0.7.0", - "commander": "^12.1.0", - "create-maizzle": "^0.3.0", - "import-from-esm": "^1.3.4", - "ora": "^8.0.1", - "pathe": "^1.1.2", - "picocolors": "^1.0.1" + "@types/markdown-it": "^14.1.2", + "markdown-it": "^14.1.0" }, - "bin": { - "maizzle": "bin/maizzle.mjs" - } - }, - "node_modules/@maizzle/cli/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "license": "MIT" - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=20.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@mdit-vue/plugin-frontmatter": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@mdit-vue/plugin-frontmatter/-/plugin-frontmatter-3.0.2.tgz", + "integrity": "sha512-QKKgIva31YtqHgSAz7S7hRcL7cHXiqdog4wxTfxeQCHo+9IP4Oi5/r1Y5E93nTPccpadDWzAwr3A0F+kAEnsVQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@mdit-vue/types": "3.0.2", + "@types/markdown-it": "^14.1.2", + "gray-matter": "^4.0.3", + "markdown-it": "^14.1.0" }, "engines": { - "node": ">= 8" + "node": ">=20.0.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@mdit-vue/types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@mdit-vue/types/-/types-3.0.2.tgz", + "integrity": "sha512-00aAZ0F0NLik6I6Yba2emGbHLxv+QYrPH00qQ5dFKXlAo1Ll2RHDXwY7nN2WAfrx2pP+WrvSRFTGFCNGdzBDHw==", "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=20.0.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, "license": "MIT" }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "node_modules/@oxc-project/types": { + "version": "0.112.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.112.0.tgz", + "integrity": "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==", "dev": true, "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.35.0.tgz", + "integrity": "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.35.0.tgz", + "integrity": "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.35.0.tgz", + "integrity": "sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.35.0.tgz", + "integrity": "sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.35.0.tgz", + "integrity": "sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.35.0.tgz", + "integrity": "sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==", "cpu": [ - "x64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.35.0.tgz", + "integrity": "sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.35.0.tgz", + "integrity": "sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.35.0.tgz", + "integrity": "sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.35.0.tgz", + "integrity": "sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==", "cpu": [ - "arm64" + "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.35.0.tgz", + "integrity": "sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==", "cpu": [ - "loong64" + "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.35.0.tgz", + "integrity": "sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==", "cpu": [ - "loong64" + "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.35.0.tgz", + "integrity": "sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==", "cpu": [ - "ppc64" + "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.35.0.tgz", + "integrity": "sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==", "cpu": [ - "ppc64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.35.0.tgz", + "integrity": "sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==", "cpu": [ - "riscv64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.35.0.tgz", + "integrity": "sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==", "cpu": [ - "riscv64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.35.0.tgz", + "integrity": "sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==", "cpu": [ - "s390x" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.35.0.tgz", + "integrity": "sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==", "cpu": [ - "x64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.35.0.tgz", + "integrity": "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.59.0.tgz", + "integrity": "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.59.0.tgz", + "integrity": "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==", "cpu": [ "arm64" ], @@ -1439,13 +1325,16 @@ "license": "MIT", "optional": true, "os": [ - "openharmony" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.59.0.tgz", + "integrity": "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==", "cpu": [ "arm64" ], @@ -1453,27 +1342,33 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.59.0.tgz", + "integrity": "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.59.0.tgz", + "integrity": "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==", "cpu": [ "x64" ], @@ -1481,2194 +1376,2954 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ] + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.59.0.tgz", + "integrity": "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.59.0.tgz", + "integrity": "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.59.0.tgz", + "integrity": "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.59.0.tgz", + "integrity": "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/js-beautify": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@types/js-beautify/-/js-beautify-1.14.3.tgz", - "integrity": "sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg==", + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.59.0.tgz", + "integrity": "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.59.0.tgz", + "integrity": "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.59.0.tgz", + "integrity": "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.59.0.tgz", + "integrity": "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.59.0.tgz", + "integrity": "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.59.0.tgz", + "integrity": "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.59.0.tgz", + "integrity": "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.59.0.tgz", + "integrity": "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.59.0.tgz", + "integrity": "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.59.0.tgz", + "integrity": "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@quansync/fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.3.tgz", + "integrity": "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.3.tgz", + "integrity": "sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.3.tgz", + "integrity": "sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.3.tgz", + "integrity": "sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.3.tgz", + "integrity": "sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.3.tgz", + "integrity": "sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.3.tgz", + "integrity": "sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.3.tgz", + "integrity": "sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.3.tgz", + "integrity": "sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.3.tgz", + "integrity": "sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.3.tgz", + "integrity": "sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.3.tgz", + "integrity": "sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.3.tgz", + "integrity": "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "@shikijs/types": "4.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" + "@shikijs/types": "4.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", "license": "MIT", "dependencies": { - "tinyspy": "^4.0.3" + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" } }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "license": "ISC", + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.6" + "node": ">= 20" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.4.0" + "node": ">= 20" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6" + "node": ">= 20" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">= 20" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 20" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">= 20" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-pull-all-with-glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/array-pull-all-with-glob/-/array-pull-all-with-glob-7.1.3.tgz", - "integrity": "sha512-BCkrHE6iK3EXywYuivo1okICHMD5WnKcoMsyM7O5AjpvTpsYM96UTFaukKv/pRAj42rzmXrNuSAMZXwFKNWn4Q==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "license": "MIT", + "optional": true, "dependencies": { - "matcher": "^6.0.0" + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" }, "engines": { - "node": ">=14.18.0" + "node": ">=14.0.0" } }, - "node_modules/arrayiffy-if-string": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/arrayiffy-if-string/-/arrayiffy-if-string-5.1.3.tgz", - "integrity": "sha512-LQk6w4KAE/65Yr1v9/1Z6dMXNnWrU5TxtQm5nFBNbqzoimKReG1tfYgmIctzMiYW1KgnsGXL8F9G0vlH0r01Ww==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14.18.0" + "node": ">= 20" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", - "dev": true, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", "license": "MIT", - "engines": { - "node": ">=8" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/binaryextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", - "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", - "license": "Artistic-2.0", + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.23.tgz", + "integrity": "sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==", + "license": "MIT", "dependencies": { - "editions": "^6.21.0" - }, - "engines": { - "node": ">=4" + "@tanstack/virtual-core": "3.13.23" }, "funding": { - "url": "https://bevry.me/fund" + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" } }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", + "node_modules/@trivago/prettier-plugin-sort-imports": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", + "integrity": "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==", + "license": "Apache-2.0", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", + "javascript-natural-sort": "^0.7.1", + "lodash": "^4.17.21" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">18.12" + }, + "peerDependencies": { + "@vue/compiler-sfc": "3.x", + "prettier": "2.x - 3.x", + "prettier-plugin-svelte": "3.x", + "svelte": "4.x || 5.x" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + }, + "svelte": { + "optional": true + } } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "license": "MIT", + "optional": true, "dependencies": { - "ms": "2.0.0" + "tslib": "^2.4.0" } }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "license": "MIT" }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@types/unist": "*" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@types/js-beautify": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@types/js-beautify/-/js-beautify-1.14.3.tgz", + "integrity": "sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "@types/lodash": "*" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@types/unist": "*" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "devOptional": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "undici-types": "~7.18.0" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/@types/postcss-safe-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/postcss-safe-parser/-/postcss-safe-parser-5.0.4.tgz", + "integrity": "sha512-5zGTm1jsW3j4+omgND1SIDbrZOcigTuxa4ihppvKbLkg2INUGBHV/fWNRSRFibK084tU3fxqZ/kVoSIGqRHnrQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "postcss": "^8.4.4" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, - "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "devOptional": true, "license": "MIT", "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" + "@types/node": "*" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unhead/vue": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.13.tgz", + "integrity": "sha512-HYy0shaHRnLNW9r85gppO8IiGz0ONWVV3zGdlT8CQ0tbTwixznJCIiyqV4BSV1aIF1jJIye0pd1p/k6Eab8Z/A==", "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "dependencies": { + "hookable": "^6.0.1", + "unhead": "2.1.13" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/harlan-zw" + }, + "peerDependencies": { + "vue": ">=3.5.18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, "engines": { - "node": ">= 16" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" } }, - "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz", + "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==", "license": "MIT", "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=18.17" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.3", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.3", + "vitest": "4.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "license": "BSD-2-Clause", + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://opencollective.com/vitest" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://opencollective.com/vitest" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", "license": "MIT", "dependencies": { - "consola": "^3.2.3" + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", "license": "MIT", "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/codsen-utils": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.7.3.tgz", - "integrity": "sha512-YIFQQ1n2NSgwoB3sCe7RpkZzsrPxTMek6jc7wC9fXOm1wwfWAKja9gLOMEjlXOUd3LKV3o6Jci7n9BoHs5Z8Sg==", + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", "license": "MIT", "dependencies": { - "rfdc": "^1.4.1" + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=14.18.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-shorthand-hex-to-six-digit": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/color-shorthand-hex-to-six-digit/-/color-shorthand-hex-to-six-digit-5.1.3.tgz", - "integrity": "sha512-uHNVXSceG5/sJ5abbk9i6ZuzYVIRI2MwgdcX13rFHD7oxiGq8bJOkOiJI7Pbjrl0zIm13hfEGefGoiQaLTi3XQ==", + "node_modules/@vue-macros/common/node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", "license": "MIT", "dependencies": { - "codsen-utils": "^1.7.3", - "hex-color-regex": "^1.1.0", - "rfdc": "^1.4.1" + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" }, "engines": { - "node": ">=14.18.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", "engines": { - "node": ">=18" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", + "node": ">=0.12" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/condense-newlines": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz", - "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==", + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "license": "MIT", "dependencies": { - "extend-shallow": "^2.0.1", - "is-whitespace": "^0.3.0", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, - "node_modules/condense-newlines/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", "license": "MIT", "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" } }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", "license": "MIT", "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" + "@vue/devtools-kit": "^8.1.1" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@vue/devtools-kit/node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "node_modules/@vue/devtools-kit/node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, + "node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", "license": "MIT" }, - "node_modules/create-maizzle": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/create-maizzle/-/create-maizzle-0.3.4.tgz", - "integrity": "sha512-k76W6MSdtv6xv1Bo8GkNmzcW40zvbuw20W4cAgOGMDrL0/yO8gtjLtNeOWEg6U9wOmuBXbWaxulT6CdTeRR/wQ==", - "license": "MIT", - "dependencies": { - "@clack/prompts": "^0.8.1", - "degit": "^2.8.4", - "nypm": "^0.4.1", - "picocolors": "^1.0.0" - }, - "bin": { - "create-maizzle": "bin/create-maizzle.mjs" - } - }, - "node_modules/create-maizzle/node_modules/@clack/prompts": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.8.2.tgz", - "integrity": "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==", - "license": "MIT", - "dependencies": { - "@clack/core": "0.3.5", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "@vue/shared": "3.5.32" } }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" } }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "peerDependencies": { + "vue": "3.5.32" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" }, - "engines": { - "node": ">=6.0" + "funding": { + "url": "https://github.com/sponsors/antfu" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "vue": "^3.5.0" } }, - "node_modules/decode-uri-component": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", - "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", "license": "MIT", - "engines": { - "node": ">=14.16" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", "license": "MIT", - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, - "node_modules/defu": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", - "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", - "license": "MIT" + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "node_modules/degit": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/degit/-/degit-2.8.4.tgz", - "integrity": "sha512-vqYuzmSA5I50J882jd+AbAhQtgK6bdKUJIex1JNfEUPENCgYsxugzKVZlFyMwV4i06MmnV47/Iqi5Io86zf3Ng==", + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { - "degit": "degit" + "acorn": "bin/acorn" }, "engines": { - "node": ">=8.0.0" + "node": ">=0.4.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=6" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "license": "MIT" - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", "dev": true, "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "engines": { + "node": ">=14" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "sprintf-js": "~1.0.2" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0" + "tslib": "^2.0.0" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=10" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", + "node_modules/array-pull-all-with-glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/array-pull-all-with-glob/-/array-pull-all-with-glob-7.1.3.tgz", + "integrity": "sha512-BCkrHE6iK3EXywYuivo1okICHMD5WnKcoMsyM7O5AjpvTpsYM96UTFaukKv/pRAj42rzmXrNuSAMZXwFKNWn4Q==", + "license": "MIT", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "matcher": "^6.0.0" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">=14.18.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/arrayiffy-if-string": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/arrayiffy-if-string/-/arrayiffy-if-string-5.1.3.tgz", + "integrity": "sha512-LQk6w4KAE/65Yr1v9/1Z6dMXNnWrU5TxtQm5nFBNbqzoimKReG1tfYgmIctzMiYW1KgnsGXL8F9G0vlH0r01Ww==", "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=14.18.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } }, - "node_modules/editions": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", - "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", - "license": "Artistic-2.0", + "node_modules/ast-kit": { + "version": "3.0.0-beta.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", + "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", + "dev": true, + "license": "MIT", "dependencies": { - "version-range": "^4.15.0" + "@babel/parser": "^8.0.0-beta.4", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" }, "engines": { - "ecmascript": ">= es5", - "node": ">=4" + "node": ">=20.19.0" }, "funding": { - "url": "https://bevry.me/fund" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/editorconfig": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", - "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, "license": "MIT", - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, "engines": { - "node": ">=14" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "license": "ISC" - }, - "node_modules/email-comb": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-7.1.3.tgz", - "integrity": "sha512-QOv6wPr7qU+aM/8cJ8Eddrb86q2djGyDTnLdvAp/7B2FQ+XFRrk4VNn22xarc6NzvcDSEslaM4oF2hp10xFNZw==", + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, "license": "MIT", "dependencies": { - "array-pull-all-with-glob": "^7.1.3", - "codsen-utils": "^1.7.3", - "html-crush": "^6.1.3", - "matcher": "^6.0.0", - "ranges-apply": "^7.1.3", - "ranges-push": "^7.1.3", - "regex-empty-conditional-comments": "^3.1.3", - "string-extract-class-names": "^8.1.3", - "string-left-right": "^6.1.3", - "string-match-left-right": "^9.1.3", - "string-range-expander": "^4.1.3", - "string-uglify": "^3.1.3" + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=14.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "node_modules/ast-kit/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", "license": "MIT", "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", + "node_modules/ast-walker-scope/node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/binary-search": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz", + "integrity": "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==", + "license": "CC0-1.0" + }, + "node_modules/birpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "node": ">=8" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/caniemail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/caniemail/-/caniemail-1.0.5.tgz", + "integrity": "sha512-T2UURmpxAKu6p+c4VpX69LztyE8+SDowBCvWgtoWL++GjyZ63wAtDeNz7m1rL+yi/szWDzec29HSlR7Zy2Nhrw==", "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.3.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "binary-search": "^1.3.6", + "css-what": "^6.1.0", + "domhandler": "^5.0.3", + "dot-prop": "^9.0.0", + "htmlparser2": "^10.0.0", + "micromatch": "^4.0.5", + "onetime": "^7.0.0", + "split-lines": "^3.0.0", + "style-to-object": "^1.0.4" + }, "engines": { - "node": ">=6" + "node": ">=20.19.0" } }, - "node_modules/escape-goat": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", - "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "license": "MIT", - "engines": { - "node": ">=10" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { - "node": ">=12" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" + "node_modules/cheerio-select/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "ms": "2.0.0" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "node_modules/cheerio/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "readdirp": "^5.0.0" }, "engines": { - "node": ">=8.6.0" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "inBundle": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "reusify": "^1.0.4" + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" } }, - "node_modules/fclone": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", - "integrity": "sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/filter-obj": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", - "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codsen-utils": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.7.3.tgz", + "integrity": "sha512-YIFQQ1n2NSgwoB3sCe7RpkZzsrPxTMek6jc7wC9fXOm1wwfWAKja9gLOMEjlXOUd3LKV3o6Jci7n9BoHs5Z8Sg==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "rfdc": "^1.4.1" }, "engines": { - "node": ">= 0.8" + "node": ">=14.18.0" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", "engines": { "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" + "ini": "^1.3.4", + "proto-list": "~1.2.1" } }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=14.0.0" + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" }, "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", + "node_modules/css-select/node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.6" + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.6" + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "bin": { + "cssesc": "bin/cssesc" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=4" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=18" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.16" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "dequal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^2.0.1" + "domelementtype": "^2.3.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" + "type-fest": "^4.18.2" }, "engines": { - "node": ">=6.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/dts-resolver": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", + "integrity": "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==", "dev": true, "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "oxc-resolver": ">=11.0.0" + }, + "peerDependenciesMeta": { + "oxc-resolver": { + "optional": true + } } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" }, "engines": { - "node": ">= 0.4" + "node": ">=14" } }, - "node_modules/hex-color-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", - "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", - "license": "MIT" + "node_modules/electron-to-chromium": { + "version": "1.5.332", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "license": "ISC" }, - "node_modules/html-crush": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/html-crush/-/html-crush-6.1.3.tgz", - "integrity": "sha512-IrDC4BrdrMmV+GMYfXtx6BtzQ6hVf+GjLSDhmg/14DkpNbXtxAcbH3Wk9VeNZbg/y2MpobWTVOSrCzr/HWBwcw==", + "node_modules/email-comb": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-7.1.3.tgz", + "integrity": "sha512-QOv6wPr7qU+aM/8cJ8Eddrb86q2djGyDTnLdvAp/7B2FQ+XFRrk4VNn22xarc6NzvcDSEslaM4oF2hp10xFNZw==", "license": "MIT", "dependencies": { + "array-pull-all-with-glob": "^7.1.3", "codsen-utils": "^1.7.3", + "html-crush": "^6.1.3", + "matcher": "^6.0.0", "ranges-apply": "^7.1.3", "ranges-push": "^7.1.3", + "regex-empty-conditional-comments": "^3.1.3", + "string-extract-class-names": "^8.1.3", "string-left-right": "^6.1.3", "string-match-left-right": "^9.1.3", "string-range-expander": "^4.1.3", - "test-mixer": "^4.2.3" + "string-uglify": "^3.1.3" }, "engines": { "node": ">=14.18.0" } }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, - "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, - "node_modules/import-from-esm": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", - "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "import-meta-resolve": "^4.0.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" }, "engines": { - "node": ">=16.20" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node": ">=10.13.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" + "node": ">=0.12" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { "node": ">=12" @@ -3677,235 +4332,238 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-json": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", - "integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==", - "license": "ISC" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=0.12.0" + "node": ">=4" } }, - "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==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "license": "Apache-2.0", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.0.0" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, - "node_modules/is-url-superb": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-6.1.0.tgz", - "integrity": "sha512-LXdhGlYqUPdvEyIhWPEEwYYK3yrUiPcBjmFGlZNv1u5GtIL5qQRf7ddDyPNAvsMFqdzS923FROpTQU97tLe3JQ==", + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "is-extendable": "^0.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/is-whitespace": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", - "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", + "node_modules/fast-string-truncated-width": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", + "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", + "inBundle": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "fast-string-truncated-width": "^1.2.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "node_modules/fast-wrap-ansi": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", + "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^1.1.0" + } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, "engines": { "node": ">=8" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "BSD-3-Clause", + "license": "ISC", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/istextorbinary": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", - "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", - "license": "Artistic-2.0", - "dependencies": { - "binaryextensions": "^6.11.0", - "editions": "^6.21.0", - "textextensions": "^6.11.0" - }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" }, "funding": { - "url": "https://bevry.me/fund" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "inBundle": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "giget": "dist/cli.mjs" } }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", - "license": "MIT", + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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": "ISC", "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" + "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "license": "MIT", - "engines": { - "node": ">=14" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=6.0" } }, - "node_modules/juice": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/juice/-/juice-11.1.1.tgz", - "integrity": "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==", + "node_modules/happy-dom": { + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "devOptional": true, "license": "MIT", "dependencies": { - "cheerio": "1.0.0", - "commander": "^12.1.0", - "entities": "^7.0.0", - "mensch": "^0.3.4", - "slick": "^1.12.2", - "web-resource-inliner": "^8.0.0" - }, - "bin": { - "juice": "bin/juice" + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" }, "engines": { - "node": ">=18.17" + "node": ">=20.0.0" } }, - "node_modules/juice/node_modules/entities": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", - "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3914,584 +4572,750 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "license": "MIT", "dependencies": { - "uc.micro": "^2.0.0" + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "node_modules/hookable": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", + "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "node_modules/html-crush": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/html-crush/-/html-crush-6.1.3.tgz", + "integrity": "sha512-IrDC4BrdrMmV+GMYfXtx6BtzQ6hVf+GjLSDhmg/14DkpNbXtxAcbH3Wk9VeNZbg/y2MpobWTVOSrCzr/HWBwcw==", "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" + "codsen-utils": "^1.7.3", + "ranges-apply": "^7.1.3", + "ranges-push": "^7.1.3", + "string-left-right": "^6.1.3", + "string-match-left-right": "^9.1.3", + "string-range-expander": "^4.1.3", + "test-mixer": "^4.2.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14.18.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/matcher": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-6.0.0.tgz", - "integrity": "sha512-TzDerdcNtI79w7Av4GT57bLdElPA/VAkjqdMZv8yhuc8geU2z0ljW9anXbX/55aHEMTpYypZb1lxsA/46r9oOQ==", + "node_modules/import-without-cache": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz", + "integrity": "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==", + "dev": true, "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, "engines": { - "node": ">=20" + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mensch": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", - "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "node": ">=12" }, - "engines": { - "node": ">=8.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", - "bin": { - "mime": "cli.js" - }, "engines": { - "node": ">=4" + "node": ">=0.12.0" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "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": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" + "node": ">=18" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "node_modules/is-url-superb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-6.1.0.tgz", + "integrity": "sha512-LXdhGlYqUPdvEyIhWPEEwYYK3yrUiPcBjmFGlZNv1u5GtIL5qQRf7ddDyPNAvsMFqdzS923FROpTQU97tLe3JQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "license": "MIT", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", - "license": "ISC", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "license": "BSD-3-Clause", "dependencies": { - "brace-expansion": "^2.0.1" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" } }, - "node_modules/morphdom": { - "version": "2.7.8", - "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.8.tgz", - "integrity": "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg==", - "license": "MIT" + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "jsesc": "bin/jsesc" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=6" } }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" - }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "license": "ISC", + "node_modules/juice": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-11.1.1.tgz", + "integrity": "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==", + "license": "MIT", "dependencies": { - "abbrev": "^2.0.0" + "cheerio": "1.0.0", + "commander": "^12.1.0", + "entities": "^7.0.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^8.0.0" }, "bin": { - "nopt": "bin/nopt.js" + "juice": "bin/juice" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=18.17" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/juice/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "node_modules/juice/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" + "engines": { + "node": ">=0.12" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/nypm": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.4.1.tgz", - "integrity": "sha512-1b9mihliBh8UCcKtcGRu//G50iHpjxIQVUqkdhPT/SDVE7KdJKoHXLS0heuYTQCx95dFqiyUbXZB9r8ikn+93g==", + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", "dependencies": { - "citty": "^0.1.6", - "consola": "^3.2.3", - "pathe": "^1.1.2", - "pkg-types": "^1.2.1", - "tinyexec": "^0.3.1", - "ufo": "^1.5.4" + "detect-libc": "^2.0.3" }, - "bin": { - "nypm": "dist/cli.mjs" + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^14.16.0 || >=16.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/nypm/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "license": "MIT" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/object-boolean-combinations": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/object-boolean-combinations/-/object-boolean-combinations-6.2.3.tgz", - "integrity": "sha512-A2inWgy5Hy3+9prMyiKBUBYcTpbuZbKOMt+YKMD3ZAsli7TwKh6TPSZdK+kNF5hmJFhaVbjHwP/xsfjO2Z/GmQ==", - "license": "MIT", - "dependencies": { - "codsen-utils": "^1.7.3", - "rfdc": "^1.4.1" - }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.18.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "uc.micro": "^2.0.0" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { - "node": ">=18" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/local-pkg/node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { "node": ">=18" @@ -4500,954 +5324,937 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", "license": "MIT", "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "license": "MIT", "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "node_modules/maizzle": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/maizzle/-/maizzle-1.0.1.tgz", + "integrity": "sha512-dBTOTbiPspu0VGRqdyd39RIoXxo/VUtWPijh1lHdrxYjosv5dH7caVEyuS849aByG6LneSlHT2BByVi9pOk3Fg==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.2.0", + "commander": "^14.0.3", + "giget": "^3.2.0", + "nypm": "^0.6.5", + "picocolors": "^1.1.1" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "bin": { + "maizzle": "bin/maizzle.mjs" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/maizzle/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "inBundle": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=20" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, + "node_modules/markdown-it-async": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-async/-/markdown-it-async-2.2.0.tgz", + "integrity": "sha512-sITME+kf799vMeO/ww/CjH6q+c05f6TLpn6VOmmWCGNqPJzSh+uFgZoMB9s0plNtW6afy63qglNAC3MhrhP/gg==", "license": "MIT", - "engines": { - "node": ">= 14.16" + "dependencies": { + "@types/markdown-it": "^14.1.2", + "markdown-it": "^14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/matcher": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-6.0.0.tgz", + "integrity": "sha512-TzDerdcNtI79w7Av4GT57bLdElPA/VAkjqdMZv8yhuc8geU2z0ljW9anXbX/55aHEMTpYypZb1lxsA/46r9oOQ==", "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, "engines": { - "node": ">=8.6" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "OpenCollective", + "url": "https://opencollective.com/unified" } ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/postcss-calc": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", - "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12 || ^20.9 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.38" - } + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, - "node_modules/postcss-color-functional-notation": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.12.tgz", - "integrity": "sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw==", + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/csstools" + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" }, { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "type": "OpenCollective", + "url": "https://opencollective.com/unified" } ], - "license": "MIT-0", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.2.1", - "@csstools/utilities": "^2.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" + "node": ">=8.6" } }, - "node_modules/postcss-css-variables": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/postcss-css-variables/-/postcss-css-variables-0.19.0.tgz", - "integrity": "sha512-Hr0WEYKLK9VCrY15anHXOd4RCvJy/xRvCnWdplGBeLInwEj6Z14hgzTb2W/39dYTCnS8hnHUfU4/F1zxX0IZuQ==", + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "escape-string-regexp": "^1.0.3", - "extend": "^3.0.1" + "bin": { + "mime": "cli.js" }, - "peerDependencies": { - "postcss": "^8.2.6" + "engines": { + "node": ">=4.0.0" } }, - "node_modules/postcss-css-variables/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "license": "MIT", "engines": { - "node": ">=0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-import": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz", - "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==", - "license": "MIT", + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=16 || 14 >=14.17" }, - "peerDependencies": { - "postcss": "^8.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" } }, - "node_modules/postcss-merge-longhand": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", - "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^7.0.5" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" }, "engines": { - "node": ">=4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" }, - "peerDependencies": { - "postcss": "^8.4.31" + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "inBundle": true, "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" }, "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/postcss-sort-media-queries": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", - "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", + "node_modules/object-boolean-combinations": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/object-boolean-combinations/-/object-boolean-combinations-6.2.3.tgz", + "integrity": "sha512-A2inWgy5Hy3+9prMyiKBUBYcTpbuZbKOMt+YKMD3ZAsli7TwKh6TPSZdK+kNF5hmJFhaVbjHwP/xsfjO2Z/GmQ==", "license": "MIT", "dependencies": { - "sort-css-media-queries": "2.2.0" + "codsen-utils": "^1.7.3", + "rfdc": "^1.4.1" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.4.23" + "node": ">=14.18.0" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT" }, - "node_modules/posthtml": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", - "integrity": "sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==", - "license": "MIT", - "dependencies": { - "posthtml-parser": "^0.11.0", - "posthtml-render": "^3.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/posthtml-attrs-parser": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/posthtml-attrs-parser/-/posthtml-attrs-parser-1.1.2.tgz", - "integrity": "sha512-9Que9y4k8c33iv8h5QyTfHKlurmsJIscozy7oeFRxuWeX/osXjVGu9o9MKrmo4TI6Vr9a9vh9b4LYXqm3/GvhA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" }, - "node_modules/posthtml-base-url": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/posthtml-base-url/-/posthtml-base-url-3.1.9.tgz", - "integrity": "sha512-iIfU8g1GZWD2grTvzD4uSN1kxrY4nld92EyWhUsmupIEv1QA3xdlhH8OwxwOBP1vt6l5RsSXLM3jyQdHFM+IeQ==", + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "is-url-superb": "^6.1.0", - "pathe": "^2.0.0", - "postcss": "^8.4.35", - "postcss-safe-parser": "^7.0.0", - "srcset": "^5.0.1" + "mimic-function": "^5.0.0" }, "engines": { "node": ">=18" }, - "peerDependencies": { - "posthtml": "^0.16.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/posthtml-component": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/posthtml-component/-/posthtml-component-2.3.0.tgz", - "integrity": "sha512-hqboG6IJ1LLv4q60uYt6L5kHLyHS7y1yEfsLq+ZUwLgJqO+Jq0IVkyxohrMvilE+n/FonHSDoWCMRPZOiKH1pg==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "posthtml": "^0.16.6", - "posthtml-attrs-parser": "^1.1.0", - "posthtml-expressions": "^1.11.4", - "posthtml-parser": "^0.12.0", - "posthtml-render": "^3.0.0", - "style-to-object": "^1.0.6" - }, - "engines": { - "node": ">=18" - } + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" }, - "node_modules/posthtml-content": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/posthtml-content/-/posthtml-content-2.1.2.tgz", - "integrity": "sha512-m+mcZstVE3GSwYascxrzcxULzXVO7UlZBV+SXylg8ynZuuZfCHA3biSSXi4EF38xBGqclhvzyb7bV6ArpC5bmg==", + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" } }, - "node_modules/posthtml-expressions": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/posthtml-expressions/-/posthtml-expressions-1.11.4.tgz", - "integrity": "sha512-tJI6KhKLcePRO0/i4d01MNXfcaBa2jIu4MuVLixvGwCRzxdY2D7LLm17ijNyQNQu3xOhCffBLtUMju0K64smmQ==", + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", "license": "MIT", "dependencies": { - "fclone": "^1.0.11", - "posthtml": "^0.16.5", - "posthtml-match-helper": "^1.0.1", - "posthtml-parser": "^0.10.0", - "posthtml-render": "^3.0.0" + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/posthtml-expressions/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" + "node": ">=20" }, "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/posthtml-expressions/node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/posthtml-expressions/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "license": "BSD-2-Clause", + "node_modules/ora/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.2.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">= 4" + "node": ">=20" }, "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/posthtml-expressions/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "license": "BSD-2-Clause", + "node_modules/oxfmt": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.35.0.tgz", + "integrity": "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==", + "license": "MIT", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/posthtml-expressions/node_modules/entities": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", - "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", - "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/posthtml-expressions/node_modules/htmlparser2": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", - "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.2", - "domutils": "^2.8.0", - "entities": "^3.0.1" - } - }, - "node_modules/posthtml-expressions/node_modules/posthtml-parser": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.10.2.tgz", - "integrity": "sha512-PId6zZ/2lyJi9LiKfe+i2xv57oEjJgWbsHGGANwos5AvdQp98i6AtamAl8gzSVFGfQ43Glb5D614cvZf012VKg==", - "license": "MIT", - "dependencies": { - "htmlparser2": "^7.1.1" + "url": "https://github.com/sponsors/Boshen" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/posthtml-extra-attributes": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/posthtml-extra-attributes/-/posthtml-extra-attributes-3.1.4.tgz", - "integrity": "sha512-mABx1GJHhmiSetBgUIoRU42+EPULtlUV3dgIDTkbMAns5YkghmrhimAglMDUZyujz7TjrEBm0MjGk3+Fc15LCQ==", + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.35.0", + "@oxfmt/binding-android-arm64": "0.35.0", + "@oxfmt/binding-darwin-arm64": "0.35.0", + "@oxfmt/binding-darwin-x64": "0.35.0", + "@oxfmt/binding-freebsd-x64": "0.35.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", + "@oxfmt/binding-linux-arm64-gnu": "0.35.0", + "@oxfmt/binding-linux-arm64-musl": "0.35.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", + "@oxfmt/binding-linux-riscv64-musl": "0.35.0", + "@oxfmt/binding-linux-s390x-gnu": "0.35.0", + "@oxfmt/binding-linux-x64-gnu": "0.35.0", + "@oxfmt/binding-linux-x64-musl": "0.35.0", + "@oxfmt/binding-openharmony-arm64": "0.35.0", + "@oxfmt/binding-win32-arm64-msvc": "0.35.0", + "@oxfmt/binding-win32-ia32-msvc": "0.35.0", + "@oxfmt/binding-win32-x64-msvc": "0.35.0" + } + }, + "node_modules/oxlint": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.59.0.tgz", + "integrity": "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==", + "dev": true, "license": "MIT", - "dependencies": { - "posthtml-match-helper": "^2.0.0" + "bin": { + "oxlint": "bin/oxlint" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.59.0", + "@oxlint/binding-android-arm64": "1.59.0", + "@oxlint/binding-darwin-arm64": "1.59.0", + "@oxlint/binding-darwin-x64": "1.59.0", + "@oxlint/binding-freebsd-x64": "1.59.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", + "@oxlint/binding-linux-arm-musleabihf": "1.59.0", + "@oxlint/binding-linux-arm64-gnu": "1.59.0", + "@oxlint/binding-linux-arm64-musl": "1.59.0", + "@oxlint/binding-linux-ppc64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-musl": "1.59.0", + "@oxlint/binding-linux-s390x-gnu": "1.59.0", + "@oxlint/binding-linux-x64-gnu": "1.59.0", + "@oxlint/binding-linux-x64-musl": "1.59.0", + "@oxlint/binding-openharmony-arm64": "1.59.0", + "@oxlint/binding-win32-arm64-msvc": "1.59.0", + "@oxlint/binding-win32-ia32-msvc": "1.59.0", + "@oxlint/binding-win32-x64-msvc": "1.59.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.18.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } } }, - "node_modules/posthtml-extra-attributes/node_modules/posthtml-match-helper": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-2.0.3.tgz", - "integrity": "sha512-p9oJgTdMF2dyd7WE54QI1LvpBIkNkbSiiECKezNnDVYhGhD1AaOnAkw0Uh0y5TW+OHO8iBdSqnd8Wkpb6iUqmw==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "entities": "^6.0.0" }, - "peerDependencies": { - "posthtml": "^0.16.6" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/posthtml-fetch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/posthtml-fetch/-/posthtml-fetch-4.0.3.tgz", - "integrity": "sha512-s0GLqRVrLqwag2fIyvkb344IJolFDhG27cptr6xzsXqUVMUUHKR/B9DlCFPFE5f0/fK/JLLh7dkhCCoH8YT/mA==", + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "is-url": "^1.2.4", - "ofetch": "^1.3.4", - "posthtml": "^0.16.6", - "posthtml-expressions": "^1.11.3", - "posthtml-match-helper": "^1.0.1" + "domhandler": "^5.0.3", + "parse5": "^7.0.0" }, - "engines": { - "node": ">=14.0.0" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/posthtml-markdownit": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/posthtml-markdownit/-/posthtml-markdownit-3.1.2.tgz", - "integrity": "sha512-PUDYSa6FPu2PX4vsSmg2SQmVzaeSc9dCATvDN5dkj79Qks5TIuPsnPqDadci0kRa7MbMKFujL9BieXum1NZgmQ==", + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "license": "MIT", "dependencies": { - "markdown-it": "^14.0.0", - "min-indent": "^1.0.0", - "posthtml": "^0.16.6", - "posthtml-parser": "^0.12.0", - "posthtml-render": "^3.0.0" + "parse5": "^7.0.0" }, - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/posthtml-match-helper": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-1.0.4.tgz", - "integrity": "sha512-Tj9orTIBxHdnraCxoEGjoizsFsTGvukzwcuhOjYQGmDG6gTlaRbMrGgi1J+FwKTN8hsCQENHYY0Deqs9a89BVg==", - "license": "ISC", - "peerDependencies": { - "posthtml": ">=0.5.0" + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/posthtml-mso": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/posthtml-mso/-/posthtml-mso-3.1.2.tgz", - "integrity": "sha512-0lzWLTa2PD/k5beNY7kj/Z5WosQB5uBvudd1iIlRmNEv7qCRCEPy0CfqOfNX0VXY50bENrnO18Z9x/LTKsdOWA==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", - "dependencies": { - "posthtml": "^0.16.6" - }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/posthtml-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.12.1.tgz", - "integrity": "sha512-rYFmsDLfYm+4Ts2Oh4DCDSZPtdC1BLnRXAobypVzX9alj28KGl65dIFtgDY9zB57D0TC4Qxqrawuq/2et1P0GA==", - "license": "MIT", + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "htmlparser2": "^9.0.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/posthtml-postcss": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/posthtml-postcss/-/posthtml-postcss-1.0.5.tgz", - "integrity": "sha512-cIpy6KhFYbpg+Z97N2PrpkaX2P8tmyk530Fy7e7yMCwAa8ys2d3U+qroPEYPNM/dUGvF/x3gZGpv8W1/7z2Ylw==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "inBundle": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", - "dependencies": { - "postcss": "^8.4.35", - "postcss-load-config": "^6.0.1" - }, "engines": { - "node": ">=18" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/posthtml-postcss-merge-longhand": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/posthtml-postcss-merge-longhand/-/posthtml-postcss-merge-longhand-3.1.4.tgz", - "integrity": "sha512-cIzN7opE7sQifqW5imHFRZcuHngJl8VcjIxiTkEyMuWxLT9SKznd7wboCgK32VOVp0x9q8fhKTcIKKoQnbQ6bQ==", + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "license": "MIT", "dependencies": { - "postcss": "^8.1.10", - "postcss-merge-longhand": "^7.0.0", - "postcss-safe-parser": "^7.0.0", - "posthtml": "^0.16.4" - }, - "engines": { - "node": ">=18" + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" } }, - "node_modules/posthtml-render": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz", - "integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==", + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "is-json": "^2.0.1" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=12" + "node": "^10 || ^12 || >=14" } }, - "node_modules/posthtml-safe-class-names": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/posthtml-safe-class-names/-/posthtml-safe-class-names-4.1.2.tgz", - "integrity": "sha512-mmTVlGj5ZkGMl1mRGYE8SqL5nLECTl/uzP+/8dBhINpTW5C75fSMD3ENOxkntdO6pmwB/BaGqLaGqD9L0RQK2w==", + "node_modules/postcss-calc": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", + "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", "license": "MIT", "dependencies": { - "css.escape": "^1.5.1", - "postcss": "^8.4.32", - "postcss-safe-parser": "^7.0.0", "postcss-selector-parser": "^7.0.0", - "posthtml": "^0.16.6" + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=18" + "node": "^18.12 || ^20.9 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" } }, - "node_modules/posthtml-url-parameters": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/posthtml-url-parameters/-/posthtml-url-parameters-3.1.4.tgz", - "integrity": "sha512-EgLOh63G9FHHhMOQMsRQNrKQEV4vesGet4eIgNvZxjAZznoSJEwYnyWVmnAByCfoXjIEg2pzpQjcFllGXz32dg==", + "node_modules/postcss-custom-properties": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-15.0.1.tgz", + "integrity": "sha512-cuyq8sd8dLY0GLbelz1KB8IMIoDECo6RVXMeHeXY2Uw3Q05k/d1GVITdaKLsheqrHbnxlwxzSRZQQ5u+rNtbMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "is-url-superb": "^6.1.0", - "posthtml": "^0.16.6", - "posthtml-match-helper": "^2.0.0", - "query-string": "^9.0.0" + "@csstools/cascade-layer-name-parser": "^3.0.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/utilities": "^3.0.0", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/posthtml-url-parameters/node_modules/posthtml-match-helper": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-2.0.3.tgz", - "integrity": "sha512-p9oJgTdMF2dyd7WE54QI1LvpBIkNkbSiiECKezNnDVYhGhD1AaOnAkw0Uh0y5TW+OHO8iBdSqnd8Wkpb6iUqmw==", + "node_modules/postcss-merge-longhand": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", + "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.5" + }, "engines": { - "node": ">=18" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "posthtml": "^0.16.6" + "postcss": "^8.4.32" } }, - "node_modules/posthtml-widows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/posthtml-widows/-/posthtml-widows-1.0.2.tgz", - "integrity": "sha512-1uy5/iirP0HWJ6VXQMSjsei6VV9F7TBqKwh9VlF60nKdA14ik/rEdVga79Wk4a9xRWAujMEnmOH5LQgNQQjreg==", + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/posthtml/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" + "node": ">=18.0" }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/posthtml/node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/posthtml/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "license": "BSD-2-Clause", + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.2.0" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=4" } }, - "node_modules/posthtml/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "license": "BSD-2-Clause", + "node_modules/postcss-sort-media-queries": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", + "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", + "license": "MIT", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "sort-css-media-queries": "2.2.0" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/posthtml/node_modules/entities": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", - "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", - "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "peerDependencies": { + "postcss": "^8.4.23" } }, - "node_modules/posthtml/node_modules/htmlparser2": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", - "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.2", - "domutils": "^2.8.0", - "entities": "^3.0.1" - } + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" }, - "node_modules/posthtml/node_modules/posthtml-parser": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz", - "integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==", + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "dependencies": { - "htmlparser2": "^7.1.1" + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=12" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz", - "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==", + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", - "dependencies": { - "condense-newlines": "^0.2.1", - "extend-shallow": "^2.0.1", - "js-beautify": "^1.6.12" - }, - "engines": { - "node": ">=0.10.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, "license": "ISC" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -5457,20 +6264,22 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" }, "node_modules/query-string": { "version": "9.3.1", @@ -5489,35 +6298,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/ranges-apply": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.1.3.tgz", @@ -5568,52 +6348,26 @@ "node": ">=14.18.0" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "node": ">= 20.19.0" }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" + "regex-utilities": "^2.3.0" } }, "node_modules/regex-empty-conditional-comments": { @@ -5625,24 +6379,54 @@ "node": ">=14.18.0" } }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/reka-ui": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.4.tgz", + "integrity": "sha512-Xz8yKl+5SwhzZU6lxTX9uCWydsv9+4zyYFv/LL+paa+sqnYZohdFqgkFxtroEy75fdoyFIlCXZK1VtSF9YVESA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^14.1.0", + "@vueuse/shared": "^14.1.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.5", + "ohash": "^2.0.11" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" }, + "peerDependencies": { + "vue": ">= 3.4.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, "node_modules/restore-cursor": { @@ -5661,116 +6445,220 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.3.tgz", + "integrity": "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.112.0", + "@rolldown/pluginutils": "1.0.0-rc.3" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.3", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", + "@rolldown/binding-darwin-x64": "1.0.0-rc.3", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" + "node_modules/rolldown-plugin-dts": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.22.5.tgz", + "integrity": "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "8.0.0-rc.2", + "@babel/helper-validator-identifier": "8.0.0-rc.2", + "@babel/parser": "8.0.0-rc.2", + "@babel/types": "8.0.0-rc.2", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", + "dts-resolver": "^2.1.3", + "get-tsconfig": "^4.13.6", + "obug": "^2.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@ts-macro/tsc": "^0.3.6", + "@typescript/native-preview": ">=7.0.0-dev.20250601.1", + "rolldown": "^1.0.0-rc.3", + "typescript": "^5.0.0 || ^6.0.0-beta", + "vue-tsc": "~3.2.0" + }, + "peerDependenciesMeta": { + "@ts-macro/tsc": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "@typescript/native-preview": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true } - ], + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.2.tgz", + "integrity": "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==", + "dev": true, "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "@babel/parser": "^8.0.0-rc.2", + "@babel/types": "^8.0.0-rc.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.2.tgz", + "integrity": "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.2.tgz", + "integrity": "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { + "version": "8.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.2.tgz", + "integrity": "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.2", + "@babel/helper-validator-identifier": "^8.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, "license": "MIT" }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -5785,9 +6673,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5796,70 +6684,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5872,88 +6701,35 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -5972,6 +6748,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "inBundle": true, "license": "MIT" }, "node_modules/slick": { @@ -6001,6 +6778,28 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split-lines": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-lines/-/split-lines-3.0.0.tgz", + "integrity": "sha512-d0TpRBL/VfKDXsk8JxPF7zgF5pCUDdBMSlEL36xBgVeaX448t+yGXcJaikUyzkoKOJ0l6KpMfygzJU9naIuivw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/split-on-first": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", @@ -6019,45 +6818,22 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, - "node_modules/srcset": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/srcset/-/srcset-5.0.2.tgz", - "integrity": "sha512-pucR5KmXL7uWI59sXE2nuodomLsfnIQDa5Fck0TooiyxsIx+JYGiFm+wFO7aaDvvl/43ipjUjAb5je7dcAwlzQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "license": "MIT" }, "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", "license": "MIT", "engines": { "node": ">=18" @@ -6171,597 +6947,1085 @@ "node": ">=14.18.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylehacks": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.8.tgz", + "integrity": "sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-mixer": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/test-mixer/-/test-mixer-4.2.3.tgz", + "integrity": "sha512-6sBlzwiDARX7Qp13MwYygwjeNrtlkbHj+6t/+1OnROQWLX+rpquOc+XXbjVs5xYUtltNo6ZpvFM4nNWtOdInHw==", + "license": "MIT", + "dependencies": { + "object-boolean-combinations": "^6.2.3", + "rfdc": "^1.4.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" + "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "is-number": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=8.0" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "bin": { + "tree-kill": "cli.js" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/tsdown": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.20.3.tgz", + "integrity": "sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansis": "^4.2.0", + "cac": "^6.7.14", + "defu": "^6.1.4", + "empathic": "^2.0.0", + "hookable": "^6.0.1", + "import-without-cache": "^0.2.5", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "rolldown": "1.0.0-rc.3", + "rolldown-plugin-dts": "^0.22.1", + "semver": "^7.7.3", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.4.2", + "unrun": "^0.2.27" + }, + "bin": { + "tsdown": "dist/run.mjs" }, "engines": { - "node": ">=12" + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@vitejs/devtools": "*", + "publint": "^0.3.0", + "typescript": "^5.0.0", + "unplugin-lightningcss": "^0.4.0", + "unplugin-unused": "^0.5.0" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-lightningcss": { + "optional": true + }, + "unplugin-unused": { + "optional": true + } } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/tsdown/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=8" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=0.10.0" + "node": ">=14.17" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^9.0.1" + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unhead": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz", + "integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.7" + "hookable": "^6.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" } }, - "node_modules/stylehacks": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.7.tgz", - "integrity": "sha512-bJkD0JkEtbRrMFtwgpJyBbFIwfDDONQ1Ov3sDLZQP8HuJ73kBOyx66H4bOcAbVWmnfLdvQ0AJwXxOMkpujcO6g==", + "node_modules/unimport": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz", + "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "postcss-selector-parser": "^7.1.0" + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" }, "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependencies": { - "postcss": "^8.4.32" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" + "@types/unist": "^3.0.0" }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" }, - "engines": { - "node": ">=16 || 14 >=14.17" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "license": "MIT", - "engines": { - "node": ">= 6" + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=14.18.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", - "bin": { - "mime": "cli.js" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, - "engines": { - "node": ">=4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "license": "MIT", "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=14.18.0" + "node": ">=18.12.0" } }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, + "node_modules/unplugin-auto-import": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", "license": "MIT", + "dependencies": { + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, "engines": { - "node": ">=6.6.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^4.0.0", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/unplugin-auto-import/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "pathe": "^2.0.3", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/unplugin-utils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "node_modules/unplugin-vue-components": { + "version": "31.1.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.1.0.tgz", + "integrity": "sha512-9EbV5ark21A4BOBt6RJGJXCVD2I1eoxTZL1TAvNgYTokcrFIiuxpufb8owyWn7n+z2x8daz/ltZq6IRRKL3ydQ==", "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } } }, - "node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, + "node_modules/unplugin-vue-components/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tailwindcss/node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/unplugin-vue-markdown": { + "version": "29.2.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-markdown/-/unplugin-vue-markdown-29.2.0.tgz", + "integrity": "sha512-/x2hFgQ6cWN1Kls+yK5mAI9YDmeTofftynVGgOy1llBlDX1ifaXsQBls/bpORaiwn7cxA7HkOo0wn/xKcrXBHA==", "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "@mdit-vue/plugin-component": "^3.0.2", + "@mdit-vue/plugin-frontmatter": "^3.0.2", + "@mdit-vue/types": "^3.0.2", + "@types/markdown-it": "^14.1.2", + "markdown-it": "^14.1.0", + "markdown-it-async": "^2.2.0", + "unplugin": "^2.3.10", + "unplugin-utils": "^0.3.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "postcss": "^8.0.0" + "vite": "^2.0.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "node_modules/unrun": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.34.tgz", + "integrity": "sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" + "rolldown": "1.0.0-rc.12" + }, + "bin": { + "unrun": "dist/cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/Gugustinette" + }, + "peerDependencies": { + "synckit": "^0.11.11" + }, + "peerDependenciesMeta": { + "synckit": { + "optional": true + } } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/unrun/node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/test-mixer": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/test-mixer/-/test-mixer-4.2.3.tgz", - "integrity": "sha512-6sBlzwiDARX7Qp13MwYygwjeNrtlkbHj+6t/+1OnROQWLX+rpquOc+XXbjVs5xYUtltNo6ZpvFM4nNWtOdInHw==", + "node_modules/unrun/node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "object-boolean-combinations": "^6.2.3", - "rfdc": "^1.4.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/textextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", - "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", - "license": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, + "node_modules/unrun/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/unrun/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/unrun/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "node_modules/unrun/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "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==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/unrun/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/unrun/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "node_modules/unrun/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "node_modules/unrun/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "node_modules/unrun/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/unrun/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "is-number": "^7.0.0" + "@napi-rs/wasm-runtime": "^1.1.1" }, "engines": { - "node": ">=8.0" + "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/unrun/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/unrun/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "node_modules/unrun/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/unrun/node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">= 0.8" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, "node_modules/update-browserslist-db": { @@ -6800,15 +8064,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/valid-data-url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", @@ -6818,32 +8073,38 @@ "node": ">=10" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/version-range": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", - "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", - "license": "Artistic-2.0", - "engines": { - "node": ">=4" + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { - "url": "https://bevry.me/fund" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", - "dev": true, + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -6914,34 +8175,10 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6959,7 +8196,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6969,65 +8205,78 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -7038,6 +8287,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -7045,7 +8297,91 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", "dev": true, + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.4.tgz", + "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7054,6 +8390,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vue-router/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/web-resource-inliner": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", @@ -7070,18 +8420,31 @@ "node": ">=10.0.0" } }, - "node_modules/web-resource-inliner/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -7096,18 +8459,20 @@ } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7123,7 +8488,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -7140,6 +8504,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7158,6 +8523,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7175,6 +8541,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7184,6 +8551,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7195,52 +8563,46 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7257,6 +8619,43 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 97e9168e..6b6fe262 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,92 @@ { "name": "@maizzle/framework", - "version": "5.5.0", + "version": "6.0.0-rc.5", "description": "Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.", "license": "MIT", "type": "module", + "publishConfig": { + "access": "public" + }, "exports": { ".": { - "import": "./src/index.js", - "types": "./types/index.d.ts" + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" } }, "bin": { - "maizzle": "bin/maizzle" + "maizzle": "./bin/maizzle.mjs" }, "files": [ - "bin", - "src", - "types", - "LICENSE", - "CHANGELOG.md" + "dist", + "bin" ], - "publishConfig": { - "access": "public" - }, "scripts": { + "build": "tsdown", "dev": "vitest", - "release": "npx np", - "pretest": "npm run lint", + "lint": "oxlint", "test": "vitest run --coverage", - "lint": "biome lint ./src ./test" + "pretest": "npm run lint", + "prepublishOnly": "npm run build", + "release": "npm run build && npx np" + }, + "bundleDependencies": [ + "maizzle" + ], + "dependencies": { + "maizzle": "latest", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@unhead/vue": "^2.1.4", + "@vitejs/plugin-vue": "^6.0.4", + "@vitest/coverage-v8": "^4.0.18", + "@vueuse/core": "^14.2.1", + "caniemail": "^1.0.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "css-select": "^6.0.0", + "defu": "^6.1.4", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "email-comb": "^7.1.3", + "html-crush": "^6.1.3", + "htmlparser2": "^10.1.0", + "is-url-superb": "^6.1.0", + "jiti": "^2.6.1", + "juice": "^11.1.1", + "lucide-vue-next": "^1.0.0", + "ora": "^9.3.0", + "oxfmt": "^0.35.0", + "postcss": "^8.5.6", + "postcss-calc": "^10.1.1", + "postcss-custom-properties": "^15.0.0", + "postcss-merge-longhand": "^7.0.5", + "postcss-safe-parser": "^7.0.1", + "postcss-sort-media-queries": "^5.2.0", + "postcss-value-parser": "^4.2.0", + "query-string": "^9.3.1", + "reka-ui": "^2.9.3", + "shiki": "^4.0.2", + "string-strip-html": "^13.5.3", + "tailwind-merge": "^3.5.0", + "tinyglobby": "^0.2.15", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^31.0.0", + "unplugin-vue-markdown": "^29.2.0", + "vite": "^7.3.1", + "vue": "^3.5.28", + "vue-router": "^5.0.2" + }, + "devDependencies": { + "@types/js-beautify": "^1.14.3", + "@types/node": "^25.2.3", + "@types/postcss-safe-parser": "^5.0.4", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.6.3", + "oxlint": "^1.50.0", + "tsdown": "^0.20.3", + "vitest": "^4.0.18" }, "repository": { "type": "git", @@ -48,64 +106,5 @@ "email-newsletter", "email-boilerplate", "html-emails" - ], - "dependencies": { - "@maizzle/cli": "^2.0.0", - "cheerio": "1.0.0", - "chokidar": "^3.6.0", - "cli-table3": "^0.6.5", - "color-shorthand-hex-to-six-digit": "^5.0.16", - "defu": "^6.1.4", - "email-comb": "^7.0.21", - "express": "^4.21.0", - "fast-glob": "^3.3.2", - "gray-matter": "^4.0.3", - "html-crush": "^6.0.19", - "is-url-superb": "^6.1.0", - "istextorbinary": "^9.5.0", - "juice": "^11.1.0", - "lodash-es": "^4.17.21", - "morphdom": "^2.7.4", - "ora": "^8.1.0", - "pathe": "^2.0.0", - "postcss": "^8.4.49", - "postcss-calc": "^10.0.2", - "postcss-color-functional-notation": "^7.0.10", - "postcss-css-variables": "^0.19.0", - "postcss-import": "^16.1.0", - "postcss-safe-parser": "^7.0.0", - "postcss-sort-media-queries": "^5.2.0", - "posthtml": "^0.16.6", - "posthtml-attrs-parser": "^1.1.1", - "posthtml-base-url": "^3.1.8", - "posthtml-component": "^2.1.0", - "posthtml-content": "^2.1.0", - "posthtml-expressions": "^1.11.4", - "posthtml-extra-attributes": "^3.1.0", - "posthtml-fetch": "^4.0.0", - "posthtml-markdownit": "^3.1.0", - "posthtml-mso": "^3.1.0", - "posthtml-parser": "^0.12.1", - "posthtml-postcss": "^1.0.2", - "posthtml-postcss-merge-longhand": "^3.1.2", - "posthtml-render": "^3.0.0", - "posthtml-safe-class-names": "^4.1.0", - "posthtml-url-parameters": "^3.1.0", - "posthtml-widows": "^1.0.0", - "pretty": "^2.0.0", - "string-strip-html": "^13.4.8", - "tailwindcss": "^3.4.16", - "ws": "^8.18.0" - }, - "devDependencies": { - "@biomejs/biome": "2.2.7", - "@types/js-beautify": "^1.14.3", - "@types/markdown-it": "^14.1.2", - "@vitest/coverage-v8": "^3.0.4", - "supertest": "^7.0.0", - "vitest": "^3.0.3" - }, - "engines": { - "node": ">=18.20" - } + ] } diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 00000000..a5b11788 --- /dev/null +++ b/src/build.ts @@ -0,0 +1,177 @@ +import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, rmSync } from 'node:fs' +import { resolve, dirname, basename, relative, join } from 'node:path' +import { glob } from 'tinyglobby' +import ora from 'ora' +import { resolveConfig } from './config/index.ts' +import { EventManager } from './events/index.ts' +import { runTransformers } from './transformers/index.ts' +import { createRenderer } from './render/createRenderer.ts' +import { createPlaintext } from './plaintext.ts' +import type { MaizzleConfig } from './types/index.ts' + +export interface BuildOptions { + config?: Partial | string +} + +export interface BuildResult { + files: string[] + config: MaizzleConfig +} + +/** + * Build all SFC email templates to HTML files. + * + * Creates a single Renderer instance, then loops through each template + * calling render → transformers → write to disk. + */ +export async function build(options: BuildOptions = {}): Promise { + const start = Date.now() + const spinner = ora('Building templates...').start() + + const config = await resolveConfig(options.config) + + const events = new EventManager() + events.registerConfig(config) + await events.fireBeforeCreate({ config }) + + const outputPath = resolve(config.output?.path ?? 'dist') + const outputExtension = config.output?.extension ?? 'html' + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const contentBase = computeContentBase(contentPatterns) + const templateFiles = await glob(contentPatterns) + + if (templateFiles.length === 0) { + spinner.succeed('No templates found') + return { files: [], config } + } + + // Clear the output directory before writing fresh output + if (existsSync(outputPath)) { + rmSync(outputPath, { recursive: true, force: true }) + } + + const renderer = await createRenderer({ markdown: config.markdown, root: config.root, componentDirs: [config.components?.source ?? []].flat() }) + const outputFiles: string[] = [] + + try { + for (const templatePath of templateFiles) { + const absolutePath = resolve(templatePath) + let template = readFileSync(absolutePath, 'utf-8') + + template = await events.fireBeforeRender({ config, template }) + + const rendered = await renderer.render(absolutePath, config) + + let html = await events.fireAfterRender({ config, template, html: rendered.html }) + + // Use the per-template merged config (from defineConfig() in the SFC) so that + // template-level overrides like css.safe: false are respected by transformers. + const templateConfig = rendered.templateConfig + + const doctype = rendered.doctype ?? templateConfig.doctype ?? '' + + if (templateConfig.useTransformers !== false) { + html = await runTransformers(html, templateConfig, absolutePath, doctype) + } + + html = await events.fireAfterTransform({ config, template, html }) + html = `${doctype}\n${html}` + + const outputFilePath = resolveOutputPath(templatePath, outputPath, outputExtension, contentBase) + mkdirSync(dirname(outputFilePath), { recursive: true }) + writeFileSync(outputFilePath, html) + outputFiles.push(outputFilePath) + + // Generate plaintext version if configured + const globalPlaintext = templateConfig.plaintext + const sfcPlaintext = rendered.plaintext + + if (globalPlaintext || sfcPlaintext) { + const stripOptions = typeof globalPlaintext === 'object' ? globalPlaintext : {} + const plaintext = createPlaintext(html, stripOptions) + const ptExtension = sfcPlaintext?.extension ?? 'txt' + + let ptOutputPath: string + + if (sfcPlaintext?.destination) { + const name = basename(templatePath).replace(/\.(vue|md)$/, '') + ptOutputPath = join(resolve(sfcPlaintext.destination), `${name}.${ptExtension}`) + } else if (typeof globalPlaintext === 'string') { + ptOutputPath = resolveOutputPath(templatePath, resolve(globalPlaintext), ptExtension, contentBase) + } else { + ptOutputPath = resolveOutputPath(templatePath, outputPath, ptExtension, contentBase) + } + + mkdirSync(dirname(ptOutputPath), { recursive: true }) + writeFileSync(ptOutputPath, plaintext) + } + + // Register SFC event handlers that were collected during render + for (const { name, handler } of rendered.sfcEventHandlers) { + events.on(name, handler) + } + + events.clearSfcHandlers() + } + + await copyStatic(config, outputPath) + await events.fireAfterBuild({ files: outputFiles, config }) + } finally { + await renderer.close() + } + + const duration = ((Date.now() - start) / 1000).toFixed(2) + const count = outputFiles.length + spinner.stopAndPersist({ + symbol: '✅', + text: `Built ${count} template${count !== 1 ? 's' : ''} in ${duration}s`, + }) + + return { files: outputFiles, config } +} + +/** + * Extract the static (non-glob) prefix from content patterns. + * + * For example, `['/abs/path/emails/**\/*.vue']` → `'/abs/path/emails'` + * + * This is used to strip the content base from template paths + * so the output preserves only the subdirectory structure. + */ +function computeContentBase(patterns: string[]): string { + // Use the first non-negated pattern + const pattern = patterns.find(p => !p.startsWith('!')) ?? patterns[0] + + // Split on first glob character (* { ? [) and take the directory part + const staticPart = pattern.split(/[*{?[]/)[0] + + // Ensure we have a clean directory path (not a partial segment) + return resolve(staticPart.endsWith('/') ? staticPart : dirname(staticPart)) +} + +function resolveOutputPath(templatePath: string, outputDir: string, extension: string, contentBase: string): string { + const name = basename(templatePath).replace(/\.(vue|md)$/, '') + const absTemplate = resolve(templatePath) + const rel = relative(contentBase, dirname(absTemplate)) + + return join(outputDir, rel, `${name}.${extension}`) +} + +async function copyStatic(config: MaizzleConfig, outputPath: string): Promise { + const sources = config.static?.source ?? ['public/**/*.*'] + const destination = config.static?.destination ?? 'public' + + const files = await glob(sources) + + for (const file of files) { + const destPath = join(outputPath, destination, relative(dirname(sources[0]).replace(/\*.*$/, ''), file)) + const destDir = dirname(destPath) + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }) + } + + cpSync(file, destPath) + } +} diff --git a/src/commands/build.js b/src/commands/build.js deleted file mode 100644 index 128ae047..00000000 --- a/src/commands/build.js +++ /dev/null @@ -1,348 +0,0 @@ -import { - readFile, - writeFile, - copyFile, - lstat, - mkdir, - rm, - cp, -} from 'node:fs/promises' -import path from 'pathe' -import fg from 'fast-glob' -import { defu as merge } from 'defu' - -import get from 'lodash/get.js' -import isEmpty from 'lodash-es/isEmpty.js' - -import ora from 'ora' -import pico from 'picocolors' -import cliTable from 'cli-table3' - -import { render } from '../generators/render.js' - -import { - formatTime, - getRootDirectories, - getFileExtensionsFromPattern, -} from '../utils/string.js' - -import { getColorizedFileSize } from '../utils/node.js' - -import { - generatePlaintext, - handlePlaintextTags, - writePlaintextFile -} from '../generators/plaintext.js' - -import { readFileConfig } from '../utils/getConfigByFilePath.js' - -/** - * Ensures that a directory exists, creating it if needed. - * - * @param {string} filePath - The path to the file to check. - */ -async function ensureDirectoryExistence(filePath) { - const dirname = path.dirname(filePath) - await mkdir(dirname, { recursive: true }) -} - -/** - * Copy a file from source to target. - * - * @param {string} source - The source file path. - * @param {string} target - The target file path. - */ -async function copyFileAsync(source, target) { - await ensureDirectoryExistence(target) - await copyFile(source, target) -} - -/** - * Compile templates and output to the build directory. - * Returns a promise containing an object with files output and the config object. - * - * @param {object|string} config - The Maizzle config object, or path to a config file. - * @returns {Promise} The build output, containing the files and config. - */ -export default async (config = {}) => { - const spinner = ora() - - try { - const startTime = Date.now() - - /** - * Read the config file for this environment, - * merging it with the default config. - */ - config = await readFileConfig(config).catch(() => { throw new Error('Could not compute config') }) - - /** - * Support customizing the spinner - */ - const spinnerConfig = get(config, 'build.spinner') - - if (spinnerConfig === false) { - // Show only 'Building...' text - spinner.isEnabled = false - } else { - spinner.spinner = get(config, 'build.spinner', 'circleHalves') - } - - spinner.start('Building...') - - // Run beforeCreate event - if (typeof config.beforeCreate === 'function') { - await config.beforeCreate({ config }) - } - - const buildOutputPath = get(config, 'build.output.path', 'build_local') - - // Remove output directory - await rm(buildOutputPath, { recursive: true, force: true }) - - const table = new cliTable({ - head: ['File name', 'File size', 'Build time'].map(item => pico.bold(item)), - }) - - /** - * Check that templates to be built, actually exist - */ - const contentPaths = get(config, 'build.content', ['emails/**/*.html']) - - const templateFolders = Array.isArray(contentPaths) ? contentPaths : [contentPaths] - const templatePaths = await fg.glob([...new Set(templateFolders)]) - - // If there are no templates to build, throw error - if (templatePaths.length === 0) { - throw new Error(`No templates found in ${pico.inverse(templateFolders)}`) - } - - /** - * Copy source directories to destination - * - * Copies each `build.content` path to the `build.output.path` directory. - */ - let from = get(config, 'build.output.from', ['emails']) - - const globPathsToCopy = contentPaths.map(glob => { - // Keep negated paths as they are - if (glob.startsWith('!')) { - return glob - } - - // Keep single-file sources as they are - if (!/\*/.test(glob)) { - return glob - } - - // Update non-negated paths to target all files, avoiding duplication - return glob.replace(/\/\*\*\/\*\.\{.*?\}$|\/\*\*\/\*\.[^/]*$|\/*\.[^/]*$/, '/**/*') - }) - - try { - from = Array.isArray(from) ? from : [from] - - /** - * Copy files from source to destination - * - * The array/set conversion is to remove duplicates - */ - for (const file of await fg(Array.from(new Set(globPathsToCopy)))) { - let relativePath - for (const dir of from) { - if (file.startsWith(dir)) { - relativePath = path.relative(dir, file) - break - } - } - if (!relativePath) { - relativePath = path.relative('.', file) - } - - const targetPath = path.join(buildOutputPath, relativePath) - await copyFileAsync(file, targetPath) - } - } catch (error) { - console.error('Error while processing pattern:', error) - } - - /** - * Get a list of files to render, from the output directory - * - * Uses all file extensions from non-negated glob paths in `build.content` - * to determine which files to render from the output directory. - */ - const outputExtensions = new Set() - - for (const pattern of contentPaths) { - getFileExtensionsFromPattern(pattern).map(ext => outputExtensions.add(ext)) - } - - /** - * Create a list of templates to compile - */ - const extensions = outputExtensions.size > 1 - ? `{${[...outputExtensions].join(',')}}` - : [...outputExtensions][0] || 'html' - - const templatesToCompile = await fg.glob( - path.join( - buildOutputPath, - `**/*.${extensions}` - ) - ) - - /** - * Render templates - */ - for await (const templatePath of templatesToCompile) { - const templateBuildStartTime = Date.now() - - /** - * Add the current template path to the config - * - * Can be used in events like `beforeRender` to determine - * which template file is being rendered. - */ - config.build.current = { - path: path.parse(templatePath), - } - - const html = await readFile(templatePath, 'utf8') - - /** - * Render the markup. - * - * Merging a custom `components` object to make sure that file extensions from both - * `build.content` * and * `components.fileExtension` are used when scanning for - * component files. - */ - const userComponentFileExtensions = get(config, 'components.fileExtension', ['html']) - - const rendered = await render(html, merge( - { - components: { - fileExtension: [ - ...outputExtensions, - ...(new Set([].concat(userComponentFileExtensions))), - ], - } - }, - config - )) - - /** - * Generate plaintext - * - * We do this first so that we can remove the - * tags from the markup before outputting the file. - */ - const plaintextConfig = get(rendered.config, 'plaintext') - - if (Boolean(plaintextConfig) || !isEmpty(plaintextConfig)) { - const posthtmlOptions = get(rendered.config, 'posthtml.options', {}) - - await writePlaintextFile( - await generatePlaintext(rendered.html, merge(plaintextConfig, posthtmlOptions)), - rendered.config - ).catch(error => { - throw new Error(`Error writing plaintext file: ${error}`) - }) - - rendered.html = await handlePlaintextTags(rendered.html, posthtmlOptions) - } - - /** - * Determine output path, creating directories if needed - * - * Prioritize `permalink` path from Front Matter, - * fallback to the current template path. - * - * We do this before generating plaintext, so that - * any paths will already have been created. - */ - const outputPathFromConfig = get(rendered.config, 'permalink', templatePath) - const parsedOutputPath = path.parse(outputPathFromConfig) - // This keeps original file extension if no output extension is set - const extension = get(rendered.config, 'build.output.extension', parsedOutputPath.ext.slice(1)) - const outputPath = `${parsedOutputPath.dir}/${parsedOutputPath.name}.${extension}` - - const pathExists = await lstat(path.dirname(outputPath)).catch(() => false) - - if (!pathExists) { - await mkdir(path.dirname(outputPath), { recursive: true }) - } - - /** - * Write the rendered HTML to disk, creating directories if needed - */ - await writeFile(outputPath, rendered.html) - - /** - * Remove original file if its path is different - * from the final destination path. - */ - if (outputPath !== templatePath) { - await rm(templatePath) - } - - /** - * Add file to CLI table for build summary logging - */ - table.push([ - path.relative(get(rendered.config, 'build.output.path'), outputPath), - getColorizedFileSize(rendered.html), - formatTime(Date.now() - templateBuildStartTime) - ]) - } - - /** - * Copy static files - */ - let staticFiles = get(config, 'build.static', []) - - if (!Array.isArray(staticFiles)) { - staticFiles = [staticFiles] - } - - for (const definition of staticFiles) { - const staticSourcePaths = getRootDirectories([...new Set(definition.source)]) - - for await (const rootDir of staticSourcePaths) { - await cp(rootDir, path.join(buildOutputPath, definition.destination), { recursive: true }) - } - } - - const allOutputFiles = await fg.glob(path.join(buildOutputPath, '**/*')) - - /** - * Run `afterBuild` event - */ - if (typeof config.afterBuild === 'function') { - await config.afterBuild({ - config, - files: allOutputFiles, - }) - } - - /** - * Log a build summary if enabled in the config - */ - - spinner.clear() - - if (config.build.summary) { - console.log(table.toString() + '\n') - } - - spinner.succeed(`Built ${table.length} template${table.length > 1 ? 's' : ''} in ${formatTime(Date.now() - startTime)}`) - - return { - files: allOutputFiles, - config - } - } catch (error) { - spinner.fail('Build failed') - throw error - } -} diff --git a/src/commands/serve.js b/src/commands/serve.js deleted file mode 100644 index 2ac5bbbf..00000000 --- a/src/commands/serve.js +++ /dev/null @@ -1,3 +0,0 @@ -import server from '../server/index.js' - -export default server diff --git a/src/components/Button.vue b/src/components/Button.vue new file mode 100644 index 00000000..f9690863 --- /dev/null +++ b/src/components/Button.vue @@ -0,0 +1,192 @@ +<script setup lang="ts"> +import { computed, useAttrs } from 'vue' +import type { PropType } from 'vue' +import { twMerge } from 'tailwind-merge' +import Outlook from './Outlook.vue' + +defineOptions({ inheritAttrs: false }) + +const attrs = useAttrs() + +const props = defineProps({ + /** The URL the button links to. */ + href: String, + /** + * The button style variant. + * - `solid` — filled background (default) + * - `outline` — transparent background with a border + * - `ghost` — transparent background, no border + * - `link` — plain anchor with no button chrome + * @default 'solid' + */ + variant: { + type: String as PropType<'solid' | 'outline' | 'ghost' | 'link'>, + default: 'solid' as const + }, + /** + * Horizontal alignment of the button wrapper. + * Accepts `'left'`, `'center'`, or `'right'`. + * @default null + */ + align: { + type: String as PropType<'left' | 'center' | 'right' | null>, + default: null + }, + /** + * Background color for `solid` and `outline` variants. + * Also used as the text color for `outline` and `ghost` variants when `color` is not set. + * @default '#4338ca' + */ + bgColor: { + type: String, + default: '#4338ca' + }, + /** + * Explicit text color. When omitted, `solid` buttons use `#fffffe` + * and all other variants fall back to `bgColor`. + * @default null + */ + color: { + type: String, + default: null + }, + /** + * `mso-text-raise` value applied to the inner `<span>` elements. + * Controls vertical text alignment inside the button in old Outlook. + * @default '16px' + */ + msoPt: { + type: String, + default: '16px' + }, + /** + * `mso-text-raise` value applied to the spacer `<i>` element rendered for Outlook. + * Adjusts the bottom spacing that old Outlook uses to simulate padding. + * @default '31px' + */ + msoPb: { + type: String, + default: '31px' + }, + /** + * URL or path to an icon image displayed alongside the button label. + * @default null + */ + icon: { + type: String, + default: null + }, + /** + * Width of the icon image in pixels. + * @default 12 + */ + iconWidth: { + type: [String, Number], + default: 12 + }, + /** + * Side on which the icon is placed relative to the button label. + * Accepts `'left'` or `'right'`. + * @default 'right' + */ + iconPosition: { + type: String as PropType<'left' | 'right'>, + default: 'right' as const + }, + /** + * Additional CSS classes applied to the icon `<img>` element. + * @default '' + */ + iconClass: { + type: String, + default: '' + } +}) + +const parsedIconWidth = computed(() => parseInt(String(props.iconWidth), 10)) + +const alignClass = computed(() => props.align ? ({ + left: 'text-left', + center: 'text-center', + right: 'text-right', +})[props.align] || '' : '') + +const textColor = computed(() => { + if (props.color) return props.color + + return props.variant === 'solid' ? '#fffffe' : props.bgColor +}) + +const styles = computed(() => { + if (props.variant === 'link') { + return [ + 'text-decoration: none;', + `color: ${textColor.value};`, + ].join('') + } + + const base = [ + 'display: inline-block;', + 'text-decoration: none;', + 'padding: 16px 24px;', + 'font-size: 16px;', + 'line-height: 1;', + 'border-radius: 4px;', + `color: ${textColor.value};`, + ] + + if (props.variant === 'outline') { + base.push( + 'background-color: transparent;', + `border: 1px solid ${props.bgColor};`, + ) + } else if (props.variant === 'ghost') { + base.push('background-color: transparent;') + } else { + base.push(`background-color: ${props.bgColor};`) + } + + return base.join('') +}) + +const isLink = computed(() => props.variant === 'link') + +const defaultClasses = computed(() => { + if (props.variant === 'ghost') return 'hover:bg-indigo-50' + return '' +}) + +const mergedClass = computed(() => twMerge(defaultClasses.value, attrs.class as string)) +</script> + +<template> + <div :class="alignClass"> + <a + v-bind="{ ...$attrs, class: undefined }" + :href="href" + :style="styles" + :class="mergedClass" + > + <template v-if="!isLink"> + <Outlook><i class="mso-font-width-[150%]" :style="`mso-text-raise: ${msoPb};`" hidden>&emsp;</i></Outlook> + <template v-if="icon && iconPosition === 'left'"> + <span :style="`mso-text-raise: ${msoPt}`"> + <img :src="icon" :width="parsedIconWidth" :class="`align-baseline max-w-full ${iconClass}`"> + </span> + <Outlook><i class="mso-font-width-[30%]" hidden>&emsp;&#8203;</i></Outlook> + </template> + <span :class="icon ? (iconPosition === 'right' ? 'mr-2' : 'ml-2') : ''" :style="`mso-text-raise: ${msoPt}`"><slot /></span> + <template v-if="icon && iconPosition === 'right'"> + <Outlook><i class="mso-font-width-[30%]" hidden>&emsp;&#8203;</i></Outlook> + <span :style="`mso-text-raise: ${msoPt}`"> + <img :src="icon" :width="parsedIconWidth" :class="`align-baseline max-w-full ${iconClass}`"> + </span> + </template> + <Outlook><i class="mso-font-width-[150%]" hidden>&emsp;&#8203;</i></Outlook> + </template> + <template v-else> + <slot /> + </template> + </a> + </div> +</template> diff --git a/src/components/CodeBlock.vue b/src/components/CodeBlock.vue new file mode 100644 index 00000000..03f247d8 --- /dev/null +++ b/src/components/CodeBlock.vue @@ -0,0 +1,75 @@ +<script lang="ts"> +import { createStaticVNode } from 'vue' +import { codeToHtml, getSingletonHighlighter } from 'shiki' + +export default { + props: { + /** The code to highlight. */ + code: { + type: String, + default: '' + }, + /** Base64-encoded code, set by the Vite transform for slot content. */ + encodedCode: { + type: String, + default: '' + }, + /** The language for syntax highlighting. @default 'html' */ + lang: { + type: String, + default: 'html' + }, + /** The shiki theme to use. @default 'github-light' */ + theme: { + type: String, + default: 'github-light' + }, + /** CSS class for the wrapping table cell. @default 'max-w-0 mso-padding-alt-6' */ + tdClass: { + type: String, + default: 'max-w-0 mso-padding-alt-6' + } + }, + inheritAttrs: false, + async setup(props, { slots, attrs }) { + // Prefer encodedCode (from Vite transform) → code prop → slot text + let source = props.encodedCode + ? Buffer.from(props.encodedCode, 'base64').toString('utf-8') + : props.code + + if (!source) { + const slotContent = slots.default?.() + source = slotContent + ?.map((vnode: any) => (typeof vnode.children === 'string' ? vnode.children : '')) + .join('') ?? '' + } + + source = source.trim() + + if (!source) { + return () => createStaticVNode('', 0) + } + + const highlighted = await codeToHtml(source, { + lang: props.lang, + theme: props.theme, + }) + + const hl = await getSingletonHighlighter({ themes: [props.theme], langs: [] }) + const bg = hl.getTheme(props.theme).bg + + // Shiki outputs <pre><code>...</code></pre>, extract the inner content + const codeContent = highlighted + .replace(/^<pre[^>]*><code>/, '') + .replace(/<\/code><\/pre>$/, '') + + const classes = ['font-mono', attrs.class].filter(Boolean).join(' ') + const baseStyles = `background-color:${bg};padding:24px;overflow:auto;white-space:pre;word-wrap:normal;word-break:normal;word-spacing:normal` + const styles = [baseStyles, attrs.style].filter(Boolean).join(';') + + const html = `<table class="w-full"><tr><td class="${props.tdClass}"><pre class="${classes}" style="${styles}"><code>${codeContent}</code></pre></td></tr></table>` + + return () => createStaticVNode(html, 1) + } +} +</script> diff --git a/src/components/CodeInline.vue b/src/components/CodeInline.vue new file mode 100644 index 00000000..22711625 --- /dev/null +++ b/src/components/CodeInline.vue @@ -0,0 +1,44 @@ +<script lang="ts"> +import { createStaticVNode } from 'vue' + +export default { + inheritAttrs: false, + props: { + /** The inline code text. */ + code: { + type: String, + default: '' + } + }, + setup(props, { slots, attrs }) { + let source = props.code + + if (!source) { + const slotContent = slots.default?.() + source = slotContent + ?.map((vnode: any) => (typeof vnode.children === 'string' ? vnode.children : '')) + .join('') ?? '' + } + + source = source.trim() + + if (!source) { + return () => createStaticVNode('', 0) + } + + const classes = attrs.class ? ` class="${attrs.class}"` : '' + const baseStyles = 'white-space:normal;border-radius:6px;border:1px solid #d1d5db;background-color:#f3f4f6;padding:2px 6px;font-size:11px;color:inherit' + const styles = [baseStyles, attrs.style].filter(Boolean).join(';') + + const escaped = source + .replace(/&/g, '&amp;') + .replace(/</g, '&lt;') + .replace(/>/g, '&gt;') + .replace(/"/g, '&quot;') + + const html = `<code${classes} style="${styles}">${escaped}</code>` + + return () => createStaticVNode(html, 1) + } +} +</script> diff --git a/src/components/Divider.vue b/src/components/Divider.vue new file mode 100644 index 00000000..88933368 --- /dev/null +++ b/src/components/Divider.vue @@ -0,0 +1,105 @@ +<script setup lang="ts"> +import { computed, useAttrs } from 'vue' +import { normalizeToPixels } from './utils.ts' + +const attrs = useAttrs() + +const props = defineProps({ + height: { + type: [String, Number], + default: '1px' + }, + color: { + type: String, + default: null + }, + spaceY: { + type: [String, Number], + default: '24px' + }, + spaceX: { + type: [String, Number], + default: null + }, + top: { + type: [String, Number], + default: null + }, + bottom: { + type: [String, Number], + default: null + }, + left: { + type: [String, Number], + default: null + }, + right: { + type: [String, Number], + default: null + } +}) + +const hasBgClass = computed(() => + typeof attrs.class === 'string' && + attrs.class.split(' ').some(c => c.startsWith('bg-')) +) + +const styles = computed(() => { + const s = [] + const height = normalizeToPixels(props.height || '1px') + + s.push(`height: ${height};`) + s.push(`line-height: ${height};`) + + // Color + if (props.color) { + s.push(`background-color: ${props.color};`) + } else if (!hasBgClass.value) { + s.push('background-color: #cbd5e1;') + } + + // Margins reset + if ( + props.top != null || + props.bottom != null || + props.left != null || + props.right != null || + props.spaceY != null || + props.spaceX != null + ) { + s.push('margin: 0;') + } + + // space-y + if (props.spaceY != null) { + const v = props.spaceY === 0 ? '0px' : normalizeToPixels(props.spaceY) + s.push(`margin-top: ${v}; margin-bottom: ${v};`) + } + + // space-x + if (props.spaceX != null) { + const v = props.spaceX === 0 ? '0px' : normalizeToPixels(props.spaceX) + s.push(`margin-left: ${v}; margin-right: ${v};`) + } + + // individual margins + if (props.top != null) { + s.push(`margin-top: ${normalizeToPixels(props.top)};`) + } + if (props.bottom != null) { + s.push(`margin-bottom: ${normalizeToPixels(props.bottom)};`) + } + if (props.left != null) { + s.push(`margin-left: ${normalizeToPixels(props.left)};`) + } + if (props.right != null) { + s.push(`margin-right: ${normalizeToPixels(props.right)};`) + } + + return s.join('') +}) +</script> + +<template> + <div role="separator" :style="styles">&zwj;</div> +</template> diff --git a/src/components/NoWidows.vue b/src/components/NoWidows.vue new file mode 100644 index 00000000..2225cd81 --- /dev/null +++ b/src/components/NoWidows.vue @@ -0,0 +1,123 @@ +<script lang="ts"> +import { defineComponent, h, Fragment, createTextVNode, Text as TextVNode } from 'vue' + +/** + * Template syntax patterns whose content must never have widow + * prevention applied (the expression should stay untouched). + */ +const IGNORED_PATTERNS = [ + { start: '{{', end: '}}' }, // Handlebars / Liquid / Nunjucks / Mustache + { start: '{%', end: '%}' }, // Liquid / Nunjucks / Twig / Jinja2 + { start: '<%=', end: '%>' }, // EJS / ERB + { start: '<%', end: '%>' }, // EJS / ERB + { start: '{$', end: '}' }, // Smarty + { start: '<\\?', end: '\\?>' }, // PHP + { start: '#{', end: '}' }, // Pug +] + +/** + * Replace the space before the last word in a text string with a + * non-breaking space (U+00A0), but only when the string has at + * least `minWords` words. Template expressions are skipped. + */ +function processText(text: string, minWords: number): string { + // Split around ignored template expressions so they are never touched + let parts: string[] = [text] + + for (const { start, end } of IGNORED_PATTERNS) { + const regex = new RegExp(`(${start}.*?${end})`, 'g') + parts = parts.flatMap(part => part.split(regex)) + } + + return parts + .map((part) => { + // Pass template expressions through unchanged + for (const { start, end } of IGNORED_PATTERNS) { + if (new RegExp(`^${start}.*?${end}$`).test(part)) return part + } + + const trimmedPart = part.trimEnd() + const wordCount = trimmedPart.trim().split(/\s+/).filter(Boolean).length + + return wordCount >= minWords + ? trimmedPart.replace(/ ([^ ]+)$/gm, '\u00A0$1') + part.slice(trimmedPart.length) + : part + }) + .join('') +} + +function processChildren(children: any[], minWords: number): any[] { + return children.map((child) => { + if (child == null) return child + if (typeof child === 'string') { + return processText(child, minWords) + } + return processVNode(child, minWords) + }) +} + +/** + * Recursively walk a VNode tree and apply widow prevention to + * every text node. Component VNodes are left untouched. + */ +function processVNode(vnode: any, minWords: number): any { + if (vnode == null || typeof vnode !== 'object') return vnode + + // Text VNode — process the string content + if (vnode.type === TextVNode) { + return createTextVNode(processText(vnode.children as string, minWords)) + } + + // Fragment — recurse into children array + if (vnode.type === Fragment) { + return h( + Fragment, + null, + Array.isArray(vnode.children) + ? processChildren(vnode.children, minWords) + : vnode.children, + ) + } + + // DOM element — recurse into children, preserve props/attrs + if (typeof vnode.type === 'string' && vnode.children != null) { + if (Array.isArray(vnode.children)) { + return h(vnode.type, vnode.props, processChildren(vnode.children, minWords)) + } + if (typeof vnode.children === 'string') { + return h(vnode.type, vnode.props, processText(vnode.children, minWords)) + } + } + + // Component VNode or anything else — leave untouched + return vnode +} + +export default defineComponent({ + name: 'NoWidows', + + props: { + /** + * Minimum number of words required for widow words + * prevention to apply. Strings with fewer words + * are ignored. + * @default 4 + */ + minWords: { + type: [String, Number], + default: 4, + }, + }, + + setup(props, { slots }) { + return () => { + const minWords = typeof props.minWords === 'string' + ? parseInt(props.minWords, 10) + : props.minWords + return (slots.default?.() ?? []).map((vnode) => + processVNode(vnode, minWords), + ) + } + }, +}) +</script> diff --git a/src/components/NotOutlook.vue b/src/components/NotOutlook.vue new file mode 100644 index 00000000..ed3fb019 --- /dev/null +++ b/src/components/NotOutlook.vue @@ -0,0 +1,17 @@ +<script lang="ts"> +import { createStaticVNode } from 'vue' + +const START = '<!--[if !mso]><!-->' +const END = '<!--<![endif]-->' + +export default { + name: 'NotOutlook', + setup(_, { slots }) { + return () => [ + createStaticVNode(START, 1), + slots.default?.(), + createStaticVNode(END, 1), + ] + } +} +</script> diff --git a/src/components/Outlook.vue b/src/components/Outlook.vue new file mode 100644 index 00000000..b7fc7cfd --- /dev/null +++ b/src/components/Outlook.vue @@ -0,0 +1,74 @@ +<script lang="ts"> +import { computed, createStaticVNode } from 'vue' + +const VERSION_MAP = { + 2003: 11, + 2007: 12, + 2010: 14, + 2013: 15, + 2016: 16, + 2019: 16 +} + +const toMso = (v: string) => VERSION_MAP[v] + +const parseList = (value: string) => + value + .split(',') + .map(v => v.trim()) + .map(toMso) + .filter(Boolean) + +export default { + name: 'Outlook', + props: { + only: String, + not: String, + lt: String, + lte: String, + gt: String, + gte: String + }, + setup(props, { slots }) { + const condition = computed(() => { + if (props.only) { + const versions = parseList(props.only) + if (versions.length === 1) { + return `mso ${versions[0]}` + } + return versions.map(v => `(mso ${v})`).join('|') + } + + if (props.not) { + const versions = parseList(props.not) + if (versions.length === 1) { + return `!mso ${versions[0]}` + } + return `!(${versions.map(v => `mso ${v}`).join('|')})` + } + + const parts = [] + + if (props.lt) parts.push(`lt mso ${toMso(props.lt)}`) + if (props.lte) parts.push(`lte mso ${toMso(props.lte)}`) + if (props.gt) parts.push(`gt mso ${toMso(props.gt)}`) + if (props.gte) parts.push(`gte mso ${toMso(props.gte)}`) + + if (parts.length) { + return parts.map(p => `(${p})`).join('&') + } + + return 'mso' + }) + + const start = computed(() => `<!--[if ${condition.value}]>`) + const end = `<![endif]-->` + + return () => [ + createStaticVNode(start.value, 1), + slots.default?.(), + createStaticVNode(end, 1), + ] + } +} +</script> diff --git a/src/components/Preview.vue b/src/components/Preview.vue new file mode 100644 index 00000000..94f61a88 --- /dev/null +++ b/src/components/Preview.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +defineProps({ + /** Number of `&#8199;&#847;` filler pairs to render. */ + fillerCount: { + type: Number, + default: 150 + }, + /** Number of `&shy;` entities to render. */ + shyCount: { + type: Number, + default: 150 + } +}) +</script> + +<template> + <Teleport to="body:start"> + <div style="display:none"><slot /><template v-for="i in fillerCount" :key="'f'+i">&#8199;&#847; </template><template v-for="i in shyCount" :key="'s'+i">&shy; </template>&nbsp;</div> + </Teleport> +</template> diff --git a/src/components/Spacer.vue b/src/components/Spacer.vue new file mode 100644 index 00000000..c2ed6919 --- /dev/null +++ b/src/components/Spacer.vue @@ -0,0 +1,36 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { normalizeToPixels } from './utils.ts' + +const props = defineProps({ + /** The height of the spacer. */ + size: { + type: [String, Number], + default: null + }, + /** The alternative height to use in Outlook. */ + msoHeight: { + type: [String, Number], + default: null + } +}) + +const styles = computed(() => { + const s = [] + + if (props.size) { + s.push(`line-height: ${normalizeToPixels(props.size)};`) + } + + if (props.msoHeight) { + s.push(`mso-line-height-alt: ${normalizeToPixels(props.msoHeight)};`) + } + + return s.join('') +}) +</script> + +<template> + <div v-if="size" role="separator" :style="styles">&zwj;</div> + <div v-else role="separator">&zwj;</div> +</template> diff --git a/src/components/Vml.vue b/src/components/Vml.vue new file mode 100644 index 00000000..3e4914ed --- /dev/null +++ b/src/components/Vml.vue @@ -0,0 +1,89 @@ +<script lang="ts"> +import { computed, createStaticVNode } from 'vue' +import { normalizeToPixels } from './utils.ts' + +export default { + name: 'Vml', + props: { + width: { + type: [String, Number], + default: '600px' + }, + height: { + type: [String, Number], + default: null + }, + type: { + type: String, + default: 'frame' + }, + sizes: String, + origin: String, + position: String, + aspect: String, + color: String, + inset: { + type: String, + default: '0,0,0,0' + }, + stroke: { + type: String, + default: 'f' + }, + strokecolor: String, + fill: { + type: String, + default: 't' + }, + fillcolor: { + type: String, + default: 'none' + }, + src: { + type: String, + default: 'https://via.placeholder.com/600x400' + } + }, + setup(props, { slots }) { + const before = computed(() => { + const width = normalizeToPixels(props.width) + + const rectAttrs = [ + `fill="${props.fillcolor ? 't' : props.fill}"`, + `stroke="${props.strokecolor ? 't' : props.stroke}"`, + `style="width: ${width};${props.height ? ` height: ${normalizeToPixels(props.height)};` : ''}"`, + props.strokecolor ? `strokecolor="${props.strokecolor}"` : '', + props.fillcolor ? `fillcolor="${props.fillcolor}"` : '' + ].filter(Boolean).join(' ') + + const fillAttrs = [ + `type="${props.type}"`, + `src="${props.src}"`, + props.sizes ? `sizes="${props.sizes}"` : '', + props.aspect ? `aspect="${props.aspect}"` : '', + props.origin ? `origin="${props.origin}"` : '', + props.position ? `position="${props.position}"` : '', + props.color ? `color="${props.color}"` : '' + ].filter(Boolean).join(' ') + + return [ + `<!--[if mso]>`, + `<v:rect xmlns:v="urn:schemas-microsoft-com:vml" ${rectAttrs}>`, + `<v:fill ${fillAttrs} />`, + `<v:textbox inset="${props.inset}" style="mso-fit-shape-to-text: true">`, + `<div><![endif]-->` + ].join('') + }) + + const after = computed(() => { + return `<!--[if mso]></div></v:textbox></v:rect><![endif]-->` + }) + + return () => [ + createStaticVNode(before.value, 1), + slots.default?.(), + createStaticVNode(after.value, 1) + ] + } +} +</script> diff --git a/src/components/WithUrl.vue b/src/components/WithUrl.vue new file mode 100644 index 00000000..12142a2b --- /dev/null +++ b/src/components/WithUrl.vue @@ -0,0 +1,190 @@ +<script lang="ts"> +import { defineComponent, h, Fragment } from 'vue' +import queryString from 'query-string' +import isUrl from 'is-url-superb' + +const defaultTags: Record<string, string[]> = { + a: ['href'], + img: ['src', 'srcset'], + video: ['src', 'poster'], + source: ['src', 'srcset'], + link: ['href'], + script: ['src'], + object: ['data'], + embed: ['src'], + iframe: ['src'], + 'v:image': ['src'], + 'v:fill': ['src'], +} + +const urlAttributes = [...new Set(Object.values(defaultTags).flat())] + +function isAbsoluteUrl(url: string): boolean { + if (!url) return true + + return url.startsWith('//') || url.startsWith('#') || url.startsWith('?') || isUrl(url) +} + +function processSrcset(srcset: string, baseUrl: string): string { + return srcset.split(',').map(entry => { + const parts = entry.trim().split(/\s+/) + + if (parts[0] && !isAbsoluteUrl(parts[0])) { + parts[0] = baseUrl + parts[0] + } + + return parts.join(' ') + }).join(', ') +} + +// ─── Base URL helpers ──────────────────────────────────────────────────────── + +/** + * Join a base URL and a relative path, normalising slashes between them so + * neither the user nor the template author needs to worry about trailing / + * on the base or leading / on the path. + * + * Examples: + * joinUrl('https://example.com', 'about') → 'https://example.com/about' + * joinUrl('https://example.com/', '/about') → 'https://example.com/about' + * joinUrl('https://cdn.example.com/assets', 'image.jpg') → 'https://cdn.example.com/assets/image.jpg' + */ +function joinUrl(base: string, path: string): string { + return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '') +} + +function applyBaseUrl(attrs: string[], props: Record<string, any> | null, baseUrl: string): Record<string, any> | null { + if (!props) return props + + let newProps: Record<string, any> | undefined + + for (const attr of attrs) { + const value = props[attr] + + if (typeof value !== 'string' || isAbsoluteUrl(value)) continue + + if (!newProps) newProps = { ...props } + + newProps[attr] = attr === 'srcset' + ? processSrcset(value, baseUrl.replace(/\/+$/, '') + '/') + : joinUrl(baseUrl, value) + } + + return newProps ?? props +} + +// ─── Query parameter helpers ────────────────────────────────────────────────── + +function applyQueryParams(attrs: string[], props: Record<string, any> | null, params: Record<string, unknown>): Record<string, any> | null { + if (!props) return props + + let newProps: Record<string, any> | undefined + + for (const attr of attrs) { + const value = props[attr] + + if (typeof value !== 'string' || !value) continue + + const updated = queryString.stringifyUrl( + { url: value, query: params as queryString.StringifiableRecord }, + { encode: false }, + ) + + if (updated === value) continue + + if (!newProps) newProps = { ...props } + + newProps[attr] = updated + } + + return newProps ?? props +} + +// ─── VNode walker ───────────────────────────────────────────────────────────── + +function processVNode(vnode: any, baseUrl: string | undefined, params: Record<string, unknown> | undefined): any { + if (vnode == null || typeof vnode !== 'object') return vnode + + if (vnode.type === Fragment) { + return h( + Fragment, + null, + Array.isArray(vnode.children) + ? vnode.children.map((child: any) => processVNode(child, baseUrl, params)) + : vnode.children, + ) + } + + if (typeof vnode.type === 'string') { + const attrs = defaultTags[vnode.type] + let newProps = vnode.props + + if (attrs) { + if (baseUrl) newProps = applyBaseUrl(attrs, newProps, baseUrl) + if (params) newProps = applyQueryParams(attrs, newProps, params) + } + + const newChildren = Array.isArray(vnode.children) + ? vnode.children.map((child: any) => processVNode(child, baseUrl, params)) + : vnode.children + + return h(vnode.type, newProps, newChildren) + } + + // Component VNode — rewrite any URL-like props before the component renders + if (typeof vnode.type === 'object' || typeof vnode.type === 'function') { + let newProps = vnode.props + + if (baseUrl) newProps = applyBaseUrl(urlAttributes, newProps, baseUrl) + if (params) newProps = applyQueryParams(urlAttributes, newProps, params) + + return h(vnode.type, newProps, vnode.children) + } + + return vnode +} + +/** + * Maizzle component that rewrites URL attributes in all child elements. + * + * - `base` — optional base URL prepended to all relative URLs + * - `parameters` — optional query string (e.g. `"utm_source=foo&bar=baz"`) + * appended to all URLs + * + * @see https://maizzle.com/docs/components/with-url + */ +export default defineComponent({ + name: 'WithUrl', + + props: { + /** + * Base URL to prepend to all relative URLs found in child elements. + */ + base: { + type: String, + default: undefined, + }, + + /** + * Query parameters to append to all URLs found in child elements. + * Provide as a query string, e.g. `"utm_source=foo&bar=baz"`. + */ + parameters: { + type: String, + default: undefined, + }, + }, + + setup(props, { slots }) { + return () => { + const params = props.parameters + ? queryString.parse(props.parameters, { decode: false }) + : undefined + + return (slots.default?.() ?? []).map( + (vnode: any) => processVNode(vnode, props.base, params), + ) + } + }, +}) +</script> diff --git a/src/components/utils.ts b/src/components/utils.ts new file mode 100644 index 00000000..3441dfe2 --- /dev/null +++ b/src/components/utils.ts @@ -0,0 +1,6 @@ +export function normalizeToPixels(value: string | number): string { + if (typeof value === 'number' || Number.isFinite(Number(value))) { + return `${value}px` + } + return value +} diff --git a/src/composables/defineConfig.ts b/src/composables/defineConfig.ts new file mode 100644 index 00000000..17fcd245 --- /dev/null +++ b/src/composables/defineConfig.ts @@ -0,0 +1,38 @@ +import { getCurrentInstance, inject, provide } from 'vue' +import { createDefu } from 'defu' +import { MaizzleConfigKey } from './useConfig.ts' +import { RenderContextKey } from './renderContext.ts' +import type { MaizzleConfig } from '../types/index.ts' + +const merge = createDefu((obj, key, value) => { + if (Array.isArray(obj[key])) { + obj[key] = value + return true + } +}) + +/** + * Define Maizzle config. + * + * Works in both contexts: + * - In maizzle.config.ts: typed identity function, returns the config as-is + * - In Vue SFC <script setup>: merges with the global config and provides + * the result to child components via useConfig() + */ +export function defineConfig(data: Partial<MaizzleConfig> = {}): MaizzleConfig { + // Inside a Vue SFC — merge with global config and provide to children + if (getCurrentInstance()) { + const globalConfig = inject(MaizzleConfigKey, {} as MaizzleConfig) + const merged = merge(data, globalConfig) as MaizzleConfig + + const ctx = inject(RenderContextKey) + if (ctx) ctx.sfcConfig = merged + + provide(MaizzleConfigKey, merged) + + return merged + } + + // Outside Vue (maizzle.config.ts) — just return the config + return data as MaizzleConfig +} diff --git a/src/composables/renderContext.ts b/src/composables/renderContext.ts new file mode 100644 index 00000000..f27e1853 --- /dev/null +++ b/src/composables/renderContext.ts @@ -0,0 +1,14 @@ +import type { InjectionKey } from 'vue' +import type { MaizzleConfig } from '../types/index.ts' +import type { EventName, EventMap } from '../events/index.ts' +import type { UsePlaintextOptions } from './usePlaintext.ts' + +export interface RenderContext { + doctype?: string + previewText?: { text: string; fillerCount: number; shyCount: number } + sfcConfig?: MaizzleConfig + sfcEventHandlers: Array<{ name: EventName; handler: EventMap[EventName] }> + plaintext?: UsePlaintextOptions +} + +export const RenderContextKey: InjectionKey<RenderContext> = Symbol('RenderContext') diff --git a/src/composables/useConfig.ts b/src/composables/useConfig.ts new file mode 100644 index 00000000..175fc3e9 --- /dev/null +++ b/src/composables/useConfig.ts @@ -0,0 +1,15 @@ +import { inject } from 'vue' +import type { InjectionKey } from 'vue' +import type { MaizzleConfig } from '../types/index.ts' + +export const MaizzleConfigKey: InjectionKey<MaizzleConfig> = Symbol('MaizzleConfig') + +export function useConfig(): MaizzleConfig { + const config = inject(MaizzleConfigKey) + + if (!config) { + throw new Error('useConfig() requires the Maizzle plugin to provide config. Make sure you are using it inside a Maizzle template.') + } + + return config +} diff --git a/src/composables/useDoctype.ts b/src/composables/useDoctype.ts new file mode 100644 index 00000000..497f218b --- /dev/null +++ b/src/composables/useDoctype.ts @@ -0,0 +1,15 @@ +import { inject } from 'vue' +import { RenderContextKey } from './renderContext.ts' + +/** + * Set the doctype for the current email template. + * + * Usage in SFC <script setup>: + * ```ts + * useDoctype('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">') + * ``` + */ +export function useDoctype(doctype: string): void { + const ctx = inject(RenderContextKey) + if (ctx) ctx.doctype = doctype +} diff --git a/src/composables/useEvent.ts b/src/composables/useEvent.ts new file mode 100644 index 00000000..db99a8ab --- /dev/null +++ b/src/composables/useEvent.ts @@ -0,0 +1,18 @@ +import { inject } from 'vue' +import { RenderContextKey } from './renderContext.ts' +import type { EventName, EventMap } from '../events/index.ts' + +/** + * Register an event handler from within an SFC's <script setup>. + * + * Usage: + * ```ts + * useEvent('beforeRender', ({ config, template }) => { + * return template.replace('foo', 'bar') + * }) + * ``` + */ +export function useEvent<K extends EventName>(name: K, handler: EventMap[K]) { + const ctx = inject(RenderContextKey) + if (ctx) ctx.sfcEventHandlers.push({ name, handler }) +} diff --git a/src/composables/usePlaintext.ts b/src/composables/usePlaintext.ts new file mode 100644 index 00000000..8f6c290f --- /dev/null +++ b/src/composables/usePlaintext.ts @@ -0,0 +1,22 @@ +import { inject } from 'vue' +import { RenderContextKey } from './renderContext.ts' + +export interface UsePlaintextOptions { + extension?: string + destination?: string +} + +/** + * Enable plaintext generation for the current email template. + * + * Usage in SFC <script setup>: + * ```ts + * usePlaintext() + * usePlaintext({ extension: 'text' }) + * usePlaintext({ destination: '/custom/path' }) + * ``` + */ +export function usePlaintext(options?: UsePlaintextOptions): void { + const ctx = inject(RenderContextKey) + if (ctx) ctx.plaintext = options ?? {} +} diff --git a/src/composables/usePreviewText.ts b/src/composables/usePreviewText.ts new file mode 100644 index 00000000..3c57f43a --- /dev/null +++ b/src/composables/usePreviewText.ts @@ -0,0 +1,33 @@ +import { inject } from 'vue' +import { RenderContextKey } from './renderContext.ts' + +export interface UsePreviewTextOptions { + /** Number of &#8199;&#847; filler pairs to render. @default 150 */ + fillerCount?: number + /** Number of &shy; entities to render. @default 150 */ + shyCount?: number +} + +/** + * Set the preview/preheader text for the current email template. + * + * Injects a hidden `<div>` at the start of `<body>` with the preview text + * followed by filler characters that prevent email clients from pulling + * in body content after the preheader. + * + * Usage in SFC <script setup>: + * ```ts + * usePreviewText('Thanks for signing up!') + * usePreviewText('Welcome!', { fillerCount: 200, shyCount: 200 }) + * ``` + */ +export function usePreviewText(text: string, options?: UsePreviewTextOptions): void { + const ctx = inject(RenderContextKey) + if (ctx) { + ctx.previewText = { + text, + fillerCount: options?.fillerCount ?? 150, + shyCount: options?.shyCount ?? 150, + } + } +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 00000000..f8170abe --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,27 @@ +import type { MaizzleConfig } from '../types/index.ts' + +export const defaults: MaizzleConfig = { + content: ['emails/**/*.{vue,md}'], + output: { + path: 'dist', + extension: 'html', + }, + static: { + source: ['public/**/*.*'], + destination: 'public', + }, + server: { + port: 3000, + watch: [], + }, + css: { + safe: true, + preferUnitless: true, + resolveCalc: true, + resolveProps: true, + }, + html: { + decodeEntities: true, + }, + useTransformers: true, +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..1919b307 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,125 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { createJiti } from 'jiti' +import { fileURLToPath } from 'node:url' +import { createDefu } from 'defu' + +// defu that replaces arrays: if user provides content: ['x'], it replaces the default, not appends +const merge = createDefu((obj, key, value) => { + if (Array.isArray(obj[key])) { + obj[key] = value + return true + } +}) +import { defaults } from './defaults.ts' +import type { MaizzleConfig } from '../types/index.ts' + +export { defineConfig } from '../composables/defineConfig.ts' +export { defaults } from './defaults.ts' + +const CONFIG_FILES = [ + 'maizzle.config.ts', + 'maizzle.config.js', +] + +/** + * Resolve the Maizzle config. + * + * Always loads from the config file on disk (maizzle.config.{ts,js}), + * then merges the programmatic config on top, then fills in defaults. + */ +export async function resolveConfig( + config?: Partial<MaizzleConfig> | string, + cwd: string = process.cwd(), +): Promise<MaizzleConfig> { + // If a string path was provided, load that specific file + const fileConfig = await loadConfig( + typeof config === 'string' ? config : undefined, + cwd, + ) + + // Programmatic config (object) overrides file config, which overrides defaults + const programmaticConfig = typeof config === 'object' && config !== null ? config : {} + + const merged = merge(programmaticConfig, fileConfig, defaults) as MaizzleConfig + + // Check if root was explicitly provided before resolving + const hasExplicitRoot = !!(programmaticConfig.root ?? fileConfig.root) + + // Resolve root to an absolute path (defaults to cwd) + const root = resolve(cwd, merged.root ?? '.') + merged.root = root + + // Resolve content patterns relative to root + if (merged.content) { + merged.content = merged.content.map(p => { + // Skip already-absolute or negated patterns + if (p.startsWith('/') || p.startsWith('!')) return p + return resolve(root, p).replace(/\\/g, '/') + }) + } + + // Resolve static source patterns relative to root + if (merged.static?.source) { + merged.static.source = merged.static.source.map(p => { + if (p.startsWith('/') || p.startsWith('!')) return p + return resolve(root, p).replace(/\\/g, '/') + }) + } + + // Resolve components.source paths relative to cwd (not root), + // since extra component dirs often live outside the root directory + if (merged.components?.source) { + const dirs = Array.isArray(merged.components.source) + ? merged.components.source + : [merged.components.source] + + merged.components.source = dirs.map(p => { + if (p.startsWith('/')) return p + return resolve(cwd, p) + }) + } + + // Default css.base to root when root is explicitly set, + // so Tailwind resolves @source from the right directory. + // When root is not set, leave css.base undefined so Tailwind + // uses its own default (the template file's directory). + if (hasExplicitRoot && !merged.css?.base) { + if (!merged.css) merged.css = {} + merged.css.base = root + } + + return merged +} + +async function loadConfig( + configPath?: string, + cwd: string = process.cwd(), +): Promise<MaizzleConfig> { + const jiti = createJiti(fileURLToPath(import.meta.url), { moduleCache: false }) + + // If an explicit path was provided, use it directly + if (configPath) { + const absolutePath = resolve(cwd, configPath) + + if (!existsSync(absolutePath)) { + throw new Error(`Config file not found: ${absolutePath}`) + } + + const mod = await jiti.import(absolutePath) as any + return mod.default ?? mod + } + + // Otherwise scan cwd for known config file names + for (const filename of CONFIG_FILES) { + const filepath = resolve(cwd, filename) + + if (existsSync(filepath)) { + const mod = await jiti.import(filepath) as any + return mod.default ?? mod + } + } + + // No config file found, return empty (defaults will be applied by resolveConfig) + return {} +} diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 00000000..d86b922a --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,145 @@ +import type { MaizzleConfig } from '../types/index.ts' + +export type EventName = 'beforeCreate' | 'beforeRender' | 'afterRender' | 'afterTransform' | 'afterBuild' + +export interface EventMap { + beforeCreate: (params: { config: MaizzleConfig }) => void | Promise<void> + beforeRender: (params: { config: MaizzleConfig; template: string }) => string | void | Promise<string | void> + afterRender: (params: { config: MaizzleConfig; template: string; html: string }) => string | void | Promise<string | void> + afterTransform: (params: { config: MaizzleConfig; template: string; html: string }) => string | void | Promise<string | void> + afterBuild: (params: { files: string[]; config: MaizzleConfig }) => void | Promise<void> +} + +/** + * Central event manager that collects handlers from config and useEvent() calls. + * + * Handlers are run in order: config handler first, then SFC handlers in registration order. + * For events that return a value (beforeRender, afterRender, afterTransform), + * the returned value replaces the corresponding input for the next handler. + */ +export class EventManager { + private handlers = new Map<EventName, EventMap[EventName][]>() + + /** + * Register handlers from the Maizzle config. + */ + registerConfig(config: MaizzleConfig) { + const eventNames: EventName[] = ['beforeCreate', 'beforeRender', 'afterRender', 'afterTransform', 'afterBuild'] + + for (const name of eventNames) { + const handler = config[name] + if (typeof handler === 'function') { + this.on(name, handler as EventMap[typeof name]) + } + } + } + + /** + * Register a handler for an event (used by useEvent composable). + */ + on<K extends EventName>(name: K, handler: EventMap[K]) { + if (!this.handlers.has(name)) { + this.handlers.set(name, []) + } + + this.handlers.get(name)!.push(handler) + } + + /** + * Fire beforeCreate — runs all handlers, config is mutated in place. + */ + async fireBeforeCreate(params: { config: MaizzleConfig }) { + const handlers = this.handlers.get('beforeCreate') ?? [] + + for (const handler of handlers) { + await (handler as EventMap['beforeCreate'])(params) + } + } + + /** + * Fire beforeRender — if a handler returns a string, it replaces `template`. + */ + async fireBeforeRender(params: { config: MaizzleConfig; template: string }): Promise<string> { + const handlers = this.handlers.get('beforeRender') ?? [] + + let { template } = params + + for (const handler of handlers) { + const result = await (handler as EventMap['beforeRender'])({ config: params.config, template }) + + if (typeof result === 'string') { + template = result + } + } + + return template + } + + /** + * Fire afterRender — if a handler returns a string, it replaces `html`. + */ + async fireAfterRender(params: { config: MaizzleConfig; template: string; html: string }): Promise<string> { + const handlers = this.handlers.get('afterRender') ?? [] + + let { html } = params + + for (const handler of handlers) { + const result = await (handler as EventMap['afterRender'])({ config: params.config, template: params.template, html }) + + if (typeof result === 'string') { + html = result + } + } + + return html + } + + /** + * Fire afterTransform — if a handler returns a string, it replaces `html`. + */ + async fireAfterTransform(params: { config: MaizzleConfig; template: string; html: string }): Promise<string> { + const handlers = this.handlers.get('afterTransform') ?? [] + + let { html } = params + + for (const handler of handlers) { + const result = await (handler as EventMap['afterTransform'])({ config: params.config, template: params.template, html }) + + if (typeof result === 'string') { + html = result + } + } + + return html + } + + /** + * Fire afterBuild — runs all handlers with the file list. + */ + async fireAfterBuild(params: { files: string[]; config: MaizzleConfig }) { + const handlers = this.handlers.get('afterBuild') ?? [] + + for (const handler of handlers) { + await (handler as EventMap['afterBuild'])(params) + } + } + + /** + * Clear all handlers (useful between builds or for per-template SFC handlers). + */ + clearSfcHandlers() { + // We keep the first handler per event (from config) and remove the rest (from SFCs) + for (const [name, handlers] of this.handlers) { + if (handlers.length > 1) { + this.handlers.set(name, [handlers[0]]) + } + } + } + + /** + * Clear all handlers entirely. + */ + clear() { + this.handlers.clear() + } +} diff --git a/src/generators/plaintext.js b/src/generators/plaintext.js deleted file mode 100644 index 916ca3c6..00000000 --- a/src/generators/plaintext.js +++ /dev/null @@ -1,222 +0,0 @@ -import path from 'pathe' -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' -import { defu as merge } from 'defu' -import { stripHtml } from 'string-strip-html' -import { writeFile, lstat, mkdir } from 'node:fs/promises' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -/** - * Removes HTML tags from a given HTML string based on - * a specified tag name or an array of tag names. - * - * @param {Object} options - The options object. - * @param {string|string[]} [options.tag='not-plaintext'] - The tag name or an array of tag names to remove from the HTML. - * @param {string} [options.html=''] - The HTML string from which to remove the tags. - * @param {Object} [options.config={}] - PostHTML options. - * @returns {string} - The HTML string with the specified tags removed. - */ -const removeTags = ({ tag = 'not-plaintext', html = '', config = {} }) => { - /** - * If the HTML string is empty, return it as is - */ - if (!html) { - return html - } - - const posthtmlPlugin = () => tree => { - const process = node => { - if (!node.tag) { - return node - } - - /** - * If the tag is a string and it matches the node tag, remove it - */ - if (node.tag === tag) { - return { - tag: false, - content: [''] - } - } - - return node - } - - return tree.walk(process) - } - - const posthtmlOptions = merge(config, getPosthtmlOptions()) - - return posthtml([posthtmlPlugin()]).process(html, { ...posthtmlOptions }).then(res => res.html) -} - -/** - * Handles custom <plaintext> tags and replaces their content based on the tag name. - * - * @param {Object} options - The options object. - * @param {string} [options.html=''] - The HTML string containing custom tags to be processed. - * @param {Object} [options.config={}] - PostHTML options. - * @returns {string} - The modified HTML string after processing custom tags. - */ -export async function handlePlaintextTags(html = '', config = {}) { - /** - * If the HTML string is empty, return early - */ - if (!html) { - return html - } - - const posthtmlPlugin = () => tree => { - const process = node => { - /** - * Remove <plaintext> tags and their content from the HTML - */ - if (node.tag === 'plaintext') { - return { - tag: false, - content: [''] - } - } - - /** - * Replace <not-plaintext> tags with their content - */ - if (node.tag === 'not-plaintext') { - return { - tag: false, - content: tree.render(node.content) - } - } - - return node - } - - return tree.walk(process) - } - - const posthtmlOptions = merge(config, getPosthtmlOptions()) - - return posthtml([posthtmlPlugin()]).process(html, { ...posthtmlOptions }).then(res => res.html) -} - -/** - * Generate a plaintext representation from the provided HTML. - * - * @param {Object} options - The options object. - * @param {string} [options.html=''] - The HTML string to convert to plaintext. - * @param {Object} [options.config={}] - Configuration object. - * @returns {Promise<string>|void} - The generated plaintext as a string. - */ -export async function generatePlaintext(html = '', config = {}) { - const { posthtml: posthtmlOptions, ...stripOptions } = config - - /** - * Remove <not-plaintext> tags and their content from the HTML. - * `config` is an object containing PostHTML options. - */ - html = await removeTags({ tag: 'not-plaintext', html, config: posthtmlOptions }) - - /** - * Return the plaintext representation from the stripped HTML. - * The `dumpLinkHrefsNearby` option is enabled by default. - */ - return stripHtml( - html, - merge( - stripOptions, - { - dumpLinkHrefsNearby: { - enabled: true, - }, - }, - ) - ).result -} - -export async function writePlaintextFile(plaintext = '', config = {}) { - if (!plaintext) { - throw new Error('Missing plaintext content.') - } - - if (typeof plaintext !== 'string') { - throw new Error('Plaintext content must be a string.') - } - - /** - * Get plaintext output path config, i.e `config.plaintext.destination.path` - * - * Fall back to template's build output path and extension, for example: - * `config.build.output.path` - */ - const plaintextConfig = get(config, 'plaintext') - let plaintextOutputPath = get(plaintextConfig, 'output.path', '') - const plaintextExtension = get(plaintextConfig, 'output.extension', 'txt') - - /** - * If `plaintext: true` (either from Front Matter or from config), - * output plaintext file in the same location as the HTML file. - */ - if (plaintextConfig === true) { - plaintextOutputPath = '' - } - - /** - * If `plaintext: path/to/file.ext` in the FM - * Can't work if set in config.js as file path, because it would be the same for all templates - * We check later if it's a dir path, won't work if it's a file path - */ - if (typeof plaintextConfig === 'string') { - plaintextOutputPath = plaintextConfig - } - - // No need to handle if it's an object, since we already set it to that initially - - /** - * If the template has a `permalink` key set in the FM, always output plaintext file there - */ - if (typeof config.permalink === 'string') { - plaintextOutputPath = config.permalink - } - - /** - * If `plaintextOutputPath` is a file path, output file there. - * - * The file will be output relative to the project root, and the extension - * doesn't matter, it will be replaced with `plaintextExtension`. - */ - if (path.extname(plaintextOutputPath)) { - // Ensure the target directory exists - await lstat(plaintextOutputPath).catch(async () => { - await mkdir(path.dirname(plaintextOutputPath), { recursive: true }) - }) - - // Ensure correct extension is used - plaintextOutputPath = path.join( - path.dirname(plaintextOutputPath), - path.basename(plaintextOutputPath, path.extname(plaintextOutputPath)) + '.' + plaintextExtension - ) - - return writeFile(plaintextOutputPath, plaintext) - } - - /** - * If `plaintextOutputPath` is a directory path, output file there using the Template's name. - * - * The file will be output relative to the `build.output.path` directory. - */ - const templateFileName = get(config, 'build.current.path.name') - - plaintextOutputPath = path.join( - get(config, 'build.current.path.dir'), - plaintextOutputPath, - templateFileName + '.' + plaintextExtension - ) - - // Ensure the target directory exists - await lstat(path.dirname(plaintextOutputPath)).catch(async () => { - await mkdir(path.dirname(plaintextOutputPath), { recursive: true }) - }) - - return writeFile(plaintextOutputPath, plaintext) -} diff --git a/src/generators/render.js b/src/generators/render.js deleted file mode 100644 index c429d981..00000000 --- a/src/generators/render.js +++ /dev/null @@ -1,129 +0,0 @@ -import { parse } from 'pathe' -import posthtml from 'posthtml' -import { cwd } from 'node:process' -import { defu as merge } from 'defu' -import expressions from 'posthtml-expressions' -import { parseFrontMatter } from '../utils/node.js' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' -import { process as compilePostHTML } from '../posthtml/index.js' -import { run as useTransformers } from '../transformers/index.js' - -export async function render(html = '', config = {}) { - if (typeof html !== 'string') { - throw new TypeError(`first argument must be a string, received ${html}`) - } - - if (html.length === 0) { - throw new RangeError('received empty string') - } - - /** - * Parse front matter - * - * Parse expressions in front matter and add to config - * This could be handled by components() but plugins aren't working with it currently - */ - let { content, matter } = parseFrontMatter(html) - - /** - * Compute template config - * - * Merge it with front matter data and set the `cwd` for Tailwind. - */ - const { data: matterData } = await posthtml( - [ - expressions({ - strictMode: false, - missingLocal: '{local}', - locals: { - page: config - } - }) - ] - ) - .process(matter, getPosthtmlOptions()) - .then(({ html }) => parseFrontMatter(`---${html}\n---`)) - - const templateConfig = merge(matterData, config) - - /** - * Used for PostCSS `from` to make `@config` work in Tailwind - * - * @todo use only when in Node environment - */ - templateConfig.cwd = parse(cwd()).base - - /** - * Run `beforeRender` event - * - * @param {Object} options - * @param {string} options.html - The HTML to be transformed - * @param {Object} options.matter - The front matter data - * @param {Object} options.config - The current template config - * @returns {string} - The transformed HTML, or the original one if nothing was returned - */ - if (typeof templateConfig.beforeRender === 'function') { - content = await templateConfig.beforeRender(({ - html: content, - matter: matterData, - config: templateConfig, - })) ?? content - } - - /** - * Compile PostHTML - */ - const compiled = await compilePostHTML(content, templateConfig) - - /** - * Run `afterRender` event - * - * @param {Object} options - * @param {string} options.html - The HTML to be transformed - * @param {Object} options.matter - The front matter data - * @param {Object} options.config - The current template config - * @returns {string} - The transformed HTML, or the original one if nothing was returned - */ - if (typeof templateConfig.afterRender === 'function') { - compiled.html = await templateConfig.afterRender(({ - html: compiled.html, - matter: matterData, - config: compiled.config, - })) ?? compiled.html - } - - /** - * Run Transformers - * - * Runs only if `useTransformers` is not explicitly disabled in the config. - * - * @param {string} html - The HTML to be transformed - * @param {Object} config - The current template config - * @returns {string} - The transformed HTML - */ - if (templateConfig.useTransformers !== false) { - compiled.html = await useTransformers(compiled.html, compiled.config).then(({ html }) => html) - } - - /** - * Run `afterTransformers` event - * - * @param {Object} options - * @param {string} options.html - The HTML to be transformed - * @param {Object} options.matter - The front matter data - * @param {Object} options.config - The current template config - * @returns {string} - The transformed HTML, or the original one if nothing was returned - */ - if (typeof templateConfig.afterTransformers === 'function') { - compiled.html = await templateConfig.afterTransformers(({ - html: compiled.html, - matter: matterData, - config: compiled.config, - })) ?? compiled.html - } - - return { - config: compiled.config, - html: compiled.html, - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index a81fa546..00000000 --- a/src/index.js +++ /dev/null @@ -1,49 +0,0 @@ -import serve from './server/index.js' -import build from './commands/build.js' -import { render } from './generators/render.js' - -import { addAttributes } from './transformers/addAttributes.js' -import { attributeToStyle } from './transformers/attributeToStyle.js' -import { addBaseUrl } from './transformers/baseUrl.js' -import { purge } from './transformers/purge.js' -import { filters } from './transformers/filters/index.js' -import { inline } from './transformers/inline.js' -import { markdown } from './transformers/markdown.js' -import { minify } from './transformers/minify.js' -import { useMso } from './transformers/posthtmlMso.js' -import { prettify } from './transformers/prettify.js' -import { removeAttributes } from './transformers/removeAttributes.js' -import { replaceStrings } from './transformers/replaceStrings.js' -import { safeClassNames } from './transformers/safeClassNames.js' -import { shorthandCSS } from './transformers/shorthandCss.js' -import { sixHEX } from './transformers/sixHex.js' -import { addURLParams } from './transformers/urlParameters.js' -import { useAttributeSizes } from './transformers/useAttributeSizes.js' -import { preventWidows } from './transformers/preventWidows.js' -import { generatePlaintext } from './generators/plaintext.js' - -export { - build, - serve, - render, - addAttributes, - attributeToStyle, - addBaseUrl, - purge as removeUnusedCSS, - purge as purgeCSS, - filters, - inline as inlineCSS, - markdown, - minify, - useMso, - prettify, - removeAttributes, - replaceStrings, - safeClassNames, - shorthandCSS, - sixHEX, - addURLParams, - useAttributeSizes, - preventWidows, - generatePlaintext, -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..45c7c7e1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +// Vite plugin +export { maizzle } from './plugin.ts' + +// Render +export { render } from './render/index.ts' +export type { RenderOptions, RenderResult } from './render/index.ts' +export type { Renderer, RenderedTemplate, CreateRendererOptions } from './render/createRenderer.ts' +export { createRenderer } from './render/createRenderer.ts' + +// Build +export { build } from './build.ts' + +// Dev server +export { serve } from './serve.ts' + +// Config +export { defineConfig, resolveConfig } from './config/index.ts' + +// Plaintext +export { createPlaintext } from './plaintext.ts' + +// Composables +export { useConfig } from './composables/useConfig.ts' +export { useDoctype } from './composables/useDoctype.ts' +export { useEvent } from './composables/useEvent.ts' +export { usePlaintext } from './composables/usePlaintext.ts' +export { useHead } from '@unhead/vue' + +// Types +export type { MaizzleConfig, HtmlConfig, UrlConfig, UrlQuery, UrlQueryOptions, CssConfig, AttributesConfig, EntitiesConfig, FilterFunction, FiltersConfig } from './types/index.ts' + +// Transformers +export { inlineLink } from './transformers/inlineLink.ts' +export { urlQuery } from './transformers/urlQuery.ts' +export { base } from './transformers/base.ts' +export { entities } from './transformers/entities.ts' +export { safeClassNames } from './transformers/safeClassNames.ts' +export { attributeToStyle } from './transformers/attributeToStyle.ts' +export { inlineCSS } from './transformers/inlineCSS.ts' +export { shorthandCSS } from './transformers/shorthandCSS.ts' +export { removeAttributes } from './transformers/removeAttributes.ts' +export { addAttributes } from './transformers/addAttributes.ts' +export { purgeCSS as removeUnusedCSS } from './transformers/purgeCSS.ts' +export { filters } from './transformers/filters/index.ts' +export { replaceStrings } from './transformers/replaceStrings.ts' +export { format } from './transformers/format.ts' +export { minify } from './transformers/minify.ts' diff --git a/src/plaintext.ts b/src/plaintext.ts new file mode 100644 index 00000000..d07e4ac9 --- /dev/null +++ b/src/plaintext.ts @@ -0,0 +1,13 @@ +import { stripHtml } from 'string-strip-html' +import defu from 'defu' + +const defaults = { + dumpLinkHrefsNearby: { + enabled: true, + putOnNewLine: true, + }, +} + +export function createPlaintext(html: string, options?: Record<string, unknown>): string { + return stripHtml(html, defu(options, defaults)).result +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 00000000..ce0f7f7b --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,60 @@ +import type { Plugin, ViteDevServer } from 'vite' +import type { MaizzleConfig } from './types/index.ts' +import { isLaravel } from './utils/detect.ts' + +/** + * Maizzle Vite plugin for use inside an existing Vite project. + * + * - During `vite dev`: starts a separate Maizzle dev server on its own port + * - During `vite build`: builds email templates alongside the host app + * + * Does NOT inject Vue, Tailwind, or any other plugins into the host's pipeline. + * The host app manages its own stack. Maizzle runs in its own process. + */ +export function maizzle(configInput?: Partial<MaizzleConfig>): Plugin[] { + let maizzleServer: ViteDevServer | null = null + + // Auto-configure defaults for Laravel projects + if (isLaravel()) { + const existing = configInput?.components?.source + const laravelComponentDir = 'resources/js/components/email' + + if (!existing) { + configInput = { + ...configInput, + components: { + ...configInput?.components, + source: [laravelComponentDir], + }, + } + } + } + + return [{ + name: 'maizzle', + + async configureServer(hostServer) { + const { serve, printBanner } = await import('./serve.ts') + maizzleServer = await serve({ config: configInput, silent: true }) + + // Print Maizzle banner after the host server prints its URLs + hostServer.httpServer?.on('listening', () => { + printBanner(maizzleServer!) + }) + }, + + async closeBundle() { + if (this.meta.watchMode) return + + const { build } = await import('./build.ts') + await build({ config: configInput }) + }, + + async buildEnd() { + if (maizzleServer) { + await maizzleServer.close() + maizzleServer = null + } + }, + }] +} diff --git a/src/plugins/postcss/mergeMediaQueries.ts b/src/plugins/postcss/mergeMediaQueries.ts new file mode 100644 index 00000000..7ce334c0 --- /dev/null +++ b/src/plugins/postcss/mergeMediaQueries.ts @@ -0,0 +1,24 @@ +import sortMediaQueries from 'postcss-sort-media-queries' +import type postcss from 'postcss' +import type { MaizzleConfig } from '../../types/config.ts' + +/** + * Sorts and merges CSS media queries using postcss-sort-media-queries. + * + * Enabled by default. Opt out with css: { media: false }. + * + * Config examples: + * css: { media: true } // merge, mobile-first sort (default) + * css: { media: { sort: 'desktop-first' } } // merge, desktop-first sort + * css: { media: false } // disabled + */ +export function mergeMediaQueries(config: MaizzleConfig): postcss.Plugin | null { + const media = config.css?.media + + if (media === false) return null + + const options = typeof media === 'object' ? media : {} + const sort = options.sort ?? 'mobile-first' + + return sortMediaQueries({ sort }) as postcss.Plugin +} diff --git a/src/plugins/postcss/pruneVars.ts b/src/plugins/postcss/pruneVars.ts new file mode 100644 index 00000000..6905c0dd --- /dev/null +++ b/src/plugins/postcss/pruneVars.ts @@ -0,0 +1,77 @@ +/** + * postcss-prune-var + * Removes unused CSS custom properties. + */ + +import type { Declaration, Plugin, Root } from 'postcss'; + +const PLUGIN_NAME = 'postcss-prune-var'; + +const VAR_RE = /var\(\s*(--[^ ,);]+)/g; + +interface VarRecord { + uses: number; + declarations: Set<Declaration>; + dependencies: Set<string>; +} + +export default (): Plugin => { + return { + postcssPlugin: PLUGIN_NAME, + + Once(root: Root) { + const records = new Map<string, VarRecord>(); + const usedVars = new Set<string>(); + + const getRecord = (name: string): VarRecord => { + let r = records.get(name); + if (!r) { + r = { uses: 0, declarations: new Set(), dependencies: new Set() }; + records.set(name, r); + } + return r; + }; + + const registerUse = (name: string, seen = new Set<string>()) => { + const r = getRecord(name); + r.uses++; + seen.add(name); + for (const dep of r.dependencies) { + if (!seen.has(dep)) registerUse(dep, seen); + } + }; + + // Build dependency graph + root.walkDecls((decl) => { + const isVar = decl.prop.startsWith('--'); + + if (isVar) getRecord(decl.prop).declarations.add(decl); + + if (!decl.value.includes('var(')) return; + + let m; + VAR_RE.lastIndex = 0; + while ((m = VAR_RE.exec(decl.value))) { + const ref = m[1].trim(); + if (isVar) { + getRecord(decl.prop).dependencies.add(ref); + } else { + usedVars.add(ref); + } + } + }); + + // Propagate usage through the graph + for (const v of usedVars) registerUse(v); + + // Remove declarations with zero uses + for (const { uses, declarations } of records.values()) { + if (uses === 0) { + for (const decl of declarations) decl.remove(); + } + } + }, + }; +}; + +export const postcss = true; diff --git a/src/plugins/postcss/removeDeclarations.ts b/src/plugins/postcss/removeDeclarations.ts new file mode 100644 index 00000000..c1259949 --- /dev/null +++ b/src/plugins/postcss/removeDeclarations.ts @@ -0,0 +1,100 @@ +/** + * postcss-remove-declarations + * + * Removes CSS declarations (or whole rules) based on a selector map. + * + * The `remove` option maps a selector string to one of: + * + * - `"*"` — remove the entire rule + * - `string` — remove the single named property + * - `string[]` — remove every listed property + * - `Record<string, string>` — remove a property only when its value matches. + * Append `!important` to the value to restrict the match + * to non-important declarations only. + * + * Example: + * removeDeclarations({ + * remove: { + * ':root': '*', + * '.foo': ['color', 'margin'], + * '.bar': { color: 'red' }, + * } + * }) + */ + +import type { Plugin, Root } from 'postcss' + +export type RemoveValue = + | '*' + | string + | string[] + | Record<string, string> + +export interface RemoveDeclarationsOptions { + remove: Record<string, RemoveValue> +} + +const IMPORTANT = '!important' + +function normalizeSelector(selector: string): string { + return selector.replace(/(\r\n|\n|\r)/gm, '') +} + +export default (options: RemoveDeclarationsOptions): Plugin => { + return { + postcssPlugin: 'postcss-remove-declarations', + + Once(root: Root) { + const remove = options.remove ?? {} + + root.walkRules((rule) => { + let toRemove = remove[normalizeSelector(rule.selector)] + + if (!toRemove) return + + // Remove the entire rule + if (toRemove === '*') { + rule.remove() + return + } + + // Normalise a bare string into an array + if (typeof toRemove === 'string') { + toRemove = [toRemove] + } + + if (Array.isArray(toRemove)) { + const props = toRemove as string[] + rule.walkDecls((decl) => { + if (props.includes(decl.prop)) decl.remove() + }) + } else if (typeof toRemove === 'object') { + // Object: match both property and value + const map = toRemove as Record<string, string> + rule.walkDecls((decl) => { + if (!(decl.prop in map)) return + + let expected = map[decl.prop] + const requireNonImportant = expected.endsWith(IMPORTANT) + + if (requireNonImportant) { + expected = expected.slice(0, -IMPORTANT.length).trim() + } + + if (decl.value !== expected) return + if (decl.important && requireNonImportant) return + + decl.remove() + }) + } + + // Remove the rule if all declarations were removed + if (rule.nodes?.length === 0) { + rule.remove() + } + }) + }, + } +} + +export const postcss = true diff --git a/src/plugins/postcss/tailwindCleanup.ts b/src/plugins/postcss/tailwindCleanup.ts new file mode 100644 index 00000000..89aab0b7 --- /dev/null +++ b/src/plugins/postcss/tailwindCleanup.ts @@ -0,0 +1,43 @@ +import postcss from 'postcss' +import type { MaizzleConfig } from '../../types/config.ts' + +const DEFAULT_SELECTORS = [':host', ':lang'] +const DEFAULT_AT_RULES = ['layer', 'property'] + +/** + * Removes CSS rules whose every comma-separated selector part starts with + * one of the configured prefixes (e.g. ':host', ':lang'). Rules with mixed + * selectors have the unwanted parts stripped. + * + * Also removes entire at-rules by name (e.g. '@layer', '@property'). + * + * Intended to clean up Tailwind's compiled output after lightningcss has + * flattened all modern CSS syntax. + */ +export function tailwindCleanup(config: MaizzleConfig): postcss.Plugin[] { + const selectors: string[] = config.postcss?.removeSelectors ?? DEFAULT_SELECTORS + const atRules: string[] = config.postcss?.removeAtRules ?? DEFAULT_AT_RULES + + return [ + { + postcssPlugin: 'tailwind-cleanup-selectors', + Rule(rule) { + const parts = rule.selector.split(',').map(s => s.trim()) + const kept = parts.filter(p => !selectors.some(s => p === s || p.startsWith(`${s}(`))) + if (kept.length === 0) { + rule.remove() + } else if (kept.length < parts.length) { + rule.selector = kept.join(', ') + } + }, + }, + { + postcssPlugin: 'tailwind-cleanup-at-rules', + AtRule(rule) { + if (atRules.includes(rule.name)) { + rule.remove() + } + }, + }, + ] +} diff --git a/src/posthtml/defaultComponentsConfig.js b/src/posthtml/defaultComponentsConfig.js deleted file mode 100644 index 05e83e19..00000000 --- a/src/posthtml/defaultComponentsConfig.js +++ /dev/null @@ -1,19 +0,0 @@ -export default { - root: './', - tag: 'component', - fileExtension: ['html'], - folders: [ - 'layouts', - 'emails', - 'templates', - 'components', - 'src/layouts', - 'src/templates', - 'src/components' - ], - expressions: { - loopTags: ['each', 'for'], - missingLocal: '{local}', - strictMode: false, - }, -} diff --git a/src/posthtml/defaultConfig.js b/src/posthtml/defaultConfig.js deleted file mode 100644 index 0c6ef18e..00000000 --- a/src/posthtml/defaultConfig.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defu as merge } from 'defu' - -export function getPosthtmlOptions(userConfigOptions = {}) { - return merge( - userConfigOptions, - { - recognizeNoValueAttribute: true, - recognizeSelfClosing: true, - directives: [ - { name: '?php', start: '<', end: '>' }, - ], - } - ) -} diff --git a/src/posthtml/index.js b/src/posthtml/index.js deleted file mode 100644 index 37154225..00000000 --- a/src/posthtml/index.js +++ /dev/null @@ -1,136 +0,0 @@ -import get from 'lodash-es/get.js' -import { defu as merge } from 'defu' - -// PostHTML -import posthtml from 'posthtml' -import posthtmlFetch from 'posthtml-fetch' -import envTags from './plugins/envTags.js' -import components from 'posthtml-component' -import posthtmlPostcss from 'posthtml-postcss' -import expandLinkTag from './plugins/expandLinkTag.js' -import envAttributes from './plugins/envAttributes.js' -import { getPosthtmlOptions } from './defaultConfig.js' - -// PostCSS -import tailwindcss from 'tailwindcss' -import postcssCalc from 'postcss-calc' -import postcssImport from 'postcss-import' -import cssVariables from 'postcss-css-variables' -import postcssSafeParser from 'postcss-safe-parser' -import postcssSortMediaQueries from 'postcss-sort-media-queries' -import postcssColorFunctionalNotation from 'postcss-color-functional-notation' - -import defaultComponentsConfig from './defaultComponentsConfig.js' - -export async function process(html = '', config = {}) { - /** - * Configure PostCSS pipeline. Plugins defined and added here - * will apply to all `<style>` tags in the HTML. - */ - const resolveCSSProps = get(config, 'css.resolveProps', true) - const mediaConfig = get(config, 'css.media') - const resolveCalc = get(config, 'css.resolveCalc') !== false - ? get(config, 'css.resolveCalc', { precision: 2 }) // enabled by default, use default precision 2 - : false - - const postcssPlugin = posthtmlPostcss( - [ - postcssImport(), - tailwindcss(get(config, 'css.tailwind', {})), - resolveCSSProps && cssVariables(resolveCSSProps), - resolveCalc !== false && postcssCalc(resolveCalc), - postcssColorFunctionalNotation(), - mediaConfig && postcssSortMediaQueries(mediaConfig === true ? {} : mediaConfig), - ...get(config, 'postcss.plugins', []), - ], - merge( - get(config, 'postcss.options', {}), - { - from: config.cwd || './', - parser: postcssSafeParser - } - ) - ) - - /** - * Define PostHTML options by merging user-provided ones - * on top of a default configuration. - */ - const posthtmlOptions = getPosthtmlOptions(get(config, 'posthtml.options', {})) - - const componentsUserOptions = get(config, 'components', {}) - - const expressionsOptions = merge( - get(config, 'expressions', get(config, 'posthtml.expressions', {})), - get(componentsUserOptions, 'expressions', {}), - ) - - const locals = merge( - get(config, 'locals', {}), - get(expressionsOptions, 'locals', {}), - { page: config }, - ) - - const fetchPlugin = posthtmlFetch( - merge( - { - expressions: merge( - { locals }, - expressionsOptions, - { - missingLocal: '{local}', - strictMode: false, - }, - ), - }, - get(config, 'fetch', {}) - ) - ) - - const componentsConfig = merge( - { - expressions: merge( - { locals }, - expressionsOptions, - ) - }, - componentsUserOptions, - defaultComponentsConfig - ) - - // Ensure `fileExtension` is array and has no duplicates - componentsConfig.fileExtension = Array.from(new Set( - [].concat(componentsConfig.fileExtension) - )) - - const beforePlugins = get(config, 'posthtml.plugins.before', []) - - return posthtml([ - ...beforePlugins, - envTags(config.env), - envAttributes(config.env), - expandLinkTag(), - postcssPlugin, - fetchPlugin, - components(componentsConfig), - expandLinkTag(), - postcssPlugin, - envTags(config.env), - envAttributes(config.env), - ...get( - config, - 'posthtml.plugins.after', - beforePlugins.length > 0 - ? [] - : get(config, 'posthtml.plugins', []) - ), - ]) - .process(html, posthtmlOptions) - .then(result => ({ - config: merge(config, { page: config }), - html: result.html, - })) - .catch(error => { - throw error - }) -} diff --git a/src/posthtml/plugins/envAttributes.js b/src/posthtml/plugins/envAttributes.js deleted file mode 100644 index df923e37..00000000 --- a/src/posthtml/plugins/envAttributes.js +++ /dev/null @@ -1,32 +0,0 @@ -const plugin = (env => tree => { - const process = node => { - // Return the original node if no environment is set - if (!env) { - return node - } - - if (node?.attrs) { - for (const attr in node.attrs) { - const suffix = `-${env}` - - // Find attributes on this node that have this suffix - if (attr.endsWith(suffix)) { - const key = attr.slice(0, -suffix.length) - const value = node.attrs[attr] - - // Change the attribute without the suffix to have the value of the suffixed attribute - node.attrs[key] = value - - // Remove the attribute with the suffix - node.attrs[attr] = false - } - } - } - - return node - } - - return tree.walk(process) -}) - -export default plugin diff --git a/src/posthtml/plugins/envTags.js b/src/posthtml/plugins/envTags.js deleted file mode 100644 index 10ab25d6..00000000 --- a/src/posthtml/plugins/envTags.js +++ /dev/null @@ -1,50 +0,0 @@ -const plugin = (env => tree => { - const process = node => { - env = env || 'local' - - // Return the original node if it doesn't have a tag - if (!node?.tag) { - return node - } - - // Handle <env:?> tags (render only if current env matches) - if ( - typeof node.tag === 'string' - && node.tag.startsWith('env:') - ) { - const tagEnv = node.tag.slice(4) // Remove 'env:' prefix - - if (tagEnv === '' || tagEnv !== env) { - // No env specified or tag doesn't target current env, remove everything - node.content = [] - node.tag = false - } else { - // Tag targets current env, remove tag and keep content - node.tag = false - } - } - - // Handle <not-env:?> tags (render only if current env does not match) - if ( - typeof node.tag === 'string' - && node.tag.startsWith('not-env:') - ) { - const tagEnv = node.tag.slice(8) // Remove 'not-env:' prefix - - if (tagEnv === '' || tagEnv === env) { - // No env specified or tag targets current env, remove everything - node.content = [] - node.tag = false - } else { - // Tag doesn't target current env, remove tag and keep content - node.tag = false - } - } - - return node - } - - return tree.walk(process) -}) - -export default plugin diff --git a/src/posthtml/plugins/expandLinkTag.js b/src/posthtml/plugins/expandLinkTag.js deleted file mode 100644 index 57ed945a..00000000 --- a/src/posthtml/plugins/expandLinkTag.js +++ /dev/null @@ -1,59 +0,0 @@ -const targets = new Set(['expand', 'inline']) - -const expandLinkPlugin = () => tree => { - return new Promise((resolve, reject) => { - const isNode = process?.versions?.node - - const loadFile = async href => { - if (isNode) { - const { readFile } = await import('node:fs/promises') - return await readFile(href, 'utf8') - } - - const res = await fetch(href) - - if (!res.ok) { - throw new Error(`Failed to fetch ${href}: ${res.statusText}`) - } - - return await res.text() - } - - const promises = [] - - try { - tree.walk(node => { - if (node?.attrs && ![...targets].some(attr => attr in node.attrs)) { - for (const attr of targets) { - node.attrs[attr] = false - } - return node - } - - if ( - node?.tag === 'link' && - node.attrs?.href && - node.attrs.rel === 'stylesheet' - ) { - const promise = loadFile(node.attrs.href).then(content => { - node.tag = 'style' - node.attrs = {} - node.content = [content] - }) - - promises.push(promise) - } - - return node - }) - - Promise.all(promises) - .then(() => resolve(tree)) - .catch(reject) - } catch (err) { - reject(err) - } - }) -} - -export default expandLinkPlugin diff --git a/src/render/createRenderer.ts b/src/render/createRenderer.ts new file mode 100644 index 00000000..a9080d9e --- /dev/null +++ b/src/render/createRenderer.ts @@ -0,0 +1,321 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isLaravel } from '../utils/detect.ts' +import { createServer } from 'vite' +import vue from '@vitejs/plugin-vue' +import Markdown from 'unplugin-vue-markdown/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { unheadVueComposablesImports } from '@unhead/vue' +import { defu as merge } from 'defu' +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { createHead, renderSSRHead } from '@unhead/vue/server' +import { MaizzleConfigKey } from '../composables/useConfig.ts' +import { RenderContextKey } from '../composables/renderContext.ts' +import type { Component, InjectionKey } from 'vue' +import type { MaizzleConfig } from '../types/index.ts' +import type { Options as MarkdownOptions } from 'unplugin-vue-markdown/types' +import type { RenderContext } from '../composables/renderContext.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * Vite plugin that extracts raw slot content from <CodeBlock> tags + * and passes it as a :code prop before Vue compiles the template. + * + * This lets users write HTML naturally inside CodeBlock slots without + * Vue attempting to compile it as template syntax. + */ +function codeBlockExtract() { + // Matches <CodeBlock ...>content</CodeBlock> (and kebab-case <code-block>) + const re = /<(CodeBlock|code-block)((?:\s[^>]*?)?)>([\s\S]*?)<\/\1>/g + + return { + name: 'maizzle:code-block-extract', + enforce: 'pre' as const, + transform(code: string, id: string) { + if (!id.endsWith('.vue') && !id.endsWith('.md')) return + if (!code.includes('CodeBlock') && !code.includes('code-block')) return + + const transformed = code.replace(re, (_match, tag, attrs, content) => { + // Skip if already has a :code or v-bind:code prop + if (/(?:^|\s):code\b/.test(attrs) || /v-bind:code\b/.test(attrs)) return _match + + // Strip leading/trailing blank lines, then dedent based on + // the minimum indent of non-empty lines (à la min-indent) + const stripped = content.replace(/^\n+/, '').replace(/\s+$/, '') + if (!stripped) return _match + + const minIndent = stripped.match(/^[ \t]*(?=\S)/gm) + ?.reduce((min, ws) => Math.min(min, ws.length), Infinity) ?? 0 + + const dedented = minIndent > 0 + ? stripped.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '') + : stripped + + // Base64-encode so no characters can interfere with Vue's HTML parser. + // The component decodes it back via Buffer. + const encoded = Buffer.from(dedented).toString('base64') + + return `<${tag}${attrs} encoded-code="${encoded}" />` + }) + + if (transformed !== code) { + return { code: transformed, map: null } + } + }, + } +} + +const vuePkgDir = dirname(fileURLToPath(import.meta.resolve('vue/package.json'))) +const vueServerRendererPkgDir = dirname(fileURLToPath(import.meta.resolve('@vue/server-renderer/package.json'))) +const unheadVuePkgDir = resolve(dirname(fileURLToPath(import.meta.resolve('@unhead/vue'))), '..') +const vueRouterPkgDir = dirname(fileURLToPath(import.meta.resolve('vue-router/package.json'))) + +export interface RenderedTemplate { + html: string + doctype?: string + templateConfig: MaizzleConfig + sfcEventHandlers: RenderContext['sfcEventHandlers'] + plaintext?: RenderContext['plaintext'] +} + +export interface Renderer { + render(input: string | Component, config: MaizzleConfig): Promise<RenderedTemplate> + invalidate(filePath: string): Promise<void> + invalidateAll(): Promise<void> + close(): Promise<void> +} + +export interface CreateRendererOptions { + /** Generate .d.ts files for auto-imports and components (default: false) */ + dts?: boolean + /** Options passed to unplugin-vue-markdown */ + markdown?: MarkdownOptions + /** Root directory for resolving user component dirs and .d.ts output */ + root?: string + /** Additional component directories to register for auto-import */ + componentDirs?: string[] +} + +/** + * Lightweight Vite SSR loader for rendering Vue SFC email templates. + * + * Uses only Vue + unplugin for component/auto-import resolution. + * Tailwind CSS compilation is handled by the transformer pipeline. + */ +export async function createRenderer( + options: CreateRendererOptions = {}, +): Promise<Renderer> { + const { dts = false, markdown: markdownOptions, root = process.cwd(), componentDirs = [] } = options + + const dtsDir = isLaravel() + ? resolve(process.cwd(), 'resources/js/types/maizzle') + : resolve(root, '.maizzle') + + const VIRTUAL_SFC_ID = 'virtual:maizzle-sfc.vue' + let virtualSfcSource = '' + + const server = await createServer({ + configFile: false, + plugins: [ + codeBlockExtract(), + { + name: 'maizzle:virtual-sfc', + resolveId(id) { + if (id === VIRTUAL_SFC_ID) return id + }, + load(id) { + if (id === VIRTUAL_SFC_ID) return virtualSfcSource + }, + }, + vue({ + include: [/\.vue$/, /\.md$/], + template: { + transformAssetUrls: false, + }, + }), + Markdown(merge(markdownOptions ?? {}, { + headEnabled: true, + wrapperDiv: false, + })), + AutoImport({ + dirs: [ + resolve(__dirname, '../composables'), + resolve(__dirname, '../filters'), + ], + imports: ['vue', unheadVueComposablesImports], + dts: dts ? resolve(dtsDir, 'auto-imports.d.ts') : false, + }), + Components({ + extensions: ['vue', 'md'], + include: [/\.vue$/, /\.vue\?vue/, /\.md$/], + dirs: [ + resolve(__dirname, '../components'), + resolve(root, 'components'), + ...componentDirs, + ], + dts: dts ? resolve(dtsDir, 'components.d.ts') : false, + }), + ], + resolve: { + alias: { + 'vue/server-renderer': resolve(vueServerRendererPkgDir, 'dist/server-renderer.esm-bundler.js'), + 'vue': resolve(vuePkgDir, 'dist/vue.runtime.esm-bundler.js'), + 'vue-router': vueRouterPkgDir, + '@unhead/vue/server': resolve(unheadVuePkgDir, 'dist/server.mjs'), + '@unhead/vue': resolve(unheadVuePkgDir, 'dist/index.mjs'), + }, + }, + server: { + middlewareMode: true, + hmr: false, + watch: null, + fs: { + allow: [process.cwd(), root, ...componentDirs, vuePkgDir, vueServerRendererPkgDir, unheadVuePkgDir, vueRouterPkgDir], + }, + }, + appType: 'custom', + logLevel: 'silent', + optimizeDeps: { + noDiscovery: true, + }, + }) + + return { + async render(input: string | Component, config: MaizzleConfig): Promise<RenderedTemplate> { + let component: Component + let configKey: InjectionKey<MaizzleConfig> + let contextKey: InjectionKey<RenderContext> + + if (typeof input === 'string') { + // String input goes through Vite — must use ssrLoadModule for injection keys + // so they share the same module instance as the SFC + const configModule = await server.ssrLoadModule(resolve(__dirname, '../composables/useConfig')) + const contextModule = await server.ssrLoadModule(resolve(__dirname, '../composables/renderContext')) + configKey = configModule.MaizzleConfigKey + contextKey = contextModule.RenderContextKey + + if (input.includes('<template') || input.includes('<script')) { + virtualSfcSource = input + const mod = server.moduleGraph.getModuleById(VIRTUAL_SFC_ID) + if (mod) server.moduleGraph.invalidateModule(mod) + component = (await server.ssrLoadModule(VIRTUAL_SFC_ID)).default + } else { + component = (await server.ssrLoadModule(input)).default + } + } else { + // Pre-compiled component — use directly imported keys + component = input + configKey = MaizzleConfigKey + contextKey = RenderContextKey + } + + const renderContext: RenderContext = { + doctype: undefined, + sfcConfig: undefined, + sfcEventHandlers: [], + } + + const head = createHead({ disableDefaults: true }) + const app = createSSRApp(component) + app.use(head) + app.provide(configKey, config) + app.provide(contextKey, renderContext) + + const ssrContext: Record<string, any> = {} + let html: string = await renderToString(app, ssrContext) + + const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = await renderSSRHead(head) + + // Inject head entries into the rendered HTML + if (htmlAttrs) { + html = html.replace(/<html([^>]*)>/, `<html$1 ${htmlAttrs}>`) + } + if (headTags) { + html = html.replace('</head>', `${headTags}\n</head>`) + } + if (bodyAttrs) { + html = html.replace(/<body([^>]*)>/, `<body$1 ${bodyAttrs}>`) + } + if (bodyTagsOpen) { + html = html.replace(/<body([^>]*)>/, `<body$1>\n${bodyTagsOpen}`) + } + if (bodyTags) { + html = html.replace('</body>', `${bodyTags}\n</body>`) + } + + // Inject SSR teleport content into their target elements + if (ssrContext.teleports) { + const { parse: parseDom, serialize: serializeDom, walk } = await import('../utils/ast/index.ts') + let dom = parseDom(html) + + for (const [rawTarget, content] of Object.entries(ssrContext.teleports) as [string, string][]) { + if (!content) continue + + const prepend = rawTarget.endsWith(':start') + const target = prepend ? rawTarget.slice(0, -6) : rawTarget + const targetChildren = parseDom(content) + + walk(dom, (node) => { + const el = node as import('domhandler').Element + + if (!el.name) return + + const matched + = target === el.name + || (target.startsWith('#') && el.attribs?.id === target.slice(1)) + || (target.startsWith('.') && el.attribs?.class?.split(/\s+/).includes(target.slice(1))) + + if (matched) { + for (const child of targetChildren) { + child.parent = el as any + } + + el.children = prepend + ? [...targetChildren, ...(el.children || [])] as any + : [...(el.children || []), ...targetChildren] as any + } + }) + } + + html = serializeDom(dom) + } + + // Inject preview/preheader text from usePreviewText() composable + if (renderContext.previewText) { + const { text, fillerCount, shyCount } = renderContext.previewText + const filler = '\u2007\u034F '.repeat(fillerCount) + const shys = '\u00AD '.repeat(shyCount) + const previewHtml = `<div style="display:none">${text}${filler}${shys}\u00A0</div>` + html = html.replace(/<body([^>]*)>/, `<body$1>${previewHtml}`) + } + + return { + html, + doctype: renderContext.doctype, + templateConfig: renderContext.sfcConfig ?? config, + sfcEventHandlers: renderContext.sfcEventHandlers, + plaintext: renderContext.plaintext, + } + }, + + async invalidate(filePath: string): Promise<void> { + const mod = await server.moduleGraph.getModuleByUrl(filePath) + if (mod) { + server.moduleGraph.invalidateModule(mod) + } + }, + + async invalidateAll(): Promise<void> { + for (const mod of server.moduleGraph.idToModuleMap.values()) { + server.moduleGraph.invalidateModule(mod) + } + }, + + async close(): Promise<void> { + await server.close() + }, + } +} diff --git a/src/render/index.ts b/src/render/index.ts new file mode 100644 index 00000000..530ba806 --- /dev/null +++ b/src/render/index.ts @@ -0,0 +1,74 @@ +import { resolve, extname } from 'node:path' +import { resolveConfig } from '../config/index.ts' +import { runTransformers } from '../transformers/index.ts' +import { createPlaintext } from '../plaintext.ts' +import type { Component } from 'vue' +import type { MaizzleConfig } from '../types/index.ts' +import { createRenderer, type Renderer } from './createRenderer.ts' + +export type { Renderer, RenderedTemplate, CreateRendererOptions } from './createRenderer.ts' +export { createRenderer } from './createRenderer.ts' + +export interface RenderOptions { + /** Already-resolved or partial config. If not provided, resolves from disk + defaults. */ + config?: Partial<MaizzleConfig> + /** Reuse an existing renderer (used internally by build/serve to avoid creating one per template) */ + _renderer?: Renderer +} + +export interface RenderResult { + html: string + config: MaizzleConfig + plaintext?: string +} + +/** + * Render a Vue SFC email template to a fully-transformed HTML string. + * + * Accepts a file path, a raw SFC source string, or an imported Vue component. + * Runs the full pipeline: SSR render → transformers → doctype. + */ +export async function render( + template: string | Component, + options: RenderOptions = {}, +): Promise<RenderResult> { + const config = await resolveConfig(options.config) + + // Reuse provided renderer or create a temporary one + const renderer = options._renderer ?? await createRenderer({ markdown: config.markdown, root: config.root, componentDirs: [config.components?.source ?? []].flat() }) + const ownsRenderer = !options._renderer + + try { + const isFile = typeof template === 'string' + && ['.vue', '.md'].includes(extname(template)) + && !template.includes('\n') + + const rendered = await renderer.render(isFile ? resolve(template) : template, config) + let html = rendered.html + + // Prepend doctype + const doctype = rendered.doctype ?? rendered.templateConfig.doctype ?? '<!DOCTYPE html>' + + // Run transformers + if (rendered.templateConfig.useTransformers !== false) { + html = await runTransformers(html, rendered.templateConfig, isFile ? resolve(template) : undefined, doctype) + } + html = `${doctype}\n${html}` + + const globalPlaintext = rendered.templateConfig.plaintext + const sfcPlaintext = rendered.plaintext + + let plaintextResult: string | undefined + + if (globalPlaintext || sfcPlaintext) { + const stripOptions = typeof globalPlaintext === 'object' ? globalPlaintext : {} + plaintextResult = createPlaintext(html, stripOptions) + } + + return { html, config: rendered.templateConfig, plaintext: plaintextResult } + } finally { + if (ownsRenderer) { + await renderer.close() + } + } +} diff --git a/src/serve.ts b/src/serve.ts new file mode 100644 index 00000000..36963165 --- /dev/null +++ b/src/serve.ts @@ -0,0 +1,535 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve, basename } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { createServer, createLogger, type ViteDevServer } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { glob } from 'tinyglobby' +import { createHighlighter, type Highlighter } from 'shiki' +import { createPlaintext } from './plaintext.ts' +import { resolveConfig } from './config/index.ts' +import { runTransformers } from './transformers/index.ts' +import { createRenderer, type Renderer } from './render/createRenderer.ts' +import { serveCompatibility } from './server/compatibility.ts' +import { serveLint } from './server/linter.ts' +import type { MaizzleConfig } from './types/index.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const devUIDir = resolve(__dirname, 'server/ui') + +const require = createRequire(import.meta.url) +const frameworkNodeModules = resolve(dirname(require.resolve('vue/package.json')), '..') +const vuePkgDir = dirname(require.resolve('vue/package.json')) + +export interface ServeOptions { + config?: Partial<MaizzleConfig> | string + /** Expose the server on the network (e.g. --host) */ + host?: boolean | string + /** When true, suppresses the banner/URL output (used by the Vite plugin, which prints its own) */ + silent?: boolean +} + +/** + * Start the Maizzle dev server. + * + * Creates two things: + * 1. A Vite dev server for the dev UI (sidebar + preview, with Vue + Tailwind for the UI itself) + * 2. A Renderer instance for SSR rendering email templates + * + * Template rendering goes through the Renderer, not the Vite dev server. + */ +export async function serve(options: ServeOptions = {}) { + const start = performance.now() + + let config = await resolveConfig(options.config) + const port = config.server?.port ?? 3000 + + // Create a renderer for SSR rendering email templates (with dts for dev) + const renderer = await createRenderer({ dts: true, markdown: config.markdown, root: config.root, componentDirs: [config.components?.source ?? []].flat() }) + + const server = await createServer({ + configFile: false, + plugins: [ + // Vue and Tailwind are only for the dev UI SPA, not for email templates + vue(), + tailwindcss(), + maizzleDevPlugin(config, renderer, options.config), + ], + resolve: { + dedupe: ['vue'], + alias: [ + { find: '@', replacement: devUIDir }, + { find: 'vue', replacement: resolve(vuePkgDir, 'dist/vue.runtime.esm-bundler.js') }, + { find: 'vue-router', replacement: resolve(frameworkNodeModules, 'vue-router') }, + { find: 'reka-ui', replacement: resolve(frameworkNodeModules, 'reka-ui') }, + { find: '@vueuse/core', replacement: resolve(frameworkNodeModules, '@vueuse/core') }, + { find: '@vueuse/shared', replacement: resolve(frameworkNodeModules, '@vueuse/shared') }, + { find: 'lucide-vue-next', replacement: resolve(frameworkNodeModules, 'lucide-vue-next') }, + { find: 'class-variance-authority', replacement: resolve(frameworkNodeModules, 'class-variance-authority') }, + { find: 'clsx', replacement: resolve(frameworkNodeModules, 'clsx') }, + { find: 'tailwind-merge', replacement: resolve(frameworkNodeModules, 'tailwind-merge') }, + ], + }, + cacheDir: resolve(devUIDir, '.vite'), + optimizeDeps: { + noDiscovery: true, + include: [ + 'vue', + 'vue-router', + 'lucide-vue-next', + '@vueuse/core', + '@vueuse/shared', + 'reka-ui', + 'class-variance-authority', + 'clsx', + 'tailwind-merge', + ], + }, + server: { + port, + host: options.host, + fs: { + allow: [process.cwd(), config.root ?? process.cwd(), devUIDir, frameworkNodeModules], + }, + }, + customLogger: customLogger(), + }) + + // Store renderer ref on server for cleanup + const originalClose = server.close.bind(server) + server.close = async () => { + await renderer.close() + return originalClose() + } + + await server.listen() + + const startupTime = Math.round(performance.now() - start) + + if (!options.silent) { + printBanner(server, startupTime) + } + + // Expose startup time so the plugin can print it later + ; (server as any)._maizzleStartupTime = startupTime + + return server +} + +/** + * Internal Vite plugin that adds Maizzle middleware and file watching to the dev UI server. + */ +function maizzleDevPlugin( + config: MaizzleConfig, + renderer: Renderer, + configInput: Partial<MaizzleConfig> | string | undefined, +) { + return { + name: 'maizzle:dev', + enforce: 'pre' as const, + + hotUpdate: { + order: 'pre' as const, + handler({ file }: { file: string }) { + // Prevent Tailwind/Vue from triggering a full reload for email template files. + // Maizzle handles these via custom HMR events in the watcher below. + if (isTemplateFile(file)) { + return [] + } + }, + }, + + configureServer(server: ViteDevServer) { + // File watching + const defaultWatchPaths = [ + 'maizzle.config.js', + 'maizzle.config.ts', + 'tailwind.config.js', + 'tailwind.config.ts', + ] + + const userWatchPaths = config.server?.watch ?? [] + const watchPaths = [...defaultWatchPaths, ...userWatchPaths] + + for (const watchPath of watchPaths) { + server.watcher.add(watchPath) + } + + server.watcher.on('add', async (file) => { + if (isTemplateFile(file)) { + await renderer.invalidateAll() + server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' }) + } + }) + + server.watcher.on('unlink', async (file) => { + if (isTemplateFile(file)) { + await renderer.invalidateAll() + server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' }) + } + }) + + server.watcher.on('change', async (file) => { + if (watchPaths.some(p => file.endsWith(p))) { + config = await resolveConfig(configInput) + } + + // Invalidate all renderer modules so component and config changes + // are picked up on the next render (Tailwind recompiles with fresh content) + await renderer.invalidateAll() + + if ( + isTemplateFile(file) + || watchPaths.some(p => file.endsWith(p)) + ) { + server.ws.send({ type: 'custom', event: 'maizzle:template-updated', data: { file } }) + } + }) + + // API middleware (before Vite's middleware) + server.middlewares.use(async (req: any, res: any, next: any) => { + const url = req.url || '/' + + if (url === '/__maizzle/templates') { + return serveTemplateList(config, res) + } + + if (url.startsWith('/__maizzle/render/')) { + return await serveRenderedTemplate(url, config, renderer, res) + } + + if (url.startsWith('/__maizzle/source/')) { + return await serveHighlightedSource(url, config, renderer, res) + } + + if (url.startsWith('/__maizzle/compatibility/')) { + return await serveCompatibility(url, config, res) + } + + if (url.startsWith('/__maizzle/lint/')) { + return await serveLint(url, config, res) + } + + if (url.startsWith('/__maizzle/vue-source/')) { + return await serveVueSource(url, config, res) + } + + if (url.startsWith('/__maizzle/plaintext/')) { + return await servePlaintext(url, config, renderer, res) + } + + if (url.startsWith('/__maizzle/stats/')) { + return await serveStats(url, config, renderer, res) + } + + next() + }) + + // Dev UI fallback (after Vite's middleware) + return () => { + server.middlewares.use(async (req: any, res: any, next: any) => { + if (isNavigationRequest(req)) { + return await serveDevUI(server, res, req.url || '/') + } + + next() + }) + } + }, + } +} + +function isTemplateFile(file: string): boolean { + return (file.endsWith('.vue') || file.endsWith('.md')) && !file.includes('server/ui') +} + +function isNavigationRequest(req: any): boolean { + const accept = req.headers?.accept || '' + return req.method === 'GET' && accept.includes('text/html') +} + +async function serveDevUI(server: ViteDevServer, res: any, url: string) { + let indexHtml = readFileSync(resolve(devUIDir, 'index.html'), 'utf-8') + + indexHtml = indexHtml.replace('./main.ts', `/@fs/${resolve(devUIDir, 'main.ts')}`) + indexHtml = indexHtml.replace('./favicon.svg', `/@fs/${resolve(devUIDir, 'favicon.svg')}`) + + const transformed = await server.transformIndexHtml(url, indexHtml) + + res.setHeader('Content-Type', 'text/html') + res.end(transformed) +} + +async function serveTemplateList(config: MaizzleConfig, res: any) { + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + + const data = templates.map(t => ({ + name: basename(t).replace(/\.(vue|md)$/, ''), + path: t, + href: '/' + t.replace(/\.(vue|md)$/, ''), + })) + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(data)) +} + +/** + * SSR render a .vue template using the Renderer (not the dev UI server). + */ +async function serveRenderedTemplate(url: string, config: MaizzleConfig, renderer: Renderer, res: any) { + const templateSlug = url.replace('/__maizzle/render/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end('Template not found') + return + } + + try { + const absolutePath = resolve(match) + + // Invalidate all modules so template + component changes are picked up + await renderer.invalidateAll() + + const rendered = await renderer.render(absolutePath, config) + let html = rendered.html + + const templateConfig = rendered.templateConfig + const doctype = rendered.doctype ?? templateConfig.doctype ?? '<!DOCTYPE html>' + + html = await runTransformers(html, templateConfig, absolutePath, doctype) + html = `${doctype}\n${html}` + + res.setHeader('Content-Type', 'text/html') + res.end(html) + } catch (error: any) { + res.statusCode = 500 + res.end(`<pre>${error.stack || error.message}</pre>`) + } +} + +let highlighter: Highlighter | null = null + +async function getHighlighter() { + if (!highlighter) { + highlighter = await createHighlighter({ + themes: ['laserwave'], + langs: ['html', 'vue'], + }) + } + return highlighter +} + +async function serveHighlightedSource(url: string, config: MaizzleConfig, renderer: Renderer, res: any) { + const templateSlug = url.replace('/__maizzle/source/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end('Template not found') + return + } + + try { + const absolutePath = resolve(match) + + await renderer.invalidateAll() + + const rendered = await renderer.render(absolutePath, config) + let html = rendered.html + + const templateConfig = rendered.templateConfig + const doctype = rendered.doctype ?? templateConfig.doctype ?? '<!DOCTYPE html>' + html = await runTransformers(html, templateConfig, absolutePath, doctype) + + html = `${doctype}\n${html}` + + const hl = await getHighlighter() + const highlighted = hl.codeToHtml(html, { + lang: 'html', + theme: 'laserwave', + transformers: [{ + line(node, line) { + node.properties['data-line'] = line + }, + }], + }) + + res.setHeader('Content-Type', 'text/html') + res.end(highlighted) + } catch (error: any) { + res.statusCode = 500 + res.end(`<pre>${error.stack || error.message}</pre>`) + } +} + +async function serveVueSource(url: string, config: MaizzleConfig, res: any) { + const templateSlug = url.replace('/__maizzle/vue-source/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end('Template not found') + return + } + + try { + const source = readFileSync(resolve(match), 'utf-8') + const lang = match.endsWith('.md') ? 'html' : 'vue' + + const hl = await getHighlighter() + const highlighted = hl.codeToHtml(source, { + lang, + theme: 'laserwave', + transformers: [{ + line(node, line) { + node.properties['data-line'] = line + }, + }], + }) + + res.setHeader('Content-Type', 'text/html') + res.end(highlighted) + } catch (error: any) { + res.statusCode = 500 + res.end(`<pre>${error.stack || error.message}</pre>`) + } +} + +async function servePlaintext(url: string, config: MaizzleConfig, renderer: Renderer, res: any) { + const templateSlug = url.replace('/__maizzle/plaintext/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end('Template not found') + return + } + + try { + const absolutePath = resolve(match) + await renderer.invalidateAll() + + const rendered = await renderer.render(absolutePath, config) + let html = rendered.html + const templateConfig = rendered.templateConfig + const doctype = rendered.doctype ?? templateConfig.doctype ?? '<!DOCTYPE html>' + html = await runTransformers(html, templateConfig, absolutePath, doctype) + + const plaintext = createPlaintext(html) + + res.setHeader('Content-Type', 'text/plain') + res.end(plaintext) + } catch (error: any) { + res.statusCode = 500 + res.end(error.message) + } +} + +function humanFileSize(bytes: number, si = false, dp = 2) { + const threshold = si ? 1000 : 1024 + + if (Math.abs(bytes) < threshold) { + return bytes + ' B' + } + + const units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + let u = -1 + const r = 10 ** dp + + do { + bytes /= threshold + ++u + } while (Math.round(Math.abs(bytes) * r) / r >= threshold && u < units.length - 1) + + return bytes.toFixed(dp) + ' ' + units[u] +} + +async function serveStats(url: string, config: MaizzleConfig, renderer: Renderer, res: any) { + const templateSlug = url.replace('/__maizzle/stats/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end(JSON.stringify({ error: 'Template not found' })) + return + } + + try { + const absolutePath = resolve(match) + await renderer.invalidateAll() + + const rendered = await renderer.render(absolutePath, config) + let html = rendered.html + const templateConfig = rendered.templateConfig + const doctype = rendered.doctype ?? templateConfig.doctype ?? '<!DOCTYPE html>' + html = await runTransformers(html, templateConfig, absolutePath, doctype) + + const sizeBytes = Buffer.byteLength(html, 'utf-8') + + // Count images: <img> tags and CSS background images + const imgTags = (html.match(/<img\b[^>]*>/gi) || []).length + const bgImages = (html.match(/url\s*\([^)]+\)/gi) || []).length + const totalImages = imgTags + bgImages + + // Count links + const links = (html.match(/<a\b[^>]*href\s*=/gi) || []).length + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + size: { + bytes: sizeBytes, + formatted: humanFileSize(sizeBytes), + }, + images: totalImages, + links, + })) + } catch (error: any) { + res.statusCode = 500 + res.end(JSON.stringify({ error: error.message })) + } +} + +export function printBanner(server: ViteDevServer, startupTime?: number) { + const info = server.config.logger.info + const time = startupTime ?? (server as any)._maizzleStartupTime + + info('') + info(` \x1b[32m\x1b[1mMAIZZLE\x1b[0m\x1b[32m v6.0.0\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m${time}\x1b[0m ms`) + info('') + server.printUrls() + info('') +} + +function customLogger() { + const logger = createLogger('info') + const warn = logger.warn + + logger.warn = (message, options) => { + if (typeof message === 'string' && message.includes('<tr> cannot be child of <table>')) { + return + } + + warn(message, options) + } + + return logger +} diff --git a/src/server/client.js b/src/server/client.js deleted file mode 100644 index 4065be35..00000000 --- a/src/server/client.js +++ /dev/null @@ -1,182 +0,0 @@ -// biome-ignore lint: need it globally -var lastKnownScrollPosition = 0 - -function connectWebSocket() { - if (!('WebSocket' in window)) { - // Force reload if WebSocket is not supported - window.location.reload() - } - - const { hostname, port } = window.location - const socket = new WebSocket(`ws://${hostname}:${port}`) - - /** - * Synchronized scrolling - * Sends the scroll position to the server - */ - function handleScroll() { - socket.send(JSON.stringify({ - type: 'scroll', - position: window.scrollY - })) - } - - function scrollHandler() { - lastKnownScrollPosition = window.scrollY - requestAnimationFrame(handleScroll) - } - - window.addEventListener('scroll', scrollHandler) - - socket.addEventListener('message', async event => { - const data = JSON.parse(event.data) - - if (data.type === 'scroll' && data.scrollSync === true) { - window.scrollTo(0, data.position) - } - - if (data.type === 'change') { - if (data.hmr === true) { - // Use morphdom to update the existing DOM with the new content - morphdom(document.documentElement, data.content, { - childrenOnly: true, - onBeforeElUpdated(fromEl, toEl) { - // Speed-up trick from morphdom docs - https://dom.spec.whatwg.org/#concept-node-equals - if (fromEl.isEqualNode(toEl)) { - return false - } - - return true - }, - onElUpdated(el) { - // Handle broken images updates, like incorrect file paths - if (el.tagName === 'IMG' && !el.complete) { - const img = new Image() - img.src = el.src - el.src = '' - - img.onload = () => { - el.src = img.src - } - } - }, - }) - } else { - // Reload the page - window.location.reload() - } - - /** - * Fix for attributes not being updated on <html> tag - * Borrowed from https://github.com/11ty/eleventy-dev-server/ - */ - const parser = new DOMParser() - const parsed = parser.parseFromString(data.content, 'text/html') - const parsedDoc = parsed.documentElement - const newAttrs = parsedDoc.getAttributeNames() - const docEl = document.documentElement - - // Remove old attributes - const removedAttrs = docEl.getAttributeNames().filter(name => !newAttrs.includes(name)) - for (const attr of removedAttrs) { - docEl.removeAttribute(attr) - } - - // Add new attributes - for (const attr of newAttrs) { - docEl.setAttribute(attr, parsedDoc.getAttribute(attr)) - } - } - - if (['add', 'unlink'].includes(data.type)) { - if (data.hmr === true) { - const randomNumber = Math.floor(Math.random() * 10 ** 16).toString().padStart(16, '0') - - /** - * Cache busting for images - * - * Appends a `?v=` cache-busting parameter to image sources - * every time a file is added or removed. This forces the - * browser to re-download the image and immediately - * reflect the changes through HMR. - */ - - // For all elements with `src` attributes - const srcElements = document.querySelectorAll('[src]') - - srcElements.forEach(el => { - // Update the value of 'v' parameter if it already exists - if (el.src.includes('?')) { - el.src = el.src.replace(/([?&])v=[^&]*/, `$1v=${randomNumber}`) - } else { - // Add 'v' parameter - el.src += `?v=${randomNumber}` - } - }) - - // For `background` attributes - const htmlBgElements = document.querySelectorAll('[background]') - - htmlBgElements.forEach(el => { - const bgValue = el.getAttribute('background') - if (bgValue) { - // Update the value of 'v' parameter if it already exists - if (bgValue.includes('?')) { - el.setAttribute('background', bgValue.replace(/([?&])v=[^&]*/, `$1v=${randomNumber}`)) - } else { - // Add 'v' parameter - el.setAttribute('background', `${bgValue}?v=${randomNumber}`) - } - } - }) - - // For inline CSS `background` properties - const styleElements = document.querySelectorAll('[style]') - - styleElements.forEach(el => { - const styleAttribute = el.getAttribute('style') - if (styleAttribute) { - const urlPattern = /(url\(["']?)(.*?)(["']?\))/g - // Replace URLs in style attribute with cache-busting parameter - const updatedStyleAttribute = styleAttribute.replace(urlPattern, (_match, p1, p2, p3) => { - // Update the value of 'v' parameter if it already exists - if (p2.includes('?')) { - return `${p1}${p2.replace(/([?&])v=[^&]*/, `$1v=${randomNumber}`)}${p3}` - } - - // Add 'v' parameter - return `${p1}${p2}?v=${randomNumber}${p3}` - }) - - // Update style attribute - el.setAttribute('style', updatedStyleAttribute) - } - }) - } else { - // Reload the page - window.location.reload() - } - } - }) - - socket.addEventListener('close', () => { - window.removeEventListener('scroll', scrollHandler) - - // debug only: - console.log('WebSocket connection closed. Reconnecting...') - - // Reconnect after a short delay - setTimeout(() => { - connectWebSocket() - }, 1000) - }) - - // Handle connection opened - socket.addEventListener('open', _event => { - console.log('WebSocket connection opened') - }) - - return socket -} - -connectWebSocket() diff --git a/src/server/compatibility.ts b/src/server/compatibility.ts new file mode 100644 index 00000000..31f12f1e --- /dev/null +++ b/src/server/compatibility.ts @@ -0,0 +1,111 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { glob } from 'tinyglobby' +import { caniemail, rawData } from 'caniemail' +import type { MaizzleConfig } from '../types/index.ts' + +export async function serveCompatibility(url: string, config: MaizzleConfig, res: any) { + const templateSlug = url.replace('/__maizzle/compatibility/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end(JSON.stringify({ errors: [], warnings: [] })) + return + } + + try { + const source = readFileSync(resolve(match), 'utf-8') + + const result = caniemail({ + clients: ['apple-mail.*', 'gmail.*', 'outlook.*', 'yahoo.*'], + html: source, + }) + + // Build title -> caniemail URL lookup + const urlMap = new Map<string, string>() + for (const item of (rawData as any).data) { + urlMap.set(item.title, item.url) + } + + const issues: Array<{ type: 'error' | 'warning', client: string, title: string, notes: string[], line?: number }> = [] + + for (const [client, clientIssues] of result.issues.errors) { + for (const issue of clientIssues) { + issues.push({ + type: 'error', + client, + title: issue.title, + notes: issue.notes, + line: issue.position?.start.line, + }) + } + } + + for (const [client, clientIssues] of result.issues.warnings) { + for (const issue of clientIssues) { + issues.push({ + type: 'warning', + client, + title: issue.title, + notes: issue.notes, + line: issue.position?.start.line, + }) + } + } + + // Group by feature title + type, keep per-client notes + const grouped = new Map<string, { + type: 'error' | 'warning' + title: string + clients: Array<{ name: string, notes: string[] }> + url?: string + line?: number + }>() + + for (const issue of issues) { + const key = `${issue.type}:${issue.title}` + const existing = grouped.get(key) + const clientName = issue.client + .split('.')[0] + .replace(/-/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()) + + if (existing) { + const existingClient = existing.clients.find(c => c.name === clientName) + if (existingClient) { + for (const note of issue.notes) { + if (!existingClient.notes.includes(note)) { + existingClient.notes.push(note) + } + } + } else { + existing.clients.push({ name: clientName, notes: [...issue.notes] }) + } + } else { + grouped.set(key, { + type: issue.type, + title: issue.title, + clients: [{ name: clientName, notes: [...issue.notes] }], + url: urlMap.get(issue.title), + line: issue.line, + }) + } + } + + // Sort: errors first, then warnings + const sortedIssues = [...grouped.values()].sort((a, b) => { + if (a.type !== b.type) return a.type === 'error' ? -1 : 1 + return a.title.localeCompare(b.title) + }) + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(sortedIssues)) + } catch (error: any) { + res.statusCode = 500 + res.end(JSON.stringify({ error: error.message })) + } +} diff --git a/src/server/index.js b/src/server/index.js deleted file mode 100644 index 0d7e94b6..00000000 --- a/src/server/index.js +++ /dev/null @@ -1,466 +0,0 @@ -import path from 'pathe' -import fs from 'node:fs/promises' -import { createServer } from 'node:http' -import { cwd, exit } from 'node:process' - -import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -import ora from 'ora' -import fg from 'fast-glob' -import express from 'express' -import pico from 'picocolors' -import get from 'lodash-es/get.js' -import * as chokidar from 'chokidar' -import { isBinary } from 'istextorbinary' - -import WebSocket, { WebSocketServer } from 'ws' -import { initWebSockets } from './websockets.js' - -import { - getLocalIP, - getColorizedFileSize, -} from '../utils/node.js' -import { injectScript, formatTime } from '../utils/string.js' - -import { render } from '../generators/render.js' -import { readFileConfig } from '../utils/getConfigByFilePath.js' -import defaultComponentsConfig from '../posthtml/defaultComponentsConfig.js' - -// Routes -import hmrRoute from './routes/hmr.js' -import indexRoute from './routes/index.js' - -const app = express() -const wss = new WebSocketServer({ noServer: true }) - -// Register routes -app.use(indexRoute) -app.use(hmrRoute) - -let viewing = '' -const spinner = ora() -let templatePaths = [] -const serverStartTime = Date.now() - -function getTemplateFolders(config) { - return Array.isArray(get(config, 'build.content')) - ? config.build.content - : [config.build.content] -} - -async function getTemplatePaths(templateFolders) { - return await fg.glob([...new Set(templateFolders)]) -} - -async function getUpdatedRoutes(_app, config) { - return getTemplatePaths(getTemplateFolders(config)) -} - -async function renderUpdatedFile(file, config) { - try { - const startTime = Date.now() - spinner.start('Building...') - - /** - * Add current template path info to the config - * - * Can be used in events like `beforeRender` to determine - * which template file is being rendered. - */ - config.build.current = { - path: path.parse(file), - } - - // Read the file - const fileContent = await fs.readFile(file, 'utf8') - - // Set a `dev` flag on the config - config._dev = true - - // Render the file with PostHTML - let { html } = await render(fileContent, config) - - // Update console message - const shouldReportFileSize = get(config, 'server.reportFileSize', false) - - spinner.succeed( - `Done in ${formatTime(Date.now() - startTime)}` - + `${pico.gray(` [${path.relative(cwd(), file)}]`)}` - + `${shouldReportFileSize ? ' · ' + getColorizedFileSize(html) : ''}` - ) - - /** - * Inject HMR script - */ - html = injectScript(html, '<script src="/hmr.js"></script>') - - // Notify connected websocket clients about the change - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: 'change', - content: html, - scrollSync: get(config, 'server.scrollSync', false), - hmr: get(config, 'server.hmr', true), - })) - } - }) - } catch (error) { - spinner.fail('Failed to render template.') - throw error - } -} - -export default async (config = {}) => { - // Read the Maizzle config file - config = await readFileConfig(config).catch(() => { throw new Error('Could not compute config') }) - - /** - * Dev server settings - */ - spinner.spinner = get(config, 'server.spinner', 'circleHalves') - spinner.start('Starting server...') - - const shouldScroll = get(config, 'server.scrollSync', false) - const useHmr = get(config, 'server.hmr', true) - - // Add static assets root prefix so user doesn't have to - if (!config.baseURL) { - config.baseURL = '/' - } - - /** - * Initialize WebSocket server - * Used to send messages between the server and the browser - */ - initWebSockets(wss, { shouldScroll, useHmr }) - - // Register routes - templatePaths = await getUpdatedRoutes(app, config) - - /** - * Store template paths on the request object - * - * We use it in the index view to list all templates. - * */ - app.request.templatePaths = templatePaths - - app.request.maizzleConfig = config - - /** - * Create route pattern - * Only allow files with the following extensions - */ - const extensions = [ - ...new Set(templatePaths - .filter(p => !isBinary(p)) // exclude binary files from routes - .map(p => path.extname(p).slice(1).toLowerCase()) - ) - ].join('|') - - const routePattern = Array.isArray(getTemplateFolders(config)) - ? `*/:file.(${extensions})` - : `:file.(${extensions})` - - /** - * Loop over the source folders and create route for each file - */ - templatePaths.forEach(() => { - app.get(routePattern, async (req, res, next) => { - // Run beforeCreate event - if (typeof config.beforeCreate === 'function') { - await config.beforeCreate({ config }) - } - - try { - const filePath = templatePaths.find(t => t.endsWith(decodeURI(req.url.slice(1)))) - - // Set the file being viewed - viewing = filePath - - // Add current template path info to the config - config.build.current = { - path: path.parse(filePath), - } - - // Read the file - const fileContent = await fs.readFile(filePath, 'utf8') - - // Set a `dev` flag on the config - config._dev = true - - // Render the file with PostHTML - let { html } = await render(fileContent, config) - - /** - * Inject HMR script - */ - html = injectScript(html, '<script src="/hmr.js"></script>') - - res.send(html) - } catch (error) { - spinner.fail(`Failed to render template: ${req.url}\n`) - next(error) - } - }) - }) - - /** - * Components watcher - * - * Watches for changes in the configured Templates and Components paths - */ - let isWatcherReady = false - chokidar - .watch( - [ - ...templatePaths, - ...get(config, 'components.folders', defaultComponentsConfig.folders) - ], - { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 150, - pollInterval: 25, - }, - } - ) - .on('change', async () => { - if (viewing) { - await renderUpdatedFile(viewing, config) - } - }) - .on('ready', () => { - /** - * `add` fires immediately when the watcher is created, - * so we use this trick to detect new files added - * after it has started. - */ - isWatcherReady = true - }) - .on('add', async () => { - if (isWatcherReady) { - templatePaths = await getUpdatedRoutes(app, config) - app.request.templatePaths = templatePaths - } - }) - .on('unlink', async () => { - if (isWatcherReady) { - templatePaths = await getUpdatedRoutes(app, config) - app.request.templatePaths = templatePaths - } - }) - - let staticFiles = get(config, 'build.static', []) - - if (!Array.isArray(staticFiles)) { - staticFiles = [staticFiles] - } - - const staticFilesSourcePaths = staticFiles - .flatMap((definition) => definition.source) - .filter(p => typeof p === 'string') - - /** - * Global watcher - * - * Watch for changes in the config files, Tailwind CSS config, CSS files, - * configured static assets, and user-defined watch paths. - */ - const globalWatchedPaths = new Set([ - 'config*.{js,cjs,ts}', - 'maizzle.config*.{js,cjs,ts}', - 'tailwind*.config.{js,ts}', - '**/*.css', - ...staticFilesSourcePaths, - ...get(config, 'server.watch', []), - ].filter(p => typeof p === 'string')) - - async function globalPathsHandler(file, eventType) { - // Update express.static to serve new files - if (eventType === 'add') { - app.use(express.static(path.dirname(file))) - } - - // Stop serving deleted files - if (eventType === 'unlink') { - app._router.stack = app._router.stack.filter( - layer => layer.regexp.source !== path.dirname(file).replace(/\\/g, '/') - ) - } - - // Not viewing a component in the browser, no need to rebuild - if (!viewing) { - return - } - - try { - const startTime = Date.now() - spinner.start('Building...') - - // Read the Maizzle config file - config = await readFileConfig() - - // Add static assets root prefix so user doesn't have to - if (!config.baseURL) { - config.baseURL = '/' - } - - // Run beforeCreate event - if (typeof config.beforeCreate === 'function') { - await config.beforeCreate({ config }) - } - - // Read the file - const filePath = templatePaths.find(t => t.endsWith(viewing)) - const fileContent = await fs.readFile(path.normalize(filePath), 'utf8') - - // Set a `dev` flag on the config - config._dev = true - - // Render the file with PostHTML - let { html } = await render(fileContent, config) - - // Update console message - const shouldReportFileSize = get(config, 'server.reportFileSize', false) - - spinner.succeed( - `Done in ${formatTime(Date.now() - startTime)}` - + `${pico.gray(` [${path.relative(cwd(), filePath)}]`)}` - + `${shouldReportFileSize ? ' · ' + getColorizedFileSize(html) : ''}` - ) - - /** - * Inject HMR script - */ - html = injectScript(html, '<script src="/hmr.js"></script>') - - // Notify connected websocket clients about the change - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: eventType, - content: html, - scrollSync: get(config, 'server.scrollSync', false), - hmr: get(config, 'server.hmr', true), - })) - } - }) - } catch (error) { - spinner.fail(`Failed to render template: ${file}`) - throw error - } - } - - chokidar - .watch([...globalWatchedPaths], { - ignored: [ - 'node_modules', - get(config, 'build.output.path', 'build_production'), - ], - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 150, - pollInterval: 25, - }, - }) - .on('change', async file => await globalPathsHandler(file, 'change')) - .on('add', async file => await globalPathsHandler(file, 'add')) - .on('unlink', async file => await globalPathsHandler(file, 'unlink')) - - /** - * Serve all folders in the cwd as static files - */ - const srcFoldersList = await fg.glob( - [ - '**/*/', - ...staticFilesSourcePaths, - ], { - onlyFiles: false, - ignore: [ - 'node_modules', - `${get(config, 'build.output.path', 'build_*')}/**`, - ] - }) - - srcFoldersList.forEach(folder => { - app.use(express.static(path.join(config.cwd, folder))) - }) - - // Error-handling middleware - app.use(async (req, res) => { - const view = await fs.readFile(path.join(__dirname, 'views', '404.html'), 'utf8') - const { html } = await render(view, { - url: req.url, - }) - - res.status(404).send(html) - }) - - /** - * Start the server - */ - let retryCount = 0 - const port = get(config, 'server.port', 3000) - const maxRetries = get(config, 'server.maxRetries', 10) - - function startServer(port) { - const server = createServer(app) - - /** - * Handle WebSocket upgrades - * Attaches the WebSocket server to the Express server. - */ - server.on('upgrade', (request, socket, head) => { - wss.handleUpgrade(request, socket, head, ws => { - wss.emit('connection', ws, request) - }) - }) - - server.listen(port, async () => { - const { version } = JSON.parse( - await fs.readFile( - new URL('../../package.json', import.meta.url) - ) - ) - - spinner.stopAndPersist({ - text: `\n${pico.bgBlue(` Maizzle v${version} `)} ready in ${pico.bold(formatTime(Date.now() - serverStartTime))}` - + '\n\n' - + ` → Local: http://localhost:${port}` - + '\n' - + ` → Network: http://${getLocalIP()}:${port}\n` - }) - }) - - server.on('error', error => { - try { - if (error.code === 'EADDRINUSE') { - server.close() - retryPort() - } - } catch (error) { - spinner.fail(error.message) - exit(1) - } - }) - - return server - } - - function retryPort() { - retryCount++ - - if (retryCount <= maxRetries) { - const nextPort = port + retryCount - startServer(nextPort) - } else { - spinner.fail(`Exceeded maximum number of retries (${maxRetries}). Unable to find a free port.`) - - exit(1) - } - } - - startServer(port) -} diff --git a/src/server/linter.ts b/src/server/linter.ts new file mode 100644 index 00000000..19894994 --- /dev/null +++ b/src/server/linter.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { glob } from 'tinyglobby' +import type { MaizzleConfig } from '../types/index.ts' + +interface LintIssue { + type: 'error' | 'warning' + title: string + message: string + line?: number +} + +export async function serveLint(url: string, config: MaizzleConfig, res: any) { + const templateSlug = url.replace('/__maizzle/lint/', '').replace(/\?.*$/, '') + + const contentPatterns = config.content ?? ['emails/**/*.vue'] + const templates = await glob(contentPatterns) + const match = templates.find(t => t.replace(/\.(vue|md)$/, '') === templateSlug) + + if (!match) { + res.statusCode = 404 + res.end(JSON.stringify({ error: 'Template not found' })) + return + } + + try { + const source = readFileSync(resolve(match), 'utf-8') + + // Extract only the <template> block for linting + const templateMatch = source.match(/<template\b[^>]*>([\s\S]*)<\/template>/) + const html = templateMatch ? templateMatch[1] : source + + // Calculate the offset of the <template> content within the source file + const templateOffset = templateMatch + ? source.slice(0, source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateMatch[1])).split('\n').length - 1 + : 0 + + const issues = lintHtml(html, templateOffset) + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(issues)) + } catch (error: any) { + res.statusCode = 500 + res.end(JSON.stringify({ error: error.message })) + } +} + +function lintHtml(html: string, lineOffset = 0): LintIssue[] { + const issues: LintIssue[] = [] + const lines = html.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineNum = i + 1 + lineOffset + + // Images missing alt text + const imgMatches = [...line.matchAll(/<img\b[^>]*?>/gi)] + for (const match of imgMatches) { + const tag = match[0] + if (!/\balt\s*=/i.test(tag)) { + issues.push({ + type: 'warning', + title: 'Missing alt text', + message: 'Image is missing the alt attribute', + line: lineNum, + }) + } + } + + // Images with empty or missing src + for (const match of imgMatches) { + const tag = match[0] + const srcMatch = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i) + if (!srcMatch) { + issues.push({ + type: 'error', + title: 'Missing image src', + message: 'Image tag has no src attribute', + line: lineNum, + }) + } else if (!srcMatch[1].trim()) { + issues.push({ + type: 'error', + title: 'Empty image src', + message: 'Image src attribute is empty', + line: lineNum, + }) + } else if (srcMatch[1].trim().startsWith('http:')) { + issues.push({ + type: 'warning', + title: 'Insecure image src', + message: 'Image loads over HTTP instead of HTTPS', + line: lineNum, + }) + } + } + + // Links: missing href, empty href, placeholder href + const linkMatches = [...line.matchAll(/<a\b[^>]*?>/gi)] + for (const match of linkMatches) { + const tag = match[0] + const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']*)["']/i) + + if (!hrefMatch) { + issues.push({ + type: 'error', + title: 'Missing link href', + message: 'Anchor tag has no href attribute', + line: lineNum, + }) + } else { + const href = hrefMatch[1].trim() + if (!href) { + issues.push({ + type: 'warning', + title: 'Empty link href', + message: 'Link href attribute is empty', + line: lineNum, + }) + } else if (href === '#' || href === '/') { + issues.push({ + type: 'warning', + title: 'Placeholder link', + message: `Link href is "${href}"`, + line: lineNum, + }) + } else if (href.startsWith('http:')) { + issues.push({ + type: 'warning', + title: 'Insecure link', + message: 'Link uses HTTP instead of HTTPS', + line: lineNum, + }) + } else if (href.startsWith('http') && !/^https?:\/\/.+\..+/i.test(href)) { + issues.push({ + type: 'warning', + title: 'Invalid link', + message: `Link href "${href}" looks malformed`, + line: lineNum, + }) + } + } + } + + // Insecure resources (<link href>, <script src>, <source src>) + const resourceMatches = [...line.matchAll(/<(?:link|script|source)\b[^>]*?>/gi)] + for (const match of resourceMatches) { + const tag = match[0] + const attrMatch = tag.match(/\b(?:href|src)\s*=\s*["']([^"']*)["']/i) + if (attrMatch && attrMatch[1].trim().startsWith('http:')) { + issues.push({ + type: 'warning', + title: 'Insecure resource', + message: 'Resource loads over HTTP instead of HTTPS', + line: lineNum, + }) + } + } + + // Insecure CSS url() references + const urlMatches = [...line.matchAll(/url\s*\(\s*["']?(http:[^"')]+)["']?\s*\)/gi)] + for (const _match of urlMatches) { + issues.push({ + type: 'warning', + title: 'Insecure CSS url()', + message: 'CSS url() loads over HTTP instead of HTTPS', + line: lineNum, + }) + } + } + + // Check for unclosed tags (block-level and common inline elements) + const voidElements = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr', + ]) + + const trackedTags = new Set([ + 'a', 'b', 'body', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'head', 'html', 'i', 'li', 'ol', 'p', 'span', 'strong', 'style', + 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul', + ]) + + const stack: Array<{ tag: string, line: number }> = [] + + // Strip comments and content inside <style>/<script> to avoid false matches + const stripped = html + .replace(/<!--[\s\S]*?-->/g, (m) => '\n'.repeat((m.match(/\n/g) || []).length)) + .replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1>/gi, (m) => '\n'.repeat((m.match(/\n/g) || []).length)) + + const strippedLines = stripped.split('\n') + + for (let i = 0; i < strippedLines.length; i++) { + const line = strippedLines[i] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g + let m + + while ((m = tagRegex.exec(line)) !== null) { + const fullMatch = m[0] + const tagName = m[1].toLowerCase() + + if (!trackedTags.has(tagName) || voidElements.has(tagName)) continue + if (fullMatch.endsWith('/>')) continue + + if (fullMatch.startsWith('</')) { + // Closing tag + const lastOpen = stack.findLastIndex(s => s.tag === tagName) + if (lastOpen !== -1) { + stack.splice(lastOpen, 1) + } + } else { + // Opening tag + stack.push({ tag: tagName, line: i + 1 + lineOffset }) + } + } + } + + for (const unclosed of stack) { + issues.push({ + type: 'error', + title: 'Unclosed tag', + message: `<${unclosed.tag}> tag is not closed`, + line: unclosed.line, + }) + } + + // Sort: errors first, then warnings, then by line + issues.sort((a, b) => { + if (a.type !== b.type) return a.type === 'error' ? -1 : 1 + return (a.line ?? 0) - (b.line ?? 0) + }) + + return issues +} diff --git a/src/server/routes/hmr.js b/src/server/routes/hmr.js deleted file mode 100644 index 0bd54483..00000000 --- a/src/server/routes/hmr.js +++ /dev/null @@ -1,26 +0,0 @@ -import express from 'express' -import fs from 'node:fs/promises' -import { dirname, join } from 'pathe' -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' - -const router = express.Router() -const require = createRequire(import.meta.url) -const __dirname = dirname(fileURLToPath(import.meta.url)) - -router.get('/hmr.js', async (_req, res) => { - try { - const morphdomPath = require.resolve('morphdom/dist/morphdom-umd.js') - const morphdomScript = await fs.readFile(morphdomPath, 'utf8') - - const clientScript = await fs.readFile(join(__dirname, '../client.js'), 'utf8') - - res.setHeader('Content-Type', 'application/javascript') - res.send(morphdomScript + clientScript) - } catch (error) { - console.error('Error reading files:', error) - res.status(500).send('Internal Server Error') - } -}) - -export default router diff --git a/src/server/routes/index.js b/src/server/routes/index.js deleted file mode 100644 index b4feb222..00000000 --- a/src/server/routes/index.js +++ /dev/null @@ -1,77 +0,0 @@ -import path from 'pathe' -import express from 'express' -const route = express.Router() -import posthtml from 'posthtml' -import fs from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import expressions from 'posthtml-expressions' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -function groupFilesByDirectories(globs, files) { - const result = {} - let current = {} - - globs.forEach(glob => { - // biome-ignore lint: needs to be escaped - const rootPath = glob.split(/[\*\!\{\}]/)[0].replace(/\/+$/, '') - - files.forEach(file => { - if (file.startsWith(rootPath)) { - const relativePath = file.slice(rootPath.length + 1) - const parts = relativePath.split('/') - current = result[rootPath] = result[rootPath] || {} - - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] = current[parts[i]] || {} - } - - const fileName = parts[parts.length - 1] - current[fileName] = { - name: fileName, - href: encodeURI(file), - } - } - }) - }) - - return result -} - -function flattenPaths(paths, parentPath = '', currentDepth = 0) { - const flatArray = [] - - for (const [key, value] of Object.entries(paths)) { - const fullPath = parentPath ? `${parentPath}/${key}` : key - - if (value && typeof value === 'object' && !value.name) { - // If it's a folder, add it with the current depth and recurse into its contents - flatArray.push({ name: key, path: fullPath, depth: currentDepth, type: 'folder' }) - flatArray.push(...flattenPaths(value, fullPath, currentDepth + 1)) - } else if (value && typeof value === 'object' && value.name) { - // If it's a file, add it with the current depth - flatArray.push({ name: value.name, href: value.href, path: fullPath, depth: currentDepth, type: 'file' }) - } - } - - return flatArray -} - -route.get(['/', '/index.html'], async (req, res) => { - const view = await fs.readFile(path.join(__dirname, '../views', 'index.html'), 'utf8') - - const content = new Set(req.maizzleConfig.build.content) - - const groupedByDir = groupFilesByDirectories(content, req.templatePaths) - - const { html } = await posthtml() - .use(expressions({ - locals: { - paths: flattenPaths(groupedByDir) - } - })) - .process(view) - - res.send(html) -}) - -export default route diff --git a/src/server/ui/App.vue b/src/server/ui/App.vue new file mode 100644 index 00000000..8bbea59d --- /dev/null +++ b/src/server/ui/App.vue @@ -0,0 +1,360 @@ +<script setup lang="ts"> +import { ref, computed, onMounted, onUnmounted, watch, watchEffect } from 'vue' +import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' +import { Monitor, CodeXml, Smartphone, ChevronDown, ArrowUp, ArrowDown, CornerDownLeft, Check, X } from 'lucide-vue-next' +import logoUrl from '@/logo.svg' +import logoGradientUrl from '@/logo-gradient.svg' +import { Kbd } from '@/components/ui/kbd' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarProvider, + SidebarTrigger, + SidebarInput, +} from '@/components/ui/sidebar' + + +interface Template { + name: string + path: string + href: string +} + +const route = useRoute() + +watchEffect(() => { + const slug = route.path === '/' ? '' : route.path.split('/').pop() + document.title = slug ? `Maizzle Dev - ${slug}.vue` : 'Maizzle Dev' +}) + +const templates = ref<Template[]>([]) +const search = ref('') +const loading = ref(true) +const viewMode = ref<'preview' | 'source'>('preview') +const sidebarOpen = ref(localStorage.getItem('maizzle:sidebar') !== 'closed') + +interface DevicePreset { + name: string + width: number + height: number +} + +const devicePresets: DevicePreset[] = [ + { name: 'iPhone 17 Pro', width: 390, height: 844 }, + { name: 'iPhone 17 Pro Max', width: 430, height: 932 }, + { name: 'iPad Pro 11"', width: 834, height: 1194 }, + { name: 'iPad Pro 12.9"', width: 1024, height: 1366 }, + { name: 'Galaxy S26 Ultra', width: 412, height: 915 }, + { name: 'Pixel 9 Pro', width: 393, height: 873 }, + { name: 'Redmi Note 13 Lite', width: 360, height: 800 }, +] + +const selectedDevice = ref<DevicePreset | null>(null) +const panelWidth = ref(0) +const panelHeight = ref(0) +const isDragging = ref(false) +const isFullSize = ref(true) +const resetKey = ref(0) + +function selectDevice(device: DevicePreset) { + selectedDevice.value = device + viewMode.value = 'preview' +} + +watch(sidebarOpen, (open) => { + localStorage.setItem('maizzle:sidebar', open ? 'open' : 'closed') +}) + +async function fetchTemplates() { + const res = await fetch('/__maizzle/templates') + templates.value = await res.json() + loading.value = false +} + +onMounted(fetchTemplates) + +if ((import.meta as any).hot) { + (import.meta as any).hot.on('maizzle:templates-changed', fetchTemplates) +} + +const grouped = computed(() => { + const filtered = templates.value.filter(t => + t.name.toLowerCase().includes(search.value.toLowerCase()) + || t.path.toLowerCase().includes(search.value.toLowerCase()) + ) + + const groups: Record<string, Template[]> = {} + + for (const t of filtered) { + const parts = t.path.split('/') + const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.' + if (!groups[dir]) groups[dir] = [] + groups[dir].push(t) + } + + return groups +}) + +const filteredCount = computed(() => { + return Object.values(grouped.value).reduce((sum, items) => sum + items.length, 0) +}) + +const isActive = (href: string) => route.path === href + +const isPreviewRoute = computed(() => route.path !== '/') + +// Command palette +const router = useRouter() +const commandOpen = ref(false) + +const commandGrouped = computed(() => { + const groups: Record<string, Template[]> = {} + + for (const t of templates.value) { + const parts = t.path.split('/') + const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.' + if (!groups[dir]) groups[dir] = [] + groups[dir].push(t) + } + + return groups +}) + +function getFileName(path: string) { + return path.split('/').pop() || path +} + +function onCommandSelect(href: string) { + commandOpen.value = false + router.push(href) +} + +function onKeydown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + commandOpen.value = !commandOpen.value + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'b') { + e.preventDefault() + sidebarOpen.value = !sidebarOpen.value + return + } + + if (e.key === '/' && !isInputFocused()) { + e.preventDefault() + commandOpen.value = true + } +} + +function isInputFocused() { + const el = document.activeElement + if (!el) return false + const tag = el.tagName.toLowerCase() + return tag === 'input' || tag === 'textarea' || (el as HTMLElement).isContentEditable +} + +onMounted(() => document.addEventListener('keydown', onKeydown)) +onUnmounted(() => document.removeEventListener('keydown', onKeydown)) +</script> + +<template> + <SidebarProvider v-model:open="sidebarOpen"> + <Sidebar collapsible="offcanvas" class="border-r border-gray-200 dark:border-gray-800"> + <SidebarHeader class="h-12 flex-row items-center justify-between border-b border-gray-200 dark:border-gray-800 px-4"> + <RouterLink to="/" class="flex items-center gap-2"> + <img :src="logoUrl" alt="Maizzle" class="h-4 dark:hidden"> + <img :src="logoGradientUrl" alt="Maizzle" class="hidden h-4 dark:block"> + </RouterLink> + <SidebarTrigger class="-mr-1" /> + </SidebarHeader> + + <div class="px-3 pt-3 pb-1"> + <div class="relative flex items-center"> + <SidebarInput + v-model="search" + placeholder="Search emails..." + class="text-xs! pr-7" + @keydown.esc="search && (search = '')" + /> + <button + v-if="search" + class="absolute right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" + @click="search = ''" + > + <X class="size-3.5" /> + </button> + </div> + </div> + + <SidebarContent> + <ScrollArea class="flex-1"> + <SidebarGroup v-if="loading"> + <p class="px-2 py-4 text-xs text-gray-500 dark:text-gray-400">Loading emails...</p> + </SidebarGroup> + + <SidebarGroup v-else-if="filteredCount === 0"> + <p class="px-2 py-4 text-xs text-gray-500 dark:text-gray-400">No emails found.</p> + </SidebarGroup> + + <SidebarGroup v-for="(items, dir) in grouped" :key="dir" v-else> + <SidebarGroupLabel>{{ dir }}</SidebarGroupLabel> + <SidebarGroupContent> + <SidebarMenu> + <SidebarMenuItem v-for="t in items" :key="t.path"> + <SidebarMenuButton + as-child + size="sm" + :is-active="isActive(t.href)" + > + <RouterLink :to="t.href" class="truncate"> + <svg class="size-3 shrink-0 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> + <path d="M14 2v4a2 2 0 0 0 2 2h4" /> + </svg> + <span class="truncate">{{ t.name }}</span> + </RouterLink> + </SidebarMenuButton> + </SidebarMenuItem> + </SidebarMenu> + </SidebarGroupContent> + </SidebarGroup> + </ScrollArea> + </SidebarContent> + + <SidebarFooter class="h-10 justify-center border-t border-gray-200 dark:border-gray-800"> + <p class="text-[10px] text-gray-500 dark:text-gray-400">{{ templates.length }} email{{ templates.length !== 1 ? 's' : '' }}</p> + </SidebarFooter> + </Sidebar> + + <SidebarInset> + <!-- Header toolbar --> + <header class="grid h-12 grid-cols-[1fr_auto_1fr] items-center border-b px-4"> + <div> + <Transition + enter-from-class="opacity-0" + enter-active-class="transition-opacity duration-150 delay-200" + leave-active-class="transition-opacity duration-0" + leave-to-class="opacity-0" + > + <SidebarTrigger v-show="!sidebarOpen" /> + </Transition> + </div> + + <!-- View mode toggles (centered) --> + <ToggleGroup v-if="isPreviewRoute" v-model="viewMode" type="single" variant="outline" size="sm"> + <ToggleGroupItem value="preview"> + <Monitor class="size-4" /> + </ToggleGroupItem> + <ToggleGroupItem value="source"> + <CodeXml class="size-4" /> + </ToggleGroupItem> + </ToggleGroup> + <div v-else /> + + <div class="flex items-center justify-end gap-3"> + <span + v-if="isPreviewRoute && (!isFullSize || selectedDevice) && panelWidth" + class="text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400 select-none" + > + {{ panelWidth }} &times; {{ panelHeight }} + </span> + <DropdownMenu v-if="isPreviewRoute"> + <DropdownMenuTrigger as-child> + <Button variant="outline" size="sm" class="gap-1.5"> + <Smartphone class="size-4" /> + <span v-if="selectedDevice" class="text-xs">{{ selectedDevice.name }}</span> + <ChevronDown class="size-3 opacity-50" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem @click="selectedDevice = null; viewMode = 'preview'; resetKey++"> + <Check v-if="!selectedDevice" class="size-3.5" /> + <span :class="!selectedDevice ? '' : 'pl-5.5'">Responsive</span> + </DropdownMenuItem> + <DropdownMenuItem + v-for="device in devicePresets" + :key="device.name" + @click="selectDevice(device)" + > + <Check v-if="selectedDevice?.name === device.name" class="size-3.5" /> + <span :class="selectedDevice?.name === device.name ? '' : 'pl-5.5'">{{ device.name }}</span> + <span class="ml-auto text-xs text-gray-500 dark:text-gray-400 tabular-nums">{{ device.width }}&times;{{ device.height }}</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </header> + + <!-- Main content --> + <div class="flex-1 overflow-hidden"> + <RouterView v-slot="{ Component }"> + <component :is="Component" v-model:view-mode="viewMode" :device="selectedDevice" :reset-key="resetKey" v-model:panel-width="panelWidth" v-model:panel-height="panelHeight" v-model:is-dragging="isDragging" v-model:is-full-size="isFullSize" @clear-device="selectedDevice = null" /> + </RouterView> + </div> + </SidebarInset> + + <CommandDialog v-model:open="commandOpen" title="Search emails" description="Search and navigate to an email"> + <CommandInput placeholder="Search emails..." /> + <CommandList> + <CommandEmpty>No emails found.</CommandEmpty> + <CommandGroup v-for="(items, dir) in commandGrouped" :key="dir" :heading="String(dir)"> + <CommandItem + v-for="t in items" + :key="t.path" + :value="t.path" + @select="onCommandSelect(t.href)" + > + <svg class="size-3 shrink-0 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> + <path d="M14 2v4a2 2 0 0 0 2 2h4" /> + </svg> + <span>{{ getFileName(t.path) }}</span> + </CommandItem> + </CommandGroup> + </CommandList> + <div class="flex items-center gap-4 border-t px-3 py-2 text-xs text-gray-500 dark:text-gray-400"> + <span class="inline-flex items-center gap-1"> + <Kbd><ArrowUp class="size-3" /></Kbd> + <Kbd><ArrowDown class="size-3" /></Kbd> + Navigate + </span> + <span class="inline-flex items-center gap-1"> + <Kbd><CornerDownLeft class="size-3" /></Kbd> + Open + </span> + <span class="inline-flex items-center gap-1"> + <Kbd>Esc</Kbd> + Close + </span> + </div> + </CommandDialog> + </SidebarProvider> +</template> diff --git a/src/server/ui/components/ui/button/Button.vue b/src/server/ui/components/ui/button/Button.vue new file mode 100644 index 00000000..3763470a --- /dev/null +++ b/src/server/ui/components/ui/button/Button.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import type { ButtonVariants } from "." +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" +import { buttonVariants } from "." + +interface Props extends PrimitiveProps { + variant?: ButtonVariants["variant"] + size?: ButtonVariants["size"] + class?: HTMLAttributes["class"] +} + +const props = withDefaults(defineProps<Props>(), { + as: "button", +}) +</script> + +<template> + <Primitive + data-slot="button" + :data-variant="variant" + :data-size="size" + :as="as" + :as-child="asChild" + :class="cn(buttonVariants({ variant, size }), props.class)" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/button/index.ts b/src/server/ui/components/ui/button/index.ts new file mode 100644 index 00000000..26e2c559 --- /dev/null +++ b/src/server/ui/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps<typeof buttonVariants> diff --git a/src/server/ui/components/ui/command/Command.vue b/src/server/ui/components/ui/command/Command.vue new file mode 100644 index 00000000..dcdf9d60 --- /dev/null +++ b/src/server/ui/components/ui/command/Command.vue @@ -0,0 +1,87 @@ +<script setup lang="ts"> +import type { ListboxRootEmits, ListboxRootProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui" +import { reactive, ref, watch } from "vue" +import { cn } from "@/lib/utils" +import { provideCommandContext } from "." + +const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), { + modelValue: "", +}) + +const emits = defineEmits<ListboxRootEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) + +const allItems = ref<Map<string, string>>(new Map()) +const allGroups = ref<Map<string, Set<string>>>(new Map()) + +const { contains } = useFilter({ sensitivity: "base" }) +const filterState = reactive({ + search: "", + filtered: { + /** The count of all visible items. */ + count: 0, + /** Map from visible item id to its search score. */ + items: new Map() as Map<string, number>, + /** Set of groups with at least one visible item. */ + groups: new Set() as Set<string>, + }, +}) + +function filterItems() { + if (!filterState.search) { + filterState.filtered.count = allItems.value.size + // Do nothing, each item will know to show itself because search is empty + return + } + + // Reset the groups + filterState.filtered.groups = new Set() + let itemCount = 0 + + // Check which items should be included + for (const [id, value] of allItems.value) { + const score = contains(value, filterState.search) + filterState.filtered.items.set(id, score ? 1 : 0) + if (score) + itemCount++ + } + + // Check which groups have at least 1 item shown + for (const [groupId, group] of allGroups.value) { + for (const itemId of group) { + if (filterState.filtered.items.get(itemId)! > 0) { + filterState.filtered.groups.add(groupId) + break + } + } + } + + filterState.filtered.count = itemCount +} + +watch(() => filterState.search, () => { + filterItems() +}) + +provideCommandContext({ + allItems, + allGroups, + filterState, +}) +</script> + +<template> + <ListboxRoot + data-slot="command" + v-bind="forwarded" + :class="cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)" + > + <slot /> + </ListboxRoot> +</template> diff --git a/src/server/ui/components/ui/command/CommandDialog.vue b/src/server/ui/components/ui/command/CommandDialog.vue new file mode 100644 index 00000000..74397360 --- /dev/null +++ b/src/server/ui/components/ui/command/CommandDialog.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"> +import type { DialogRootEmits, DialogRootProps } from "reka-ui" +import { useForwardPropsEmits } from "reka-ui" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import Command from "./Command.vue" + +const props = withDefaults(defineProps<DialogRootProps & { + title?: string + description?: string +}>(), { + title: "Command Palette", + description: "Search for a command to run...", +}) +const emits = defineEmits<DialogRootEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <Dialog v-slot="slotProps" v-bind="forwarded"> + <DialogContent class="overflow-hidden p-0 "> + <DialogHeader class="sr-only"> + <DialogTitle>{{ title }}</DialogTitle> + <DialogDescription>{{ description }}</DialogDescription> + </DialogHeader> + <Command> + <slot v-bind="slotProps" /> + </Command> + </DialogContent> + </Dialog> +</template> diff --git a/src/server/ui/components/ui/command/CommandEmpty.vue b/src/server/ui/components/ui/command/CommandEmpty.vue new file mode 100644 index 00000000..489c4064 --- /dev/null +++ b/src/server/ui/components/ui/command/CommandEmpty.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Primitive } from "reka-ui" +import { computed } from "vue" +import { cn } from "@/lib/utils" +import { useCommand } from "." + +const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") + +const { filterState } = useCommand() +const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0, +) +</script> + +<template> + <Primitive + v-if="isRender" + data-slot="command-empty" + v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/command/CommandGroup.vue b/src/server/ui/components/ui/command/CommandGroup.vue new file mode 100644 index 00000000..a5dd55e3 --- /dev/null +++ b/src/server/ui/components/ui/command/CommandGroup.vue @@ -0,0 +1,45 @@ +<script setup lang="ts"> +import type { ListboxGroupProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui" +import { computed, onMounted, onUnmounted } from "vue" +import { cn } from "@/lib/utils" +import { provideCommandGroupContext, useCommand } from "." + +const props = defineProps<ListboxGroupProps & { + class?: HTMLAttributes["class"] + heading?: string +}>() + +const delegatedProps = reactiveOmit(props, "class") + +const { allGroups, filterState } = useCommand() +const id = useId() + +const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id)) + +provideCommandGroupContext({ id }) +onMounted(() => { + if (!allGroups.value.has(id)) + allGroups.value.set(id, new Set()) +}) +onUnmounted(() => { + allGroups.value.delete(id) +}) +</script> + +<template> + <ListboxGroup + v-bind="delegatedProps" + :id="id" + data-slot="command-group" + :class="cn('text-foreground overflow-hidden p-1', props.class)" + :hidden="isRender ? undefined : true" + > + <ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground"> + {{ heading }} + </ListboxGroupLabel> + <slot /> + </ListboxGroup> +</template> diff --git a/src/server/ui/components/ui/command/CommandInput.vue b/src/server/ui/components/ui/command/CommandInput.vue new file mode 100644 index 00000000..653141e4 --- /dev/null +++ b/src/server/ui/components/ui/command/CommandInput.vue @@ -0,0 +1,39 @@ +<script setup lang="ts"> +import type { ListboxFilterProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Search } from "lucide-vue-next" +import { ListboxFilter, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" +import { useCommand } from "." + +defineOptions({ + inheritAttrs: false, +}) + +const props = defineProps<ListboxFilterProps & { + class?: HTMLAttributes["class"] +}>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwardedProps = useForwardProps(delegatedProps) + +const { filterState } = useCommand() +</script> + +<template> + <div + data-slot="command-input-wrapper" + class="flex h-9 items-center gap-2 border-b px-3" + > + <Search class="size-4 shrink-0 opacity-50" /> + <ListboxFilter + v-bind="{ ...forwardedProps, ...$attrs }" + v-model="filterState.search" + data-slot="command-input" + auto-focus + :class="cn('placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)" + /> + </div> +</template> diff --git a/src/server/ui/components/ui/command/CommandItem.vue b/src/server/ui/components/ui/command/CommandItem.vue new file mode 100644 index 00000000..2ae4827d --- /dev/null +++ b/src/server/ui/components/ui/command/CommandItem.vue @@ -0,0 +1,76 @@ +<script setup lang="ts"> +import type { ListboxItemEmits, ListboxItemProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit, useCurrentElement } from "@vueuse/core" +import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui" +import { computed, onMounted, onUnmounted, ref } from "vue" +import { cn } from "@/lib/utils" +import { useCommand, useCommandGroup } from "." + +const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<ListboxItemEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) + +const id = useId() +const { filterState, allItems, allGroups } = useCommand() +const groupContext = useCommandGroup() + +const isRender = computed(() => { + if (!filterState.search) { + return true + } + else { + const filteredCurrentItem = filterState.filtered.items.get(id) + // If the filtered items is undefined means not in the all times map yet + // Do the first render to add into the map + if (filteredCurrentItem === undefined) { + return true + } + + // Check with filter + return filteredCurrentItem > 0 + } +}) + +const itemRef = ref() +const currentElement = useCurrentElement(itemRef) +onMounted(() => { + if (!(currentElement.value instanceof HTMLElement)) + return + + // textValue to perform filter + allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? "")) + + const groupId = groupContext?.id + if (groupId) { + if (!allGroups.value.has(groupId)) { + allGroups.value.set(groupId, new Set([id])) + } + else { + allGroups.value.get(groupId)?.add(id) + } + } +}) +onUnmounted(() => { + allItems.value.delete(id) +}) +</script> + +<template> + <ListboxItem + v-if="isRender" + v-bind="forwarded" + :id="id" + ref="itemRef" + data-slot="command-item" + :class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" + @select="() => { + filterState.search = '' + }" + > + <slot /> + </ListboxItem> +</template> diff --git a/src/server/ui/components/ui/command/CommandList.vue b/src/server/ui/components/ui/command/CommandList.vue new file mode 100644 index 00000000..928d2f0f --- /dev/null +++ b/src/server/ui/components/ui/command/CommandList.vue @@ -0,0 +1,25 @@ +<script setup lang="ts"> +import type { ListboxContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ListboxContent, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardProps(delegatedProps) +</script> + +<template> + <ListboxContent + data-slot="command-list" + v-bind="forwarded" + :class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)" + > + <div role="presentation"> + <slot /> + </div> + </ListboxContent> +</template> diff --git a/src/server/ui/components/ui/command/CommandSeparator.vue b/src/server/ui/components/ui/command/CommandSeparator.vue new file mode 100644 index 00000000..6def19ec --- /dev/null +++ b/src/server/ui/components/ui/command/CommandSeparator.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { SeparatorProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Separator } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <Separator + data-slot="command-separator" + v-bind="delegatedProps" + :class="cn('bg-border -mx-1 h-px', props.class)" + > + <slot /> + </Separator> +</template> diff --git a/src/server/ui/components/ui/command/CommandShortcut.vue b/src/server/ui/components/ui/command/CommandShortcut.vue new file mode 100644 index 00000000..e1d0e07a --- /dev/null +++ b/src/server/ui/components/ui/command/CommandShortcut.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <span + data-slot="command-shortcut" + :class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)" + > + <slot /> + </span> +</template> diff --git a/src/server/ui/components/ui/command/index.ts b/src/server/ui/components/ui/command/index.ts new file mode 100644 index 00000000..af18933b --- /dev/null +++ b/src/server/ui/components/ui/command/index.ts @@ -0,0 +1,25 @@ +import type { Ref } from "vue" +import { createContext } from "reka-ui" + +export { default as Command } from "./Command.vue" +export { default as CommandDialog } from "./CommandDialog.vue" +export { default as CommandEmpty } from "./CommandEmpty.vue" +export { default as CommandGroup } from "./CommandGroup.vue" +export { default as CommandInput } from "./CommandInput.vue" +export { default as CommandItem } from "./CommandItem.vue" +export { default as CommandList } from "./CommandList.vue" +export { default as CommandSeparator } from "./CommandSeparator.vue" +export { default as CommandShortcut } from "./CommandShortcut.vue" + +export const [useCommand, provideCommandContext] = createContext<{ + allItems: Ref<Map<string, string>> + allGroups: Ref<Map<string, Set<string>>> + filterState: { + search: string + filtered: { count: number, items: Map<string, number>, groups: Set<string> } + } +}>("Command") + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string +}>("CommandGroup") diff --git a/src/server/ui/components/ui/dialog/Dialog.vue b/src/server/ui/components/ui/dialog/Dialog.vue new file mode 100644 index 00000000..ade52603 --- /dev/null +++ b/src/server/ui/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ +<script setup lang="ts"> +import type { DialogRootEmits, DialogRootProps } from "reka-ui" +import { DialogRoot, useForwardPropsEmits } from "reka-ui" + +const props = defineProps<DialogRootProps>() +const emits = defineEmits<DialogRootEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <DialogRoot + v-slot="slotProps" + data-slot="dialog" + v-bind="forwarded" + > + <slot v-bind="slotProps" /> + </DialogRoot> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogClose.vue b/src/server/ui/components/ui/dialog/DialogClose.vue new file mode 100644 index 00000000..c5fae043 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { DialogCloseProps } from "reka-ui" +import { DialogClose } from "reka-ui" + +const props = defineProps<DialogCloseProps>() +</script> + +<template> + <DialogClose + data-slot="dialog-close" + v-bind="props" + > + <slot /> + </DialogClose> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogContent.vue b/src/server/ui/components/ui/dialog/DialogContent.vue new file mode 100644 index 00000000..9236758b --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ +<script setup lang="ts"> +import type { DialogContentEmits, DialogContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { X } from "lucide-vue-next" +import { + DialogClose, + DialogContent, + DialogPortal, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" +import DialogOverlay from "./DialogOverlay.vue" + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { + showCloseButton: true, +}) +const emits = defineEmits<DialogContentEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DialogPortal> + <DialogOverlay /> + <DialogContent + data-slot="dialog-content" + v-bind="{ ...$attrs, ...forwarded }" + :class=" + cn( + 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-xl shadow-black/5 duration-200 sm:max-w-lg', + props.class, + )" + > + <slot /> + + <DialogClose + v-if="showCloseButton" + data-slot="dialog-close" + class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + <X /> + <span class="sr-only">Close</span> + </DialogClose> + </DialogContent> + </DialogPortal> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogDescription.vue b/src/server/ui/components/ui/dialog/DialogDescription.vue new file mode 100644 index 00000000..f52e6555 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ +<script setup lang="ts"> +import type { DialogDescriptionProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogDescription, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <DialogDescription + data-slot="dialog-description" + v-bind="forwardedProps" + :class="cn('text-muted-foreground text-sm', props.class)" + > + <slot /> + </DialogDescription> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogFooter.vue b/src/server/ui/components/ui/dialog/DialogFooter.vue new file mode 100644 index 00000000..c7500a96 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { DialogClose } from "reka-ui" +import { cn } from "@/lib/utils" +import { Button } from '@/components/ui/button' + +const props = withDefaults(defineProps<{ + class?: HTMLAttributes["class"] + showCloseButton?: boolean +}>(), { + showCloseButton: false, +}) +</script> + +<template> + <div + data-slot="dialog-footer" + :class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)" + > + <slot /> + <DialogClose v-if="showCloseButton" as-child> + <Button variant="outline"> + Close + </Button> + </DialogClose> + </div> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogHeader.vue b/src/server/ui/components/ui/dialog/DialogHeader.vue new file mode 100644 index 00000000..bfc3c646 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="dialog-header" + :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogOverlay.vue b/src/server/ui/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 00000000..7ffaaa41 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { DialogOverlayProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogOverlay } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <DialogOverlay + data-slot="dialog-overlay" + v-bind="delegatedProps" + :class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/80', props.class)" + > + <slot /> + </DialogOverlay> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogScrollContent.vue b/src/server/ui/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 00000000..f2475dba --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,59 @@ +<script setup lang="ts"> +import type { DialogContentEmits, DialogContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { X } from "lucide-vue-next" +import { + DialogClose, + DialogContent, + DialogOverlay, + DialogPortal, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" + +defineOptions({ + inheritAttrs: false, +}) + +const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<DialogContentEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DialogPortal> + <DialogOverlay + class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" + > + <DialogContent + :class=" + cn( + 'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full', + props.class, + ) + " + v-bind="{ ...$attrs, ...forwarded }" + @pointer-down-outside="(event) => { + const originalEvent = event.detail.originalEvent; + const target = originalEvent.target as HTMLElement; + if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) { + event.preventDefault(); + } + }" + > + <slot /> + + <DialogClose + class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary" + > + <X class="w-4 h-4" /> + <span class="sr-only">Close</span> + </DialogClose> + </DialogContent> + </DialogOverlay> + </DialogPortal> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogTitle.vue b/src/server/ui/components/ui/dialog/DialogTitle.vue new file mode 100644 index 00000000..860f01a4 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ +<script setup lang="ts"> +import type { DialogTitleProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogTitle, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <DialogTitle + data-slot="dialog-title" + v-bind="forwardedProps" + :class="cn('text-lg leading-none font-semibold', props.class)" + > + <slot /> + </DialogTitle> +</template> diff --git a/src/server/ui/components/ui/dialog/DialogTrigger.vue b/src/server/ui/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 00000000..49667e99 --- /dev/null +++ b/src/server/ui/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { DialogTriggerProps } from "reka-ui" +import { DialogTrigger } from "reka-ui" + +const props = defineProps<DialogTriggerProps>() +</script> + +<template> + <DialogTrigger + data-slot="dialog-trigger" + v-bind="props" + > + <slot /> + </DialogTrigger> +</template> diff --git a/src/server/ui/components/ui/dialog/index.ts b/src/server/ui/components/ui/dialog/index.ts new file mode 100644 index 00000000..6768b090 --- /dev/null +++ b/src/server/ui/components/ui/dialog/index.ts @@ -0,0 +1,10 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogOverlay } from "./DialogOverlay.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenu.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenu.vue new file mode 100644 index 00000000..e1c9ee30 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenu.vue @@ -0,0 +1,19 @@ +<script setup lang="ts"> +import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui" +import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui" + +const props = defineProps<DropdownMenuRootProps>() +const emits = defineEmits<DropdownMenuRootEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <DropdownMenuRoot + v-slot="slotProps" + data-slot="dropdown-menu" + v-bind="forwarded" + > + <slot v-bind="slotProps" /> + </DropdownMenuRoot> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue new file mode 100644 index 00000000..1253078d --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue @@ -0,0 +1,39 @@ +<script setup lang="ts"> +import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Check } from "lucide-vue-next" +import { + DropdownMenuCheckboxItem, + DropdownMenuItemIndicator, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<DropdownMenuCheckboxItemEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DropdownMenuCheckboxItem + data-slot="dropdown-menu-checkbox-item" + v-bind="forwarded" + :class=" cn( + 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', + props.class, + )" + > + <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuItemIndicator> + <slot name="indicator-icon"> + <Check class="size-4" /> + </slot> + </DropdownMenuItemIndicator> + </span> + <slot /> + </DropdownMenuCheckboxItem> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuContent.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuContent.vue new file mode 100644 index 00000000..7c430141 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuContent.vue @@ -0,0 +1,39 @@ +<script setup lang="ts"> +import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { + DropdownMenuContent, + DropdownMenuPortal, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults( + defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), + { + sideOffset: 4, + }, +) +const emits = defineEmits<DropdownMenuContentEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DropdownMenuPortal> + <DropdownMenuContent + data-slot="dropdown-menu-content" + v-bind="{ ...$attrs, ...forwarded }" + :class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)" + > + <slot /> + </DropdownMenuContent> + </DropdownMenuPortal> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuGroup.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuGroup.vue new file mode 100644 index 00000000..da634ec0 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuGroup.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { DropdownMenuGroupProps } from "reka-ui" +import { DropdownMenuGroup } from "reka-ui" + +const props = defineProps<DropdownMenuGroupProps>() +</script> + +<template> + <DropdownMenuGroup + data-slot="dropdown-menu-group" + v-bind="props" + > + <slot /> + </DropdownMenuGroup> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue new file mode 100644 index 00000000..f56cae3e --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"> +import type { DropdownMenuItemProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DropdownMenuItem, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = withDefaults(defineProps<DropdownMenuItemProps & { + class?: HTMLAttributes["class"] + inset?: boolean + variant?: "default" | "destructive" +}>(), { + variant: "default", +}) + +const delegatedProps = reactiveOmit(props, "inset", "variant", "class") + +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <DropdownMenuItem + data-slot="dropdown-menu-item" + :data-inset="inset ? '' : undefined" + :data-variant="variant" + v-bind="forwardedProps" + :class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" + > + <slot /> + </DropdownMenuItem> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuLabel.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuLabel.vue new file mode 100644 index 00000000..8bca83c4 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuLabel.vue @@ -0,0 +1,23 @@ +<script setup lang="ts"> +import type { DropdownMenuLabelProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DropdownMenuLabel, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>() + +const delegatedProps = reactiveOmit(props, "class", "inset") +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <DropdownMenuLabel + data-slot="dropdown-menu-label" + :data-inset="inset ? '' : undefined" + v-bind="forwardedProps" + :class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)" + > + <slot /> + </DropdownMenuLabel> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue new file mode 100644 index 00000000..fe82cadd --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui" +import { + DropdownMenuRadioGroup, + useForwardPropsEmits, +} from "reka-ui" + +const props = defineProps<DropdownMenuRadioGroupProps>() +const emits = defineEmits<DropdownMenuRadioGroupEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <DropdownMenuRadioGroup + data-slot="dropdown-menu-radio-group" + v-bind="forwarded" + > + <slot /> + </DropdownMenuRadioGroup> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioItem.vue new file mode 100644 index 00000000..e03c40c5 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuRadioItem.vue @@ -0,0 +1,40 @@ +<script setup lang="ts"> +import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Circle } from "lucide-vue-next" +import { + DropdownMenuItemIndicator, + DropdownMenuRadioItem, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>() + +const emits = defineEmits<DropdownMenuRadioItemEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DropdownMenuRadioItem + data-slot="dropdown-menu-radio-item" + v-bind="forwarded" + :class="cn( + 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', + props.class, + )" + > + <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuItemIndicator> + <slot name="indicator-icon"> + <Circle class="size-2 fill-current" /> + </slot> + </DropdownMenuItemIndicator> + </span> + <slot /> + </DropdownMenuRadioItem> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuSeparator.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSeparator.vue new file mode 100644 index 00000000..1b936c39 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSeparator.vue @@ -0,0 +1,23 @@ +<script setup lang="ts"> +import type { DropdownMenuSeparatorProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { + DropdownMenuSeparator, +} from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuSeparatorProps & { + class?: HTMLAttributes["class"] +}>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <DropdownMenuSeparator + data-slot="dropdown-menu-separator" + v-bind="delegatedProps" + :class="cn('bg-border -mx-1 my-1 h-px', props.class)" + /> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuShortcut.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuShortcut.vue new file mode 100644 index 00000000..60be75cc --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuShortcut.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <span + data-slot="dropdown-menu-shortcut" + :class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)" + > + <slot /> + </span> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuSub.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSub.vue new file mode 100644 index 00000000..7472e77f --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSub.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui" +import { + DropdownMenuSub, + useForwardPropsEmits, +} from "reka-ui" + +const props = defineProps<DropdownMenuSubProps>() +const emits = defineEmits<DropdownMenuSubEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded"> + <slot v-bind="slotProps" /> + </DropdownMenuSub> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubContent.vue new file mode 100644 index 00000000..d7c6b087 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { + DropdownMenuSubContent, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<DropdownMenuSubContentEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DropdownMenuSubContent + data-slot="dropdown-menu-sub-content" + v-bind="forwarded" + :class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)" + > + <slot /> + </DropdownMenuSubContent> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue new file mode 100644 index 00000000..09064911 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"> +import type { DropdownMenuSubTriggerProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ChevronRight } from "lucide-vue-next" +import { + DropdownMenuSubTrigger, + useForwardProps, +} from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>() + +const delegatedProps = reactiveOmit(props, "class", "inset") +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <DropdownMenuSubTrigger + data-slot="dropdown-menu-sub-trigger" + v-bind="forwardedProps" + :data-inset="inset ? '' : undefined" + :class="cn( + 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground', + props.class, + )" + > + <slot /> + <ChevronRight class="ml-auto size-4" /> + </DropdownMenuSubTrigger> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/DropdownMenuTrigger.vue b/src/server/ui/components/ui/dropdown-menu/DropdownMenuTrigger.vue new file mode 100644 index 00000000..75cd7472 --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/DropdownMenuTrigger.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { DropdownMenuTriggerProps } from "reka-ui" +import { DropdownMenuTrigger, useForwardProps } from "reka-ui" + +const props = defineProps<DropdownMenuTriggerProps>() + +const forwardedProps = useForwardProps(props) +</script> + +<template> + <DropdownMenuTrigger + data-slot="dropdown-menu-trigger" + v-bind="forwardedProps" + > + <slot /> + </DropdownMenuTrigger> +</template> diff --git a/src/server/ui/components/ui/dropdown-menu/index.ts b/src/server/ui/components/ui/dropdown-menu/index.ts new file mode 100644 index 00000000..955fe3aa --- /dev/null +++ b/src/server/ui/components/ui/dropdown-menu/index.ts @@ -0,0 +1,16 @@ +export { default as DropdownMenu } from "./DropdownMenu.vue" + +export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue" +export { default as DropdownMenuContent } from "./DropdownMenuContent.vue" +export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue" +export { default as DropdownMenuItem } from "./DropdownMenuItem.vue" +export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue" +export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue" +export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue" +export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue" +export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue" +export { default as DropdownMenuSub } from "./DropdownMenuSub.vue" +export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue" +export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue" +export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue" +export { DropdownMenuPortal } from "reka-ui" diff --git a/src/server/ui/components/ui/empty/Empty.vue b/src/server/ui/components/ui/empty/Empty.vue new file mode 100644 index 00000000..2637b3c7 --- /dev/null +++ b/src/server/ui/components/ui/empty/Empty.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="empty" + :class="cn( + 'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12', + props.class, + )" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/empty/EmptyContent.vue b/src/server/ui/components/ui/empty/EmptyContent.vue new file mode 100644 index 00000000..d19ee000 --- /dev/null +++ b/src/server/ui/components/ui/empty/EmptyContent.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="empty-content" + :class="cn( + 'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm', + props.class, + )" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/empty/EmptyDescription.vue b/src/server/ui/components/ui/empty/EmptyDescription.vue new file mode 100644 index 00000000..011c0ff7 --- /dev/null +++ b/src/server/ui/components/ui/empty/EmptyDescription.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <p + data-slot="empty-description" + :class="cn( + 'text-gray-500 dark:text-gray-400 [&>a:hover]:text-indigo-600 [&>a:hover]:dark:text-indigo-400 text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + props.class, + )" + > + <slot /> + </p> +</template> diff --git a/src/server/ui/components/ui/empty/EmptyHeader.vue b/src/server/ui/components/ui/empty/EmptyHeader.vue new file mode 100644 index 00000000..ac21d8c8 --- /dev/null +++ b/src/server/ui/components/ui/empty/EmptyHeader.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="empty-header" + :class="cn( + 'flex max-w-sm flex-col items-center gap-2 text-center', + props.class, + )" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/empty/EmptyMedia.vue b/src/server/ui/components/ui/empty/EmptyMedia.vue new file mode 100644 index 00000000..c68397c0 --- /dev/null +++ b/src/server/ui/components/ui/empty/EmptyMedia.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import type { EmptyMediaVariants } from "." +import { cn } from "@/lib/utils" +import { emptyMediaVariants } from "." + +const props = defineProps<{ + class?: HTMLAttributes["class"] + variant?: EmptyMediaVariants["variant"] +}>() +</script> + +<template> + <div + data-slot="empty-icon" + :data-variant="variant" + :class="cn(emptyMediaVariants({ variant }), props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/empty/EmptyTitle.vue b/src/server/ui/components/ui/empty/EmptyTitle.vue new file mode 100644 index 00000000..90c950d2 --- /dev/null +++ b/src/server/ui/components/ui/empty/EmptyTitle.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="empty-title" + :class="cn('text-lg font-medium tracking-tight', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/empty/index.ts b/src/server/ui/components/ui/empty/index.ts new file mode 100644 index 00000000..0777d23e --- /dev/null +++ b/src/server/ui/components/ui/empty/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Empty } from "./Empty.vue" +export { default as EmptyContent } from "./EmptyContent.vue" +export { default as EmptyDescription } from "./EmptyDescription.vue" +export { default as EmptyHeader } from "./EmptyHeader.vue" +export { default as EmptyMedia } from "./EmptyMedia.vue" +export { default as EmptyTitle } from "./EmptyTitle.vue" + +export const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants> diff --git a/src/server/ui/components/ui/input/Input.vue b/src/server/ui/components/ui/input/Input.vue new file mode 100644 index 00000000..e5135c14 --- /dev/null +++ b/src/server/ui/components/ui/input/Input.vue @@ -0,0 +1,33 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { useVModel } from "@vueuse/core" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + defaultValue?: string | number + modelValue?: string | number + class?: HTMLAttributes["class"] +}>() + +const emits = defineEmits<{ + (e: "update:modelValue", payload: string | number): void +}>() + +const modelValue = useVModel(props, "modelValue", emits, { + passive: true, + defaultValue: props.defaultValue, +}) +</script> + +<template> + <input + v-model="modelValue" + data-slot="input" + :class="cn( + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', + 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + props.class, + )" + > +</template> diff --git a/src/server/ui/components/ui/input/index.ts b/src/server/ui/components/ui/input/index.ts new file mode 100644 index 00000000..9976b86f --- /dev/null +++ b/src/server/ui/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from "./Input.vue" diff --git a/src/server/ui/components/ui/kbd/Kbd.vue b/src/server/ui/components/ui/kbd/Kbd.vue new file mode 100644 index 00000000..b0bde322 --- /dev/null +++ b/src/server/ui/components/ui/kbd/Kbd.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <kbd + :class="cn( + 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none', + '[&_svg:not([class*=\'size-\'])]:size-3', + props.class, + )" + > + <slot /> + </kbd> +</template> diff --git a/src/server/ui/components/ui/kbd/KbdGroup.vue b/src/server/ui/components/ui/kbd/KbdGroup.vue new file mode 100644 index 00000000..e0cd8622 --- /dev/null +++ b/src/server/ui/components/ui/kbd/KbdGroup.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <kbd + data-slot="kbd-group" + :class="cn('inline-flex items-center gap-1', props.class)" + > + <slot /> + </kbd> +</template> diff --git a/src/server/ui/components/ui/kbd/index.ts b/src/server/ui/components/ui/kbd/index.ts new file mode 100644 index 00000000..b613b0cc --- /dev/null +++ b/src/server/ui/components/ui/kbd/index.ts @@ -0,0 +1,2 @@ +export { default as Kbd } from "./Kbd.vue" +export { default as KbdGroup } from "./KbdGroup.vue" diff --git a/src/server/ui/components/ui/resizable/ResizableHandle.vue b/src/server/ui/components/ui/resizable/ResizableHandle.vue new file mode 100644 index 00000000..6848165e --- /dev/null +++ b/src/server/ui/components/ui/resizable/ResizableHandle.vue @@ -0,0 +1,30 @@ +<script setup lang="ts"> +import type { SplitterResizeHandleEmits, SplitterResizeHandleProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { GripVertical } from "lucide-vue-next" +import { SplitterResizeHandle, useForwardPropsEmits } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<SplitterResizeHandleProps & { class?: HTMLAttributes["class"], withHandle?: boolean }>() +const emits = defineEmits<SplitterResizeHandleEmits>() + +const delegatedProps = reactiveOmit(props, "class", "withHandle") +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <SplitterResizeHandle + data-slot="resizable-handle" + v-bind="forwarded" + :class="cn('bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90', props.class)" + > + <template v-if="props.withHandle"> + <div class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border"> + <slot> + <GripVertical class="size-2.5" /> + </slot> + </div> + </template> + </SplitterResizeHandle> +</template> diff --git a/src/server/ui/components/ui/resizable/ResizablePanel.vue b/src/server/ui/components/ui/resizable/ResizablePanel.vue new file mode 100644 index 00000000..e52e1ecb --- /dev/null +++ b/src/server/ui/components/ui/resizable/ResizablePanel.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { SplitterPanelEmits, SplitterPanelProps } from "reka-ui" +import { SplitterPanel, useForwardExpose, useForwardPropsEmits } from "reka-ui" + +const props = defineProps<SplitterPanelProps>() +const emits = defineEmits<SplitterPanelEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +const { forwardRef } = useForwardExpose() +</script> + +<template> + <SplitterPanel + :ref="forwardRef" + v-slot="slotProps" + data-slot="resizable-panel" + v-bind="forwarded" + > + <slot v-bind="slotProps" /> + </SplitterPanel> +</template> diff --git a/src/server/ui/components/ui/resizable/ResizablePanelGroup.vue b/src/server/ui/components/ui/resizable/ResizablePanelGroup.vue new file mode 100644 index 00000000..dd55b50c --- /dev/null +++ b/src/server/ui/components/ui/resizable/ResizablePanelGroup.vue @@ -0,0 +1,25 @@ +<script setup lang="ts"> +import type { SplitterGroupEmits, SplitterGroupProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { SplitterGroup, useForwardPropsEmits } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<SplitterGroupProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<SplitterGroupEmits>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <SplitterGroup + v-slot="slotProps" + data-slot="resizable-panel-group" + v-bind="forwarded" + :class="cn('flex h-full w-full data-[orientation=vertical]:flex-col', props.class)" + > + <slot v-bind="slotProps" /> + </SplitterGroup> +</template> diff --git a/src/server/ui/components/ui/resizable/index.ts b/src/server/ui/components/ui/resizable/index.ts new file mode 100644 index 00000000..89e5d770 --- /dev/null +++ b/src/server/ui/components/ui/resizable/index.ts @@ -0,0 +1,3 @@ +export { default as ResizableHandle } from "./ResizableHandle.vue" +export { default as ResizablePanel } from "./ResizablePanel.vue" +export { default as ResizablePanelGroup } from "./ResizablePanelGroup.vue" diff --git a/src/server/ui/components/ui/scroll-area/ScrollArea.vue b/src/server/ui/components/ui/scroll-area/ScrollArea.vue new file mode 100644 index 00000000..6112caad --- /dev/null +++ b/src/server/ui/components/ui/scroll-area/ScrollArea.vue @@ -0,0 +1,33 @@ +<script setup lang="ts"> +import type { ScrollAreaRootProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { + ScrollAreaCorner, + ScrollAreaRoot, + ScrollAreaViewport, +} from "reka-ui" +import { cn } from "@/lib/utils" +import ScrollBar from "./ScrollBar.vue" + +const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <ScrollAreaRoot + data-slot="scroll-area" + v-bind="delegatedProps" + :class="cn('relative', props.class)" + > + <ScrollAreaViewport + data-slot="scroll-area-viewport" + class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" + > + <slot /> + </ScrollAreaViewport> + <ScrollBar /> + <ScrollAreaCorner /> + </ScrollAreaRoot> +</template> diff --git a/src/server/ui/components/ui/scroll-area/ScrollBar.vue b/src/server/ui/components/ui/scroll-area/ScrollBar.vue new file mode 100644 index 00000000..a0b6f9b7 --- /dev/null +++ b/src/server/ui/components/ui/scroll-area/ScrollBar.vue @@ -0,0 +1,32 @@ +<script setup lang="ts"> +import type { ScrollAreaScrollbarProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), { + orientation: "vertical", +}) + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <ScrollAreaScrollbar + data-slot="scroll-area-scrollbar" + v-bind="delegatedProps" + :class=" + cn('flex touch-none p-px transition-colors select-none', + orientation === 'vertical' + && 'h-full w-2.5 border-l border-l-transparent', + orientation === 'horizontal' + && 'h-2.5 flex-col border-t border-t-transparent', + props.class)" + > + <ScrollAreaThumb + data-slot="scroll-area-thumb" + class="bg-border relative flex-1 rounded-full" + /> + </ScrollAreaScrollbar> +</template> diff --git a/src/server/ui/components/ui/scroll-area/index.ts b/src/server/ui/components/ui/scroll-area/index.ts new file mode 100644 index 00000000..c416759c --- /dev/null +++ b/src/server/ui/components/ui/scroll-area/index.ts @@ -0,0 +1,2 @@ +export { default as ScrollArea } from "./ScrollArea.vue" +export { default as ScrollBar } from "./ScrollBar.vue" diff --git a/src/server/ui/components/ui/separator/Separator.vue b/src/server/ui/components/ui/separator/Separator.vue new file mode 100644 index 00000000..78d60eca --- /dev/null +++ b/src/server/ui/components/ui/separator/Separator.vue @@ -0,0 +1,29 @@ +<script setup lang="ts"> +import type { SeparatorProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { Separator } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = withDefaults(defineProps< + SeparatorProps & { class?: HTMLAttributes["class"] } +>(), { + orientation: "horizontal", + decorative: true, +}) + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <Separator + data-slot="separator" + v-bind="delegatedProps" + :class=" + cn( + 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', + props.class, + ) + " + /> +</template> diff --git a/src/server/ui/components/ui/separator/index.ts b/src/server/ui/components/ui/separator/index.ts new file mode 100644 index 00000000..44072873 --- /dev/null +++ b/src/server/ui/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue" diff --git a/src/server/ui/components/ui/sheet/Sheet.vue b/src/server/ui/components/ui/sheet/Sheet.vue new file mode 100644 index 00000000..8522f84d --- /dev/null +++ b/src/server/ui/components/ui/sheet/Sheet.vue @@ -0,0 +1,19 @@ +<script setup lang="ts"> +import type { DialogRootEmits, DialogRootProps } from "reka-ui" +import { DialogRoot, useForwardPropsEmits } from "reka-ui" + +const props = defineProps<DialogRootProps>() +const emits = defineEmits<DialogRootEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <DialogRoot + v-slot="slotProps" + data-slot="sheet" + v-bind="forwarded" + > + <slot v-bind="slotProps" /> + </DialogRoot> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetClose.vue b/src/server/ui/components/ui/sheet/SheetClose.vue new file mode 100644 index 00000000..39a942c4 --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetClose.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { DialogCloseProps } from "reka-ui" +import { DialogClose } from "reka-ui" + +const props = defineProps<DialogCloseProps>() +</script> + +<template> + <DialogClose + data-slot="sheet-close" + v-bind="props" + > + <slot /> + </DialogClose> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetContent.vue b/src/server/ui/components/ui/sheet/SheetContent.vue new file mode 100644 index 00000000..e0c4b8f6 --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetContent.vue @@ -0,0 +1,62 @@ +<script setup lang="ts"> +import type { DialogContentEmits, DialogContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { X } from "lucide-vue-next" +import { + DialogClose, + DialogContent, + DialogPortal, + useForwardPropsEmits, +} from "reka-ui" +import { cn } from "@/lib/utils" +import SheetOverlay from "./SheetOverlay.vue" + +interface SheetContentProps extends DialogContentProps { + class?: HTMLAttributes["class"] + side?: "top" | "right" | "bottom" | "left" +} + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults(defineProps<SheetContentProps>(), { + side: "right", +}) +const emits = defineEmits<DialogContentEmits>() + +const delegatedProps = reactiveOmit(props, "class", "side") + +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <DialogPortal> + <SheetOverlay /> + <DialogContent + data-slot="sheet-content" + :class="cn( + 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + side === 'right' + && 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', + side === 'left' + && 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', + side === 'top' + && 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', + side === 'bottom' + && 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', + props.class)" + v-bind="{ ...$attrs, ...forwarded }" + > + <slot /> + + <DialogClose + class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" + > + <X class="size-4" /> + <span class="sr-only">Close</span> + </DialogClose> + </DialogContent> + </DialogPortal> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetDescription.vue b/src/server/ui/components/ui/sheet/SheetDescription.vue new file mode 100644 index 00000000..6c8ba0a9 --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetDescription.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { DialogDescriptionProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogDescription } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <DialogDescription + data-slot="sheet-description" + :class="cn('text-muted-foreground text-sm', props.class)" + v-bind="delegatedProps" + > + <slot /> + </DialogDescription> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetFooter.vue b/src/server/ui/components/ui/sheet/SheetFooter.vue new file mode 100644 index 00000000..5fcf751d --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetFooter.vue @@ -0,0 +1,16 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ class?: HTMLAttributes["class"] }>() +</script> + +<template> + <div + data-slot="sheet-footer" + :class="cn('mt-auto flex flex-col gap-2 p-4', props.class) + " + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetHeader.vue b/src/server/ui/components/ui/sheet/SheetHeader.vue new file mode 100644 index 00000000..b6305ab6 --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetHeader.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ class?: HTMLAttributes["class"] }>() +</script> + +<template> + <div + data-slot="sheet-header" + :class="cn('flex flex-col gap-1.5 p-4', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetOverlay.vue b/src/server/ui/components/ui/sheet/SheetOverlay.vue new file mode 100644 index 00000000..220452a7 --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetOverlay.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { DialogOverlayProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogOverlay } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <DialogOverlay + data-slot="sheet-overlay" + :class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)" + v-bind="delegatedProps" + > + <slot /> + </DialogOverlay> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetTitle.vue b/src/server/ui/components/ui/sheet/SheetTitle.vue new file mode 100644 index 00000000..889ae54a --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetTitle.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { DialogTitleProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { DialogTitle } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <DialogTitle + data-slot="sheet-title" + :class="cn('text-foreground font-semibold', props.class)" + v-bind="delegatedProps" + > + <slot /> + </DialogTitle> +</template> diff --git a/src/server/ui/components/ui/sheet/SheetTrigger.vue b/src/server/ui/components/ui/sheet/SheetTrigger.vue new file mode 100644 index 00000000..41b121db --- /dev/null +++ b/src/server/ui/components/ui/sheet/SheetTrigger.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { DialogTriggerProps } from "reka-ui" +import { DialogTrigger } from "reka-ui" + +const props = defineProps<DialogTriggerProps>() +</script> + +<template> + <DialogTrigger + data-slot="sheet-trigger" + v-bind="props" + > + <slot /> + </DialogTrigger> +</template> diff --git a/src/server/ui/components/ui/sheet/index.ts b/src/server/ui/components/ui/sheet/index.ts new file mode 100644 index 00000000..7c70e5d7 --- /dev/null +++ b/src/server/ui/components/ui/sheet/index.ts @@ -0,0 +1,8 @@ +export { default as Sheet } from "./Sheet.vue" +export { default as SheetClose } from "./SheetClose.vue" +export { default as SheetContent } from "./SheetContent.vue" +export { default as SheetDescription } from "./SheetDescription.vue" +export { default as SheetFooter } from "./SheetFooter.vue" +export { default as SheetHeader } from "./SheetHeader.vue" +export { default as SheetTitle } from "./SheetTitle.vue" +export { default as SheetTrigger } from "./SheetTrigger.vue" diff --git a/src/server/ui/components/ui/sidebar/Sidebar.vue b/src/server/ui/components/ui/sidebar/Sidebar.vue new file mode 100644 index 00000000..05ab23d4 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/Sidebar.vue @@ -0,0 +1,96 @@ +<script setup lang="ts"> +import type { SidebarProps } from "." +import { cn } from "@/lib/utils" +import { Sheet, SheetContent } from '@/components/ui/sheet' +import SheetDescription from '@/components/ui/sheet/SheetDescription.vue' +import SheetHeader from '@/components/ui/sheet/SheetHeader.vue' +import SheetTitle from '@/components/ui/sheet/SheetTitle.vue' +import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils" + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults(defineProps<SidebarProps>(), { + side: "left", + variant: "sidebar", + collapsible: "offcanvas", +}) + +const { isMobile, state, openMobile, setOpenMobile } = useSidebar() +</script> + +<template> + <div + v-if="collapsible === 'none'" + data-slot="sidebar" + :class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)" + v-bind="$attrs" + > + <slot /> + </div> + + <Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile"> + <SheetContent + data-sidebar="sidebar" + data-slot="sidebar" + data-mobile="true" + :side="side" + class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" + :style="{ + '--sidebar-width': SIDEBAR_WIDTH_MOBILE, + }" + > + <SheetHeader class="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> + <div class="flex h-full w-full flex-col"> + <slot /> + </div> + </SheetContent> + </Sheet> + + <div + v-else + class="group peer text-sidebar-foreground hidden md:block" + data-slot="sidebar" + :data-state="state" + :data-collapsible="state === 'collapsed' ? collapsible : ''" + :data-variant="variant" + :data-side="side" + > + <!-- This is what handles the sidebar gap on desktop --> + <div + :class="cn( + 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-in-out', + 'group-data-[collapsible=offcanvas]:w-0', + 'group-data-[side=right]:rotate-180', + variant === 'floating' || variant === 'inset' + ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', + )" + /> + <div + :class="cn( + 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-in-out md:flex', + side === 'left' + ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' + : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', + // Adjust the padding for floating and inset variants. + variant === 'floating' || variant === 'inset' + ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', + props.class, + )" + v-bind="$attrs" + > + <div + data-sidebar="sidebar" + class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" + > + <slot /> + </div> + </div> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarContent.vue b/src/server/ui/components/ui/sidebar/SidebarContent.vue new file mode 100644 index 00000000..bb6d9f1d --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarContent.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-content" + data-sidebar="content" + :class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarFooter.vue b/src/server/ui/components/ui/sidebar/SidebarFooter.vue new file mode 100644 index 00000000..20dd72f4 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarFooter.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-footer" + data-sidebar="footer" + :class="cn('flex flex-col gap-2 p-2', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarGroup.vue b/src/server/ui/components/ui/sidebar/SidebarGroup.vue new file mode 100644 index 00000000..fbb48873 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarGroup.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-group" + data-sidebar="group" + :class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarGroupAction.vue b/src/server/ui/components/ui/sidebar/SidebarGroupAction.vue new file mode 100644 index 00000000..f5fa5eb0 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarGroupAction.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<PrimitiveProps & { + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <Primitive + data-slot="sidebar-group-action" + data-sidebar="group-action" + :as="as" + :as-child="asChild" + :class="cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'after:absolute after:-inset-2 md:after:hidden', + 'group-data-[collapsible=icon]:hidden', + props.class, + )" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarGroupContent.vue b/src/server/ui/components/ui/sidebar/SidebarGroupContent.vue new file mode 100644 index 00000000..06e1a92f --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarGroupContent.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-group-content" + data-sidebar="group-content" + :class="cn('w-full text-sm', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarGroupLabel.vue b/src/server/ui/components/ui/sidebar/SidebarGroupLabel.vue new file mode 100644 index 00000000..f5da1300 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarGroupLabel.vue @@ -0,0 +1,25 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<PrimitiveProps & { + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <Primitive + data-slot="sidebar-group-label" + data-sidebar="group-label" + :as="as" + :as-child="asChild" + :class="cn( + 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', + props.class)" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarHeader.vue b/src/server/ui/components/ui/sidebar/SidebarHeader.vue new file mode 100644 index 00000000..79cf8833 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarHeader.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-header" + data-sidebar="header" + :class="cn('flex flex-col gap-2 p-2', props.class)" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarInput.vue b/src/server/ui/components/ui/sidebar/SidebarInput.vue new file mode 100644 index 00000000..7d06cc40 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarInput.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" +import { Input } from '@/components/ui/input' + +const props = defineProps<{ + class?: HTMLAttributes["class"] + modelValue?: string | number + placeholder?: string +}>() + +defineEmits<{ + (e: "update:modelValue", payload: string | number): void +}>() +</script> + +<template> + <Input + data-slot="sidebar-input" + data-sidebar="input" + :model-value="modelValue" + :placeholder="placeholder" + :class="cn( + 'bg-background h-8 w-full shadow-none', + props.class, + )" + @update:model-value="$emit('update:modelValue', $event)" + > + <slot /> + </Input> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarInset.vue b/src/server/ui/components/ui/sidebar/SidebarInset.vue new file mode 100644 index 00000000..5d480b92 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarInset.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <main + data-slot="sidebar-inset" + :class="cn( + 'bg-background relative flex w-full flex-1 flex-col overflow-hidden', + 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', + props.class, + )" + > + <slot /> + </main> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenu.vue b/src/server/ui/components/ui/sidebar/SidebarMenu.vue new file mode 100644 index 00000000..e1dd39eb --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenu.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <ul + data-slot="sidebar-menu" + data-sidebar="menu" + :class="cn('flex w-full min-w-0 flex-col gap-1', props.class)" + > + <slot /> + </ul> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuAction.vue b/src/server/ui/components/ui/sidebar/SidebarMenuAction.vue new file mode 100644 index 00000000..bdffdb7d --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuAction.vue @@ -0,0 +1,35 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = withDefaults(defineProps<PrimitiveProps & { + showOnHover?: boolean + class?: HTMLAttributes["class"] +}>(), { + as: "button", +}) +</script> + +<template> + <Primitive + data-slot="sidebar-menu-action" + data-sidebar="menu-action" + :class="cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'after:absolute after:-inset-2 md:after:hidden', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + showOnHover + && 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', + props.class, + )" + :as="as" + :as-child="asChild" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuBadge.vue b/src/server/ui/components/ui/sidebar/SidebarMenuBadge.vue new file mode 100644 index 00000000..43216184 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuBadge.vue @@ -0,0 +1,26 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <div + data-slot="sidebar-menu-badge" + data-sidebar="menu-badge" + :class="cn( + 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none', + 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + props.class, + )" + > + <slot /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuButton.vue b/src/server/ui/components/ui/sidebar/SidebarMenuButton.vue new file mode 100644 index 00000000..502d3959 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuButton.vue @@ -0,0 +1,48 @@ +<script setup lang="ts"> +import type { Component } from "vue" +import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue" +import { reactiveOmit } from "@vueuse/core" +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue" +import { useSidebar } from "./utils" + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults(defineProps<SidebarMenuButtonProps & { + tooltip?: string | Component +}>(), { + as: "button", + variant: "default", + size: "default", +}) + +const { isMobile, state } = useSidebar() + +const delegatedProps = reactiveOmit(props, "tooltip") +</script> + +<template> + <SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }"> + <slot /> + </SidebarMenuButtonChild> + + <Tooltip v-else> + <TooltipTrigger as-child> + <SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }"> + <slot /> + </SidebarMenuButtonChild> + </TooltipTrigger> + <TooltipContent + side="right" + align="center" + :hidden="state !== 'collapsed' || isMobile" + > + <template v-if="typeof tooltip === 'string'"> + {{ tooltip }} + </template> + <component :is="tooltip" v-else /> + </TooltipContent> + </Tooltip> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuButtonChild.vue b/src/server/ui/components/ui/sidebar/SidebarMenuButtonChild.vue new file mode 100644 index 00000000..4b4ca3ba --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuButtonChild.vue @@ -0,0 +1,36 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import type { SidebarMenuButtonVariants } from "." +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" +import { sidebarMenuButtonVariants } from "." + +export interface SidebarMenuButtonProps extends PrimitiveProps { + variant?: SidebarMenuButtonVariants["variant"] + size?: SidebarMenuButtonVariants["size"] + isActive?: boolean + class?: HTMLAttributes["class"] +} + +const props = withDefaults(defineProps<SidebarMenuButtonProps>(), { + as: "button", + variant: "default", + size: "default", +}) +</script> + +<template> + <Primitive + data-slot="sidebar-menu-button" + data-sidebar="menu-button" + :data-size="size" + :data-active="isActive" + :class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)" + :as="as" + :as-child="asChild" + v-bind="$attrs" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuItem.vue b/src/server/ui/components/ui/sidebar/SidebarMenuItem.vue new file mode 100644 index 00000000..e2fda5b4 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuItem.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <li + data-slot="sidebar-menu-item" + data-sidebar="menu-item" + :class="cn('group/menu-item relative', props.class)" + > + <slot /> + </li> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuSkeleton.vue b/src/server/ui/components/ui/sidebar/SidebarMenuSkeleton.vue new file mode 100644 index 00000000..b33812c3 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuSkeleton.vue @@ -0,0 +1,35 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { computed } from "vue" +import { cn } from "@/lib/utils" +import { Skeleton } from '@/components/ui/skeleton' + +const props = defineProps<{ + showIcon?: boolean + class?: HTMLAttributes["class"] +}>() + +const width = computed(() => { + return `${Math.floor(Math.random() * 40) + 50}%` +}) +</script> + +<template> + <div + data-slot="sidebar-menu-skeleton" + data-sidebar="menu-skeleton" + :class="cn('flex h-8 items-center gap-2 rounded-md px-2', props.class)" + > + <Skeleton + v-if="showIcon" + class="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + + <Skeleton + class="h-4 max-w-(--skeleton-width) flex-1" + data-sidebar="menu-skeleton-text" + :style="{ '--skeleton-width': width }" + /> + </div> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuSub.vue b/src/server/ui/components/ui/sidebar/SidebarMenuSub.vue new file mode 100644 index 00000000..81c2dab9 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuSub.vue @@ -0,0 +1,22 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <ul + data-slot="sidebar-menu-sub" + data-sidebar="menu-badge" + :class="cn( + 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', + 'group-data-[collapsible=icon]:hidden', + props.class, + )" + > + <slot /> + </ul> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuSubButton.vue b/src/server/ui/components/ui/sidebar/SidebarMenuSubButton.vue new file mode 100644 index 00000000..457cfb63 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuSubButton.vue @@ -0,0 +1,36 @@ +<script setup lang="ts"> +import type { PrimitiveProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { Primitive } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = withDefaults(defineProps<PrimitiveProps & { + size?: "sm" | "md" + isActive?: boolean + class?: HTMLAttributes["class"] +}>(), { + as: "a", + size: "md", +}) +</script> + +<template> + <Primitive + data-slot="sidebar-menu-sub-button" + data-sidebar="menu-sub-button" + :as="as" + :as-child="asChild" + :data-size="size" + :data-active="isActive" + :class="cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', + size === 'sm' && 'text-xs', + size === 'md' && 'text-sm', + 'group-data-[collapsible=icon]:hidden', + props.class, + )" + > + <slot /> + </Primitive> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarMenuSubItem.vue b/src/server/ui/components/ui/sidebar/SidebarMenuSubItem.vue new file mode 100644 index 00000000..d4374f65 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarMenuSubItem.vue @@ -0,0 +1,18 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <li + data-slot="sidebar-menu-sub-item" + data-sidebar="menu-sub-item" + :class="cn('group/menu-sub-item relative', props.class)" + > + <slot /> + </li> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarProvider.vue b/src/server/ui/components/ui/sidebar/SidebarProvider.vue new file mode 100644 index 00000000..04ed975f --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarProvider.vue @@ -0,0 +1,82 @@ +<script setup lang="ts"> +import type { HTMLAttributes, Ref } from "vue" +import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core" +import { TooltipProvider } from "reka-ui" +import { computed, ref } from "vue" +import { cn } from "@/lib/utils" +import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils" + +const props = withDefaults(defineProps<{ + defaultOpen?: boolean + open?: boolean + class?: HTMLAttributes["class"] +}>(), { + defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`), + open: undefined, +}) + +const emits = defineEmits<{ + "update:open": [open: boolean] +}>() + +const isMobile = useMediaQuery("(max-width: 768px)") +const openMobile = ref(false) + +const open = useVModel(props, "open", emits, { + defaultValue: props.defaultOpen ?? false, + passive: (props.open === undefined) as false, +}) as Ref<boolean> + +function setOpen(value: boolean) { + open.value = value // emits('update:open', value) + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` +} + +function setOpenMobile(value: boolean) { + openMobile.value = value +} + +// Helper to toggle the sidebar. +function toggleSidebar() { + return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value) +} + +useEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } +}) + +// We add a state so that we can do data-state="expanded" or "collapsed". +// This makes it easier to style the sidebar with Tailwind classes. +const state = computed(() => open.value ? "expanded" : "collapsed") + +provideSidebarContext({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, +}) +</script> + +<template> + <TooltipProvider :delay-duration="0"> + <div + data-slot="sidebar-wrapper" + :style="{ + '--sidebar-width': SIDEBAR_WIDTH, + '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, + }" + :class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" + v-bind="$attrs" + > + <slot /> + </div> + </TooltipProvider> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarRail.vue b/src/server/ui/components/ui/sidebar/SidebarRail.vue new file mode 100644 index 00000000..e0994362 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarRail.vue @@ -0,0 +1,33 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" +import { useSidebar } from "./utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() + +const { toggleSidebar } = useSidebar() +</script> + +<template> + <button + data-sidebar="rail" + data-slot="sidebar-rail" + aria-label="Toggle Sidebar" + :tabindex="-1" + + :class="cn( + 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', + 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', + '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', + 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', + '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', + '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', + props.class, + )" + @click="toggleSidebar" + > + <slot /> + </button> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarSeparator.vue b/src/server/ui/components/ui/sidebar/SidebarSeparator.vue new file mode 100644 index 00000000..91170bc4 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarSeparator.vue @@ -0,0 +1,19 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" +import { Separator } from '@/components/ui/separator' + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() +</script> + +<template> + <Separator + data-slot="sidebar-separator" + data-sidebar="separator" + :class="cn('bg-sidebar-border mx-2 w-auto', props.class)" + > + <slot /> + </Separator> +</template> diff --git a/src/server/ui/components/ui/sidebar/SidebarTrigger.vue b/src/server/ui/components/ui/sidebar/SidebarTrigger.vue new file mode 100644 index 00000000..ba9324a0 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/SidebarTrigger.vue @@ -0,0 +1,28 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { PanelRightClose, PanelRightOpen } from "lucide-vue-next" +import { cn } from "@/lib/utils" +import { Button } from '@/components/ui/button' +import { useSidebar } from "./utils" + +const props = defineProps<{ + class?: HTMLAttributes["class"] +}>() + +const { open, toggleSidebar } = useSidebar() +</script> + +<template> + <Button + data-sidebar="trigger" + data-slot="sidebar-trigger" + variant="ghost" + size="icon" + :class="cn('h-7 w-7', props.class)" + @click="toggleSidebar" + > + <PanelRightOpen v-if="open" /> + <PanelRightClose v-else /> + <span class="sr-only">Toggle Sidebar</span> + </Button> +</template> diff --git a/src/server/ui/components/ui/sidebar/index.ts b/src/server/ui/components/ui/sidebar/index.ts new file mode 100644 index 00000000..ef718a9c --- /dev/null +++ b/src/server/ui/components/ui/sidebar/index.ts @@ -0,0 +1,60 @@ +import type { VariantProps } from "class-variance-authority" +import type { HTMLAttributes } from "vue" +import { cva } from "class-variance-authority" + +export interface SidebarProps { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + class?: HTMLAttributes["class"] +} + +export { default as Sidebar } from "./Sidebar.vue" +export { default as SidebarContent } from "./SidebarContent.vue" +export { default as SidebarFooter } from "./SidebarFooter.vue" +export { default as SidebarGroup } from "./SidebarGroup.vue" +export { default as SidebarGroupAction } from "./SidebarGroupAction.vue" +export { default as SidebarGroupContent } from "./SidebarGroupContent.vue" +export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue" +export { default as SidebarHeader } from "./SidebarHeader.vue" +export { default as SidebarInput } from "./SidebarInput.vue" +export { default as SidebarInset } from "./SidebarInset.vue" +export { default as SidebarMenu } from "./SidebarMenu.vue" +export { default as SidebarMenuAction } from "./SidebarMenuAction.vue" +export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue" +export { default as SidebarMenuButton } from "./SidebarMenuButton.vue" +export { default as SidebarMenuItem } from "./SidebarMenuItem.vue" +export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue" +export { default as SidebarMenuSub } from "./SidebarMenuSub.vue" +export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue" +export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue" +export { default as SidebarProvider } from "./SidebarProvider.vue" +export { default as SidebarRail } from "./SidebarRail.vue" +export { default as SidebarSeparator } from "./SidebarSeparator.vue" +export { default as SidebarTrigger } from "./SidebarTrigger.vue" + +export { useSidebar } from "./utils" + +export const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants> diff --git a/src/server/ui/components/ui/sidebar/utils.ts b/src/server/ui/components/ui/sidebar/utils.ts new file mode 100644 index 00000000..6e57eeb9 --- /dev/null +++ b/src/server/ui/components/ui/sidebar/utils.ts @@ -0,0 +1,19 @@ +import type { ComputedRef, Ref } from "vue" +import { createContext } from "reka-ui" + +export const SIDEBAR_COOKIE_NAME = "sidebar_state" +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +export const SIDEBAR_WIDTH = "16rem" +export const SIDEBAR_WIDTH_MOBILE = "18rem" +export const SIDEBAR_WIDTH_ICON = "3rem" +export const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +export const [useSidebar, provideSidebarContext] = createContext<{ + state: ComputedRef<"expanded" | "collapsed"> + open: Ref<boolean> + setOpen: (value: boolean) => void + isMobile: Ref<boolean> + openMobile: Ref<boolean> + setOpenMobile: (value: boolean) => void + toggleSidebar: () => void +}>("Sidebar") diff --git a/src/server/ui/components/ui/skeleton/Skeleton.vue b/src/server/ui/components/ui/skeleton/Skeleton.vue new file mode 100644 index 00000000..0dadcef9 --- /dev/null +++ b/src/server/ui/components/ui/skeleton/Skeleton.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type { HTMLAttributes } from "vue" +import { cn } from "@/lib/utils" + +interface SkeletonProps { + class?: HTMLAttributes["class"] +} + +const props = defineProps<SkeletonProps>() +</script> + +<template> + <div + data-slot="skeleton" + :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" + /> +</template> diff --git a/src/server/ui/components/ui/skeleton/index.ts b/src/server/ui/components/ui/skeleton/index.ts new file mode 100644 index 00000000..e5ce72c3 --- /dev/null +++ b/src/server/ui/components/ui/skeleton/index.ts @@ -0,0 +1 @@ +export { default as Skeleton } from "./Skeleton.vue" diff --git a/src/server/ui/components/ui/tabs/Tabs.vue b/src/server/ui/components/ui/tabs/Tabs.vue new file mode 100644 index 00000000..d260a15c --- /dev/null +++ b/src/server/ui/components/ui/tabs/Tabs.vue @@ -0,0 +1,24 @@ +<script setup lang="ts"> +import type { TabsRootEmits, TabsRootProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { TabsRoot, useForwardPropsEmits } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<TabsRootProps & { class?: HTMLAttributes["class"] }>() +const emits = defineEmits<TabsRootEmits>() + +const delegatedProps = reactiveOmit(props, "class") +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <TabsRoot + v-slot="slotProps" + data-slot="tabs" + v-bind="forwarded" + :class="cn('flex flex-col gap-2', props.class)" + > + <slot v-bind="slotProps" /> + </TabsRoot> +</template> diff --git a/src/server/ui/components/ui/tabs/TabsContent.vue b/src/server/ui/components/ui/tabs/TabsContent.vue new file mode 100644 index 00000000..3186ee88 --- /dev/null +++ b/src/server/ui/components/ui/tabs/TabsContent.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +import type { TabsContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { TabsContent } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<TabsContentProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <TabsContent + data-slot="tabs-content" + :class="cn('flex-1 outline-none', props.class)" + v-bind="delegatedProps" + > + <slot /> + </TabsContent> +</template> diff --git a/src/server/ui/components/ui/tabs/TabsList.vue b/src/server/ui/components/ui/tabs/TabsList.vue new file mode 100644 index 00000000..a64a2dad --- /dev/null +++ b/src/server/ui/components/ui/tabs/TabsList.vue @@ -0,0 +1,24 @@ +<script setup lang="ts"> +import type { TabsListProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { TabsList } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<TabsListProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") +</script> + +<template> + <TabsList + data-slot="tabs-list" + v-bind="delegatedProps" + :class="cn( + 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', + props.class, + )" + > + <slot /> + </TabsList> +</template> diff --git a/src/server/ui/components/ui/tabs/TabsTrigger.vue b/src/server/ui/components/ui/tabs/TabsTrigger.vue new file mode 100644 index 00000000..45e424f7 --- /dev/null +++ b/src/server/ui/components/ui/tabs/TabsTrigger.vue @@ -0,0 +1,26 @@ +<script setup lang="ts"> +import type { TabsTriggerProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { TabsTrigger, useForwardProps } from "reka-ui" +import { cn } from "@/lib/utils" + +const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes["class"] }>() + +const delegatedProps = reactiveOmit(props, "class") + +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <TabsTrigger + data-slot="tabs-trigger" + :class="cn( + 'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', + props.class, + )" + v-bind="forwardedProps" + > + <slot /> + </TabsTrigger> +</template> diff --git a/src/server/ui/components/ui/tabs/index.ts b/src/server/ui/components/ui/tabs/index.ts new file mode 100644 index 00000000..7f99b7f2 --- /dev/null +++ b/src/server/ui/components/ui/tabs/index.ts @@ -0,0 +1,4 @@ +export { default as Tabs } from "./Tabs.vue" +export { default as TabsContent } from "./TabsContent.vue" +export { default as TabsList } from "./TabsList.vue" +export { default as TabsTrigger } from "./TabsTrigger.vue" diff --git a/src/server/ui/components/ui/toggle-group/ToggleGroup.vue b/src/server/ui/components/ui/toggle-group/ToggleGroup.vue new file mode 100644 index 00000000..876051fd --- /dev/null +++ b/src/server/ui/components/ui/toggle-group/ToggleGroup.vue @@ -0,0 +1,49 @@ +<script setup lang="ts"> +import type { VariantProps } from "class-variance-authority" +import type { ToggleGroupRootEmits, ToggleGroupRootProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import type { toggleVariants } from '@/components/ui/toggle' +import { reactiveOmit } from "@vueuse/core" +import { ToggleGroupRoot, useForwardPropsEmits } from "reka-ui" +import { provide } from "vue" +import { cn } from "@/lib/utils" + +type ToggleGroupVariants = VariantProps<typeof toggleVariants> + +const props = withDefaults(defineProps<ToggleGroupRootProps & { + class?: HTMLAttributes["class"] + variant?: ToggleGroupVariants["variant"] + size?: ToggleGroupVariants["size"] + spacing?: number +}>(), { + spacing: 0, +}) + +const emits = defineEmits<ToggleGroupRootEmits>() + +provide("toggleGroup", { + variant: props.variant, + size: props.size, + spacing: props.spacing, +}) + +const delegatedProps = reactiveOmit(props, "class", "size", "variant") +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <ToggleGroupRoot + v-slot="slotProps" + data-slot="toggle-group" + :data-size="size" + :data-variant="variant" + :data-spacing="spacing" + :style="{ + '--gap': spacing, + }" + v-bind="forwarded" + :class="cn('group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs', props.class)" + > + <slot v-bind="slotProps" /> + </ToggleGroupRoot> +</template> diff --git a/src/server/ui/components/ui/toggle-group/ToggleGroupItem.vue b/src/server/ui/components/ui/toggle-group/ToggleGroupItem.vue new file mode 100644 index 00000000..c042abf8 --- /dev/null +++ b/src/server/ui/components/ui/toggle-group/ToggleGroupItem.vue @@ -0,0 +1,46 @@ +<script setup lang="ts"> +import type { VariantProps } from "class-variance-authority" +import type { ToggleGroupItemProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { ToggleGroupItem, useForwardProps } from "reka-ui" +import { inject } from "vue" +import { cn } from "@/lib/utils" +import { toggleVariants } from '@/components/ui/toggle' + +type ToggleGroupVariants = VariantProps<typeof toggleVariants> & { + spacing?: number +} + +const props = defineProps<ToggleGroupItemProps & { + class?: HTMLAttributes["class"] + variant?: ToggleGroupVariants["variant"] + size?: ToggleGroupVariants["size"] +}>() + +const context = inject<ToggleGroupVariants>("toggleGroup") + +const delegatedProps = reactiveOmit(props, "class", "size", "variant") +const forwardedProps = useForwardProps(delegatedProps) +</script> + +<template> + <ToggleGroupItem + v-slot="slotProps" + data-slot="toggle-group-item" + :data-variant="context?.variant || variant" + :data-size="context?.size || size" + :data-spacing="context?.spacing" + v-bind="forwardedProps" + :class="cn( + toggleVariants({ + variant: context?.variant || variant, + size: context?.size || size, + }), + 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', + 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', + props.class)" + > + <slot v-bind="slotProps" /> + </ToggleGroupItem> +</template> diff --git a/src/server/ui/components/ui/toggle-group/index.ts b/src/server/ui/components/ui/toggle-group/index.ts new file mode 100644 index 00000000..c6ce2fc9 --- /dev/null +++ b/src/server/ui/components/ui/toggle-group/index.ts @@ -0,0 +1,2 @@ +export { default as ToggleGroup } from "./ToggleGroup.vue" +export { default as ToggleGroupItem } from "./ToggleGroupItem.vue" diff --git a/src/server/ui/components/ui/toggle/Toggle.vue b/src/server/ui/components/ui/toggle/Toggle.vue new file mode 100644 index 00000000..aea8e17d --- /dev/null +++ b/src/server/ui/components/ui/toggle/Toggle.vue @@ -0,0 +1,35 @@ +<script setup lang="ts"> +import type { ToggleEmits, ToggleProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import type { ToggleVariants } from "." +import { reactiveOmit } from "@vueuse/core" +import { Toggle, useForwardPropsEmits } from "reka-ui" +import { cn } from "@/lib/utils" +import { toggleVariants } from "." + +const props = withDefaults(defineProps<ToggleProps & { + class?: HTMLAttributes["class"] + variant?: ToggleVariants["variant"] + size?: ToggleVariants["size"] +}>(), { + variant: "default", + size: "default", + disabled: false, +}) + +const emits = defineEmits<ToggleEmits>() + +const delegatedProps = reactiveOmit(props, "class", "size", "variant") +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <Toggle + v-slot="slotProps" + data-slot="toggle" + v-bind="forwarded" + :class="cn(toggleVariants({ variant, size }), props.class)" + > + <slot v-bind="slotProps" /> + </Toggle> +</template> diff --git a/src/server/ui/components/ui/toggle/index.ts b/src/server/ui/components/ui/toggle/index.ts new file mode 100644 index 00000000..d873390e --- /dev/null +++ b/src/server/ui/components/ui/toggle/index.ts @@ -0,0 +1,28 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Toggle } from "./Toggle.vue" + +export const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export type ToggleVariants = VariantProps<typeof toggleVariants> diff --git a/src/server/ui/components/ui/tooltip/Tooltip.vue b/src/server/ui/components/ui/tooltip/Tooltip.vue new file mode 100644 index 00000000..2a393d6d --- /dev/null +++ b/src/server/ui/components/ui/tooltip/Tooltip.vue @@ -0,0 +1,19 @@ +<script setup lang="ts"> +import type { TooltipRootEmits, TooltipRootProps } from "reka-ui" +import { TooltipRoot, useForwardPropsEmits } from "reka-ui" + +const props = defineProps<TooltipRootProps>() +const emits = defineEmits<TooltipRootEmits>() + +const forwarded = useForwardPropsEmits(props, emits) +</script> + +<template> + <TooltipRoot + v-slot="slotProps" + data-slot="tooltip" + v-bind="forwarded" + > + <slot v-bind="slotProps" /> + </TooltipRoot> +</template> diff --git a/src/server/ui/components/ui/tooltip/TooltipContent.vue b/src/server/ui/components/ui/tooltip/TooltipContent.vue new file mode 100644 index 00000000..c5d2df9c --- /dev/null +++ b/src/server/ui/components/ui/tooltip/TooltipContent.vue @@ -0,0 +1,34 @@ +<script setup lang="ts"> +import type { TooltipContentEmits, TooltipContentProps } from "reka-ui" +import type { HTMLAttributes } from "vue" +import { reactiveOmit } from "@vueuse/core" +import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui" +import { cn } from "@/lib/utils" + +defineOptions({ + inheritAttrs: false, +}) + +const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), { + sideOffset: 4, +}) + +const emits = defineEmits<TooltipContentEmits>() + +const delegatedProps = reactiveOmit(props, "class") +const forwarded = useForwardPropsEmits(delegatedProps, emits) +</script> + +<template> + <TooltipPortal> + <TooltipContent + data-slot="tooltip-content" + v-bind="{ ...forwarded, ...$attrs }" + :class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)" + > + <slot /> + + <TooltipArrow class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> + </TooltipContent> + </TooltipPortal> +</template> diff --git a/src/server/ui/components/ui/tooltip/TooltipProvider.vue b/src/server/ui/components/ui/tooltip/TooltipProvider.vue new file mode 100644 index 00000000..395927d5 --- /dev/null +++ b/src/server/ui/components/ui/tooltip/TooltipProvider.vue @@ -0,0 +1,14 @@ +<script setup lang="ts"> +import type { TooltipProviderProps } from "reka-ui" +import { TooltipProvider } from "reka-ui" + +const props = withDefaults(defineProps<TooltipProviderProps>(), { + delayDuration: 0, +}) +</script> + +<template> + <TooltipProvider v-bind="props"> + <slot /> + </TooltipProvider> +</template> diff --git a/src/server/ui/components/ui/tooltip/TooltipTrigger.vue b/src/server/ui/components/ui/tooltip/TooltipTrigger.vue new file mode 100644 index 00000000..3332950e --- /dev/null +++ b/src/server/ui/components/ui/tooltip/TooltipTrigger.vue @@ -0,0 +1,15 @@ +<script setup lang="ts"> +import type { TooltipTriggerProps } from "reka-ui" +import { TooltipTrigger } from "reka-ui" + +const props = defineProps<TooltipTriggerProps>() +</script> + +<template> + <TooltipTrigger + data-slot="tooltip-trigger" + v-bind="props" + > + <slot /> + </TooltipTrigger> +</template> diff --git a/src/server/ui/components/ui/tooltip/index.ts b/src/server/ui/components/ui/tooltip/index.ts new file mode 100644 index 00000000..8f8d514d --- /dev/null +++ b/src/server/ui/components/ui/tooltip/index.ts @@ -0,0 +1,4 @@ +export { default as Tooltip } from "./Tooltip.vue" +export { default as TooltipContent } from "./TooltipContent.vue" +export { default as TooltipProvider } from "./TooltipProvider.vue" +export { default as TooltipTrigger } from "./TooltipTrigger.vue" diff --git a/src/server/ui/favicon.svg b/src/server/ui/favicon.svg new file mode 100644 index 00000000..472b6565 --- /dev/null +++ b/src/server/ui/favicon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="34" height="25" fill="none" viewBox="0 0 34 25"><path fill="url(#a)" d="M32.294 4.214a3 3 0 0 0-6 0V20.21a3 3 0 0 0 6 0z"/><path fill="#4338ca" d="M19.746 4.214a3 3 0 0 0-5.999 0V20.21a3 3 0 0 0 5.999 0z"/><path fill="url(#b)" d="M31.658 6.059a3 3 0 1 0-4.725-3.697L14.408 18.371a3 3 0 0 0 4.724 3.696z"/><path fill="url(#c)" d="M19.098 6.06a3 3 0 0 0-4.725-3.697L1.848 18.37a3 3 0 1 0 4.725 3.697z"/><defs><linearGradient id="a" x1="29.294" x2="29.294" y1="1.214" y2="23.21" gradientUnits="userSpaceOnUse"><stop stop-color="#4338ca"/><stop offset="1" stop-color="#6a5ff3"/></linearGradient><linearGradient id="b" x1="31.143" x2="14.922" y1="1.848" y2="22.581" gradientUnits="userSpaceOnUse"><stop stop-color="#8689ff"/><stop offset="1" stop-color="#6366f1"/></linearGradient><linearGradient id="c" x1="18.584" x2="2.362" y1="1.849" y2="22.582" gradientUnits="userSpaceOnUse"><stop stop-color="#8486ff"/><stop offset="1" stop-color="#6366f1"/></linearGradient></defs></svg> \ No newline at end of file diff --git a/src/server/ui/index.html b/src/server/ui/index.html new file mode 100644 index 00000000..a74979d2 --- /dev/null +++ b/src/server/ui/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="icon" type="image/svg+xml" href="./favicon.svg"> + <title>Maizzle Dev</title> +</head> +<body> + <div id="app"></div> + <script type="module" src="./main.ts"></script> +</body> +</html> diff --git a/src/server/ui/lib/utils.ts b/src/server/ui/lib/utils.ts new file mode 100644 index 00000000..abba253f --- /dev/null +++ b/src/server/ui/lib/utils.ts @@ -0,0 +1,7 @@ +import type { ClassValue } from 'clsx' +import { clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/server/ui/logo-gradient.svg b/src/server/ui/logo-gradient.svg new file mode 100644 index 00000000..8a1e4346 --- /dev/null +++ b/src/server/ui/logo-gradient.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="130" height="25" fill="none" viewBox="0 0 130 25"><path fill="#fff" d="M60.6 14.505v7.2c0 .8-.7 1.5-1.5 1.5s-1.4-.6-1.4-1.5v-7.3c0-1.6-1.3-2.9-3-2.9-1.6 0-3 1.3-3 2.9v7.3c0 .8-.6 1.5-1.4 1.5s-1.5-.6-1.5-1.5v-7.3c0-1.6-1.3-2.9-2.9-2.9s-3 1.3-3 2.9v7.3c0 .8-.6 1.5-1.4 1.5s-1.5-.6-1.5-1.5v-7.3c0-3.2 2.6-5.8 5.9-5.8 1.7 0 3.3.8 4.4 2 1.1-1.3 2.7-2 4.4-2 3.2 0 5.9 2.6 5.9 5.8zM76.9 15.905v5.7c0 .8-.6 1.5-1.5 1.5-.6 0-1.4-.6-1.4-1.5-1.3 1-2.7 1.5-4.3 1.5-4.1 0-7.4-3.1-7.4-7.2s3.3-7.4 7.4-7.4c4.1.1 7.2 3.3 7.2 7.4m-2.9 0c0-2.4-1.9-4.5-4.3-4.5s-4.5 2.1-4.5 4.5 2.1 4.3 4.5 4.3c2.5.1 4.3-1.8 4.3-4.3M79 5.205c-.3-.3-.4-.6-.4-1s.2-.8.4-1c.3-.3.6-.4 1-.4s.8.2 1 .4c.3.3.4.6.4 1s-.1.8-.4 1c-.3.3-.6.4-1 .4-.3 0-.7-.1-1-.4m-.4 16.5v-11.7c0-.8.6-1.4 1.5-1.4.8 0 1.4.6 1.4 1.4v11.7c0 .8-.6 1.5-1.4 1.5-.9 0-1.5-.7-1.5-1.5M95.2 21.705c0 .8-.6 1.5-1.4 1.5h-9.1c-.6 0-1.1-.3-1.3-.8-.3-.5-.2-1.1.1-1.5l7.3-9.4h-6.1c-.8 0-1.4-.7-1.4-1.5s.6-1.4 1.4-1.4h9.1c.6 0 1.1.3 1.3.8.3.5.2 1.1-.1 1.5l-7.3 9.4h6.2c.7 0 1.3.6 1.3 1.4M108.9 21.705c0 .8-.6 1.5-1.4 1.5h-9.1c-.6 0-1.1-.3-1.3-.8-.3-.5-.2-1.1.1-1.5l7.3-9.4h-6.2c-.8 0-1.4-.7-1.4-1.5s.6-1.4 1.4-1.4h9.1c.6 0 1.1.3 1.3.8.3.5.2 1.1-.1 1.5l-7.3 9.4h6.2c.8 0 1.4.6 1.4 1.4M110.6 21.705v-19c0-.8.6-1.4 1.5-1.4.8 0 1.4.6 1.4 1.4v19c0 .8-.6 1.5-1.4 1.5-.9 0-1.5-.7-1.5-1.5M129.9 15.805c0 .8-.6 1.4-1.5 1.4h-10.1c.6 1.7 2.2 3 4.2 3 .7 0 1.9-.1 3.3-1 .7-.4 1.6 0 1.9.7s0 1.5-.7 1.9c-1.9 1.3-3.4 1.3-4.5 1.3-4.1 0-7.3-3.3-7.3-7.3s3.3-7.3 7.3-7.3c3.6 0 7.4 2.6 7.4 7.3m-11.5-1.4h8.4c-.6-2-2.5-3-4.2-3-2 0-3.6 1.2-4.2 3"/><path stroke="url(#a)" stroke-miterlimit="10" stroke-width="1.5" d="M32.294 4.214a3 3 0 0 0-6 0V20.21a3 3 0 0 0 6 0z"/><path stroke="url(#b)" stroke-miterlimit="10" stroke-width="1.5" d="M19.746 4.214a3 3 0 0 0-5.999 0V20.21a3 3 0 0 0 5.999 0z"/><path stroke="url(#c)" stroke-miterlimit="10" stroke-width="1.5" d="M31.658 6.059a3 3 0 1 0-4.725-3.697L14.408 18.371a3 3 0 0 0 4.724 3.696z"/><path stroke="url(#d)" stroke-miterlimit="10" stroke-width="1.5" d="M19.098 6.06a3 3 0 0 0-4.725-3.697L1.848 18.37a3 3 0 1 0 4.725 3.697z"/><defs><linearGradient id="a" x1="29.294" x2="29.294" y1="1.214" y2="23.21" gradientUnits="userSpaceOnUse"><stop stop-color="#4f46e5"/><stop offset="1" stop-color="#9c96ff"/></linearGradient><linearGradient id="b" x1="16.747" x2="16.747" y1="1.214" y2="23.21" gradientUnits="userSpaceOnUse"><stop stop-color="#4f46e5"/><stop offset="1" stop-color="#9c96ff"/></linearGradient><linearGradient id="c" x1="31.143" x2="14.922" y1="1.848" y2="22.581" gradientUnits="userSpaceOnUse"><stop stop-color="#4f46e5"/><stop offset="1" stop-color="#9c96ff"/></linearGradient><linearGradient id="d" x1="18.584" x2="2.362" y1="1.849" y2="22.582" gradientUnits="userSpaceOnUse"><stop stop-color="#4f46e5"/><stop offset="1" stop-color="#9c96ff"/></linearGradient></defs></svg> \ No newline at end of file diff --git a/src/server/ui/logo.svg b/src/server/ui/logo.svg new file mode 100644 index 00000000..5128ebe3 --- /dev/null +++ b/src/server/ui/logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="130" height="25" fill="none" viewBox="0 0 130 25"><path fill="#1e293b" d="M60.6 14.505v7.2c0 .8-.7 1.5-1.5 1.5s-1.4-.6-1.4-1.5v-7.3c0-1.6-1.3-2.9-3-2.9-1.6 0-3 1.3-3 2.9v7.3c0 .8-.6 1.5-1.4 1.5s-1.5-.6-1.5-1.5v-7.3c0-1.6-1.3-2.9-2.9-2.9s-3 1.3-3 2.9v7.3c0 .8-.6 1.5-1.4 1.5s-1.5-.6-1.5-1.5v-7.3c0-3.2 2.6-5.8 5.9-5.8 1.7 0 3.3.8 4.4 2 1.1-1.3 2.7-2 4.4-2 3.2 0 5.9 2.6 5.9 5.8zM76.9 15.905v5.7c0 .8-.6 1.5-1.5 1.5-.6 0-1.4-.6-1.4-1.5-1.3 1-2.7 1.5-4.3 1.5-4.1 0-7.4-3.1-7.4-7.2s3.3-7.4 7.4-7.4c4.1.1 7.2 3.3 7.2 7.4m-2.9 0c0-2.4-1.9-4.5-4.3-4.5s-4.5 2.1-4.5 4.5 2.1 4.3 4.5 4.3c2.5.1 4.3-1.8 4.3-4.3M79 5.205c-.3-.3-.4-.6-.4-1s.2-.8.4-1c.3-.3.6-.4 1-.4s.8.2 1 .4c.3.3.4.6.4 1s-.1.8-.4 1c-.3.3-.6.4-1 .4-.3 0-.7-.1-1-.4m-.4 16.5v-11.7c0-.8.6-1.4 1.5-1.4.8 0 1.4.6 1.4 1.4v11.7c0 .8-.6 1.5-1.4 1.5-.9 0-1.5-.7-1.5-1.5M95.2 21.705c0 .8-.6 1.5-1.4 1.5h-9.1c-.6 0-1.1-.3-1.3-.8-.3-.5-.2-1.1.1-1.5l7.3-9.4h-6.1c-.8 0-1.4-.7-1.4-1.5s.6-1.4 1.4-1.4h9.1c.6 0 1.1.3 1.3.8.3.5.2 1.1-.1 1.5l-7.3 9.4h6.2c.7 0 1.3.6 1.3 1.4M108.9 21.705c0 .8-.6 1.5-1.4 1.5h-9.1c-.6 0-1.1-.3-1.3-.8-.3-.5-.2-1.1.1-1.5l7.3-9.4h-6.2c-.8 0-1.4-.7-1.4-1.5s.6-1.4 1.4-1.4h9.1c.6 0 1.1.3 1.3.8.3.5.2 1.1-.1 1.5l-7.3 9.4h6.2c.8 0 1.4.6 1.4 1.4M110.6 21.705v-19c0-.8.6-1.4 1.5-1.4.8 0 1.4.6 1.4 1.4v19c0 .8-.6 1.5-1.4 1.5-.9 0-1.5-.7-1.5-1.5M129.9 15.805c0 .8-.6 1.4-1.5 1.4h-10.1c.6 1.7 2.2 3 4.2 3 .7 0 1.9-.1 3.3-1 .7-.4 1.6 0 1.9.7s0 1.5-.7 1.9c-1.9 1.3-3.4 1.3-4.5 1.3-4.1 0-7.3-3.3-7.3-7.3s3.3-7.3 7.3-7.3c3.6 0 7.4 2.6 7.4 7.3m-11.5-1.4h8.4c-.6-2-2.5-3-4.2-3-2 0-3.6 1.2-4.2 3"/><path stroke="#4f46e5" stroke-miterlimit="10" d="M32.3 4.215a3 3 0 1 0-6 0v16a3 3 0 0 0 6 0zM19.75 4.215a3 3 0 1 0-6 0v16a3 3 0 0 0 6 0z"/><path stroke="#4f46e5" stroke-miterlimit="10" d="M31.664 6.06a3 3 0 1 0-4.726-3.697L14.411 18.374a3 3 0 1 0 4.725 3.698zM19.102 6.06a3 3 0 1 0-4.726-3.697L1.85 18.375a3 3 0 0 0 4.725 3.697z"/></svg> \ No newline at end of file diff --git a/src/server/ui/main.css b/src/server/ui/main.css new file mode 100644 index 00000000..27fc7c01 --- /dev/null +++ b/src/server/ui/main.css @@ -0,0 +1,129 @@ +@import "tailwindcss" source(none); +@import "tw-animate-css"; +@source "./"; + +/* Light theme — gray palette */ +:root { + --background: #ffffff; + --foreground: #030712; /* gray-950 */ + --card: #ffffff; + --card-foreground: #030712; + --popover: #ffffff; + --popover-foreground: #030712; + --primary: #111827; /* gray-900 */ + --primary-foreground: #f9fafb; /* gray-50 */ + --secondary: #f3f4f6; /* gray-100 */ + --secondary-foreground: #111827; + --muted: #f3f4f6; + --muted-foreground: #6b7280; /* gray-500 */ + --accent: #f3f4f6; + --accent-foreground: #111827; + --destructive: #ef4444; + --border: #e5e7eb; /* gray-200 */ + --input: #e5e7eb; + --ring: #9ca3af; /* gray-400 */ + --radius: 0.625rem; + --sidebar-background: #f9fafb; /* gray-50 */ + --sidebar-foreground: #030712; + --sidebar-primary: #111827; + --sidebar-primary-foreground: #f9fafb; + --sidebar-accent: #f3f4f6; + --sidebar-accent-foreground: #111827; + --sidebar-border: #e5e7eb; + --sidebar-ring: #9ca3af; +} + +/* Dark theme — gray palette */ +@media (prefers-color-scheme: dark) { + :root { + --background: #030712; /* gray-950 */ + --foreground: #f9fafb; /* gray-50 */ + --card: #030712; + --card-foreground: #f9fafb; + --popover: #030712; + --popover-foreground: #f9fafb; + --primary: #f9fafb; + --primary-foreground: #030712; + --secondary: #1f2937; /* gray-800 */ + --secondary-foreground: #f9fafb; + --muted: #1f2937; + --muted-foreground: #9ca3af; /* gray-400 */ + --accent: #1f2937; + --accent-foreground: #f9fafb; + --destructive: #ef4444; + --border: #374151; /* gray-700 */ + --input: #374151; + --ring: #6b7280; /* gray-500 */ + --sidebar-background: #111827; /* gray-900 */ + --sidebar-foreground: #f9fafb; + --sidebar-primary: #f9fafb; + --sidebar-primary-foreground: #111827; + --sidebar-accent: rgb(255 255 255 / 0.06); + --sidebar-accent-foreground: #f9fafb; + --sidebar-border: #374151; + --sidebar-ring: #6b7280; + } +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius: var(--radius); + --color-sidebar-background: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.shiki-line-numbers code { + counter-reset: line; +} + +.shiki-line-numbers code .line::before { + counter-increment: line; + content: counter(line); + display: inline-block; + width: 3ch; + margin-right: 1.5rem; + text-align: right; + color: rgba(255, 255, 255, 0.2); +} + +.shiki-line-numbers code .line.shiki-highlight-line { + display: inline-block; + width: calc(100% + 3rem); + margin: 0 -1.5rem; + padding: 0 1.5rem; + background: rgba(255, 255, 255, 0.1); + border-left: 2px solid #f59e0b; + min-width: fit-content; +} diff --git a/src/server/ui/main.ts b/src/server/ui/main.ts new file mode 100644 index 00000000..c435d765 --- /dev/null +++ b/src/server/ui/main.ts @@ -0,0 +1,16 @@ +import './main.css' +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' +import Home from './pages/Home.vue' +import Preview from './pages/Preview.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: Home }, + { path: '/:template(.*)', component: Preview }, + ], +}) + +createApp(App).use(router).mount('#app') diff --git a/src/server/ui/mark-gradient.svg b/src/server/ui/mark-gradient.svg new file mode 100644 index 00000000..59b4908a --- /dev/null +++ b/src/server/ui/mark-gradient.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 251 184"><path d="M241.919 31.566c0-12.409-10.06-22.469-22.47-22.469-12.409 0-22.469 10.06-22.469 22.47v119.836c0 12.41 10.06 22.469 22.469 22.469 12.41 0 22.47-10.059 22.47-22.469V31.566Z" stroke="url(#a)" stroke-width="4" stroke-miterlimit="10"/><path d="M147.921 31.566c0-12.409-10.06-22.469-22.469-22.469-12.41 0-22.47 10.06-22.47 22.47v119.836c0 12.41 10.06 22.469 22.47 22.469 12.409 0 22.469-10.059 22.469-22.469V31.566Z" stroke="url(#b)" stroke-width="4" stroke-miterlimit="10"/><path d="M237.154 45.388c7.647-9.773 5.923-23.895-3.85-31.542-9.774-7.647-23.896-5.923-31.543 3.85L107.932 137.62c-7.646 9.774-5.923 23.896 3.851 31.543 9.773 7.646 23.895 5.923 31.542-3.851l93.829-119.923Z" stroke="url(#c)" stroke-width="4" stroke-miterlimit="10"/><path d="M143.068 45.392c7.646-9.774 5.922-23.896-3.851-31.543-9.774-7.646-23.896-5.922-31.542 3.851l-93.83 119.923c-7.646 9.773-5.922 23.895 3.851 31.542 9.774 7.647 23.896 5.923 31.543-3.85l93.829-119.923Z" stroke="url(#d)" stroke-width="4" stroke-miterlimit="10"/><defs><linearGradient id="a" x1="219.449" y1="9.097" x2="219.449" y2="173.872" gradientUnits="userSpaceOnUse"><stop stop-color="#4F46E5"/><stop offset="1" stop-color="#9C96FF"/></linearGradient><linearGradient id="b" x1="125.452" y1="9.097" x2="125.452" y2="173.872" gradientUnits="userSpaceOnUse"><stop stop-color="#4F46E5"/><stop offset="1" stop-color="#9C96FF"/></linearGradient><linearGradient id="c" x1="233.304" y1="13.846" x2="111.783" y2="169.162" gradientUnits="userSpaceOnUse"><stop stop-color="#4F46E5"/><stop offset="1" stop-color="#9C96FF"/></linearGradient><linearGradient id="d" x1="139.217" y1="13.85" x2="17.696" y2="169.165" gradientUnits="userSpaceOnUse"><stop stop-color="#4F46E5"/><stop offset="1" stop-color="#9C96FF"/></linearGradient></defs></svg> diff --git a/src/server/ui/mark.svg b/src/server/ui/mark.svg new file mode 100644 index 00000000..7da93aea --- /dev/null +++ b/src/server/ui/mark.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 205"><path d="M269.869 35.214c0-13.844-11.222-25.066-25.065-25.066-13.843 0-25.065 11.222-25.065 25.066v133.682c0 13.843 11.222 25.065 25.065 25.065 13.843 0 25.065-11.222 25.065-25.065V35.214ZM165.011 35.214c0-13.844-11.222-25.066-25.065-25.066-13.843 0-25.065 11.222-25.065 25.066v133.682c0 13.843 11.222 25.065 25.065 25.065 13.843 0 25.065-11.222 25.065-25.065V35.214Z" stroke="#4F46E5" stroke-width="4" stroke-miterlimit="10"/><path d="M264.554 50.632c8.531-10.902 6.608-26.656-4.295-35.186-10.903-8.53-26.656-6.608-35.187 4.295L120.403 153.519c-8.531 10.903-6.608 26.657 4.295 35.187 10.903 8.53 26.656 6.607 35.187-4.295L264.554 50.632ZM159.597 50.636c8.53-10.902 6.607-26.656-4.295-35.186-10.903-8.53-26.657-6.608-35.187 4.295L15.445 153.523c-8.53 10.903-6.607 26.657 4.296 35.187 10.902 8.53 26.656 6.607 35.186-4.295l104.67-133.779Z" stroke="#4F46E5" stroke-width="4" stroke-miterlimit="10"/></svg> diff --git a/src/server/ui/pages/Home.vue b/src/server/ui/pages/Home.vue new file mode 100644 index 00000000..4c53aa87 --- /dev/null +++ b/src/server/ui/pages/Home.vue @@ -0,0 +1,39 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { Kbd } from '@/components/ui/kbd' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty' + +import markUrl from '@/mark.svg' +import markGradientUrl from '@/mark-gradient.svg' + +const isMac = computed(() => navigator.platform.includes('Mac')) +</script> + +<template> + <Empty class="h-full"> + <EmptyHeader> + <EmptyMedia> + <img :src="markUrl" alt="Maizzle" class="size-12 dark:hidden"> + <img :src="markGradientUrl" alt="Maizzle" class="hidden size-12 dark:block"> + </EmptyMedia> + <EmptyTitle>Select an email to preview</EmptyTitle> + <EmptyDescription> + Choose an email from the sidebar to see a live preview. + </EmptyDescription> + </EmptyHeader> + <EmptyContent> + <p class="text-gray-500 dark:text-gray-400 text-sm"> + Press + <Kbd>{{ isMac ? '⌘' : 'Ctrl' }}</Kbd> + <Kbd>K</Kbd> + to open the command palette + </p> + </EmptyContent> + </Empty> +</template> diff --git a/src/server/ui/pages/Preview.vue b/src/server/ui/pages/Preview.vue new file mode 100644 index 00000000..a824b409 --- /dev/null +++ b/src/server/ui/pages/Preview.vue @@ -0,0 +1,615 @@ +<script setup lang="ts"> +import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue' +import { useRoute } from 'vue-router' +import { ChevronUp, ChevronDown, Check } from 'lucide-vue-next' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/components/ui/resizable' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' + +import stripesUrl from '../stripes.svg' + +interface Device { + name: string + width: number + height: number +} + +const props = defineProps<{ + device?: Device | null + resetKey?: number +}>() + +const viewMode = defineModel<'preview' | 'source'>('viewMode', { default: 'preview' }) + +const route = useRoute() +const srcdoc = ref('') +const sourceHtml = ref('') +const vueSourceHtml = ref('') +const plaintextContent = ref('') +const sourceView = ref<'compiled' | 'vue' | 'plaintext'>('compiled') +const copied = ref(false) + +const iframeEl = ref<HTMLIFrameElement>() +const vueSourceEl = ref<HTMLElement>() +const containerEl = ref<HTMLElement>() +const previewEl = ref<InstanceType<typeof ResizablePanel>>() +const leftPanel = ref<InstanceType<typeof ResizablePanel>>() +const rightPanel = ref<InstanceType<typeof ResizablePanel>>() +const topPanel = ref<InstanceType<typeof ResizablePanel>>() +const bottomPanel = ref<InstanceType<typeof ResizablePanel>>() + +const panelWidth = defineModel<number>('panelWidth', { default: 0 }) +const panelHeight = defineModel<number>('panelHeight', { default: 0 }) +const isDragging = defineModel<boolean>('isDragging', { default: false }) +const isFullSize = defineModel<boolean>('isFullSize', { default: true }) + +const sideSizes = ref({ left: 0, right: 0, top: 0, bottom: 0 }) + +function updateFullSize() { + isFullSize.value = sideSizes.value.left < 0.5 + && sideSizes.value.right < 0.5 + && sideSizes.value.top < 0.5 + && sideSizes.value.bottom < 0.5 +} + +async function copySource() { + if (sourceView.value === 'compiled') { + await navigator.clipboard.writeText(srcdoc.value) + } else if (sourceView.value === 'plaintext') { + await navigator.clipboard.writeText(plaintextContent.value) + } else { + const el = document.createElement('div') + el.innerHTML = vueSourceHtml.value + await navigator.clipboard.writeText(el.textContent || '') + } + copied.value = true + setTimeout(() => { copied.value = false }, 2000) +} + +interface CompatibilityIssue { + type: 'error' | 'warning' + title: string + clients: Array<{ name: string, notes: string[] }> + url?: string + line?: number +} + +interface LintIssue { + type: 'error' | 'warning' + title: string + message: string + line?: number +} + +interface TemplateStats { + size: { bytes: number, formatted: string } + images: number + links: number +} + +const compatibilityIssues = ref<CompatibilityIssue[]>([]) +const compatibilityLoading = ref(false) +const lintIssues = ref<LintIssue[]>([]) +const lintLoading = ref(false) +const stats = ref<TemplateStats | null>(null) +const statsLoading = ref(false) + +async function fetchTemplate() { + const res = await fetch(`/__maizzle/render/${route.params.template}`) + srcdoc.value = await res.text() +} + +async function fetchSource() { + const res = await fetch(`/__maizzle/source/${route.params.template}`) + sourceHtml.value = await res.text() +} + +async function fetchVueSource() { + const res = await fetch(`/__maizzle/vue-source/${route.params.template}`) + vueSourceHtml.value = await res.text() +} + +async function fetchPlaintext() { + const res = await fetch(`/__maizzle/plaintext/${route.params.template}`) + plaintextContent.value = await res.text() +} + +async function fetchStats() { + statsLoading.value = true + try { + const res = await fetch(`/__maizzle/stats/${route.params.template}`) + stats.value = await res.json() + } catch { + stats.value = null + } finally { + statsLoading.value = false + } +} + +async function fetchCompatibility() { + compatibilityLoading.value = true + try { + const res = await fetch(`/__maizzle/compatibility/${route.params.template}`) + compatibilityIssues.value = await res.json() + } catch { + compatibilityIssues.value = [] + } finally { + compatibilityLoading.value = false + } +} + +async function fetchLint() { + lintLoading.value = true + try { + const res = await fetch(`/__maizzle/lint/${route.params.template}`) + lintIssues.value = await res.json() + } catch { + lintIssues.value = [] + } finally { + lintLoading.value = false + } +} + +watch(() => route.params.template, () => { + sourceHtml.value = '' + vueSourceHtml.value = '' + plaintextContent.value = '' + compatibilityIssues.value = [] + lintIssues.value = [] + stats.value = null + sourceView.value = 'compiled' + fetchTemplate() + fetchCompatibility() + fetchLint() + fetchStats() + if (viewMode.value === 'source') fetchSource() +}, { immediate: true }) + +watch(viewMode, (mode) => { + if (mode === 'source') { + if (sourceView.value === 'compiled' && !sourceHtml.value) fetchSource() + if (sourceView.value === 'vue' && !vueSourceHtml.value) fetchVueSource() + if (sourceView.value === 'plaintext' && !plaintextContent.value) fetchPlaintext() + } +}) + +watch(sourceView, (view) => { + if (view === 'vue' && !vueSourceHtml.value) fetchVueSource() + if (view === 'compiled' && !sourceHtml.value) fetchSource() + if (view === 'plaintext' && !plaintextContent.value) fetchPlaintext() +}) + +if ((import.meta as any).hot) { + ;(import.meta as any).hot.on('maizzle:template-updated', () => { + fetchTemplate() + fetchCompatibility() + fetchLint() + fetchStats() + + // Always clear all source views so they re-fetch when switched to + sourceHtml.value = '' + vueSourceHtml.value = '' + plaintextContent.value = '' + + // Re-fetch the active source view immediately if currently visible + if (viewMode.value === 'source') { + if (sourceView.value === 'compiled') fetchSource() + if (sourceView.value === 'vue') fetchVueSource() + if (sourceView.value === 'plaintext') fetchPlaintext() + } + }) +} + + +async function goToLine(line: number) { + // Switch to source view showing Vue source + viewMode.value = 'source' + sourceView.value = 'vue' + + // Ensure vue source is loaded + if (!vueSourceHtml.value) { + await fetchVueSource() + } + + await nextTick() + + const el = vueSourceEl.value + if (!el) return + + // Remove previous highlight + el.querySelectorAll('.shiki-highlight-line').forEach(l => l.classList.remove('shiki-highlight-line')) + + // Find and highlight the line + const lineEl = el.querySelector(`[data-line="${line}"]`) + if (lineEl) { + lineEl.classList.add('shiki-highlight-line') + lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } +} + +// Track which axis is being user-dragged so we can sync the opposite panel +let hDragging = false +let vDragging = false + +const emit = defineEmits<{ 'clear-device': [] }>() + +function onHDragStart() { hDragging = true; isDragging.value = true; emit('clear-device') } +function onHDragEnd() { setTimeout(() => { hDragging = false }, 50); isDragging.value = false } +function onVDragStart() { vDragging = true; isDragging.value = true; emit('clear-device') } +function onVDragEnd() { setTimeout(() => { vDragging = false }, 50); isDragging.value = false } + +function onHorizontalLayout(sizes: number[]) { + if (!hDragging) return + + const [left, , right] = sizes + if (Math.abs(left - right) < 0.5) return + + hDragging = false + const side = Math.max(left, right) + if (left < side) leftPanel.value?.resize(side) + if (right < side) rightPanel.value?.resize(side) +} + +function onVerticalLayout(sizes: number[]) { + if (!vDragging) return + + const [top, , bottom] = sizes + if (Math.abs(top - bottom) < 0.5) return + + vDragging = false + const side = Math.max(top, bottom) + if (top < side) topPanel.value?.resize(side) + if (bottom < side) bottomPanel.value?.resize(side) +} + +function applyDeviceSize(device: Device | null | undefined) { + const el = containerEl.value + if (!el) return + + if (!device) { + if (!hDragging && !vDragging) { + leftPanel.value?.resize(0) + rightPanel.value?.resize(0) + topPanel.value?.resize(0) + bottomPanel.value?.resize(0) + } + return + } + + const rect = el.getBoundingClientRect() + if (!rect.width || !rect.height) return + + const handleSize = 16 + const hPanelSpace = rect.width - handleSize * 2 + const vPanelSpace = rect.height - handleSize * 2 + + const hSide = Math.max(0, ((hPanelSpace - device.width) / 2) / hPanelSpace * 100) + const vSide = Math.max(0, ((vPanelSpace - device.height) / 2) / vPanelSpace * 100) + + leftPanel.value?.resize(hSide) + rightPanel.value?.resize(hSide) + topPanel.value?.resize(vSide) + bottomPanel.value?.resize(vSide) +} + +watch(() => props.device, (device) => { + if (viewMode.value === 'source') return + applyDeviceSize(device) +}) + +watch(() => props.resetKey, () => { + applyDeviceSize(null) +}) + +watch(viewMode, async (mode) => { + if (mode === 'preview' && props.device) { + await nextTick() + applyDeviceSize(props.device) + } +}) + +let observer: ResizeObserver | null = null + +function forwardIframeKeys(iframe: HTMLIFrameElement) { + try { + const iframeDoc = iframe.contentDocument + if (!iframeDoc) return + + iframeDoc.addEventListener('keydown', (e: KeyboardEvent) => { + document.dispatchEvent(new KeyboardEvent('keydown', { + key: e.key, + code: e.code, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + })) + }) + } catch {} +} + +onMounted(() => { + const el = iframeEl.value + if (el) { + const rect = el.getBoundingClientRect() + panelWidth.value = Math.round(rect.width) + panelHeight.value = Math.round(rect.height) + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + panelWidth.value = Math.round(entry.contentRect.width) + panelHeight.value = Math.round(entry.contentRect.height) + } + }) + observer.observe(el) + el.addEventListener('load', () => forwardIframeKeys(el)) + } +}) + +onUnmounted(() => { + observer?.disconnect() +}) + +const bottomPanelOpen = ref(false) +const tabsPanelHeight = ref(40) +const activeTab = ref<string | undefined>(undefined) + +function toggleBottomPanel() { + bottomPanelOpen.value = !bottomPanelOpen.value + if (bottomPanelOpen.value) { + tabsPanelHeight.value = 200 + if (!activeTab.value) activeTab.value = 'compatibility' + } else { + tabsPanelHeight.value = 40 + activeTab.value = undefined + } +} + +function onTabClick(tab: string) { + if (tab === activeTab.value && bottomPanelOpen.value) { + bottomPanelOpen.value = false + tabsPanelHeight.value = 40 + activeTab.value = undefined + return + } + activeTab.value = tab + if (!bottomPanelOpen.value) { + bottomPanelOpen.value = true + tabsPanelHeight.value = 200 + } +} + +const tabsDragging = ref(false) + +function onTabsDragStart(e: MouseEvent) { + e.preventDefault() + tabsDragging.value = true + const startY = e.clientY + const startHeight = tabsPanelHeight.value + + const onMouseMove = (e: MouseEvent) => { + const newHeight = Math.max(40, startHeight + startY - e.clientY) + tabsPanelHeight.value = newHeight + bottomPanelOpen.value = newHeight > 40 + + if (!bottomPanelOpen.value) { + activeTab.value = undefined + } else if (!activeTab.value) { + activeTab.value = 'compatibility' + } + } + + const onMouseUp = () => { + tabsDragging.value = false + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) +} + +const stripeBg = { + backgroundImage: `url(${stripesUrl})`, + backgroundRepeat: 'repeat', + backgroundAttachment: 'fixed', +} +</script> + +<template> + <div class="flex flex-col h-full"> + <div class="relative flex-1 min-h-0"> + <!-- Source code view --> + <div v-show="viewMode === 'source'" class="absolute inset-0 min-w-0 overflow-hidden"> + <div class="absolute top-3 left-6 z-10"> + <DropdownMenu :modal="false"> + <DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 h-7 text-xs font-medium text-gray-300 hover:bg-white/15 transition-colors"> + {{ sourceView === 'compiled' ? 'HTML' : sourceView === 'vue' ? 'Source' : 'Plaintext' }} + <ChevronDown class="size-3 opacity-50" /> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" class="min-w-0 bg-white/10 backdrop-blur-md border-white/10"> + <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'vue'"> + <Check v-if="sourceView === 'vue'" class="size-3.5" /> + <span :class="sourceView === 'vue' ? '' : 'pl-5.5'">Source</span> + </DropdownMenuItem> + <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'compiled'"> + <Check v-if="sourceView === 'compiled'" class="size-3.5" /> + <span :class="sourceView === 'compiled' ? '' : 'pl-5.5'">HTML</span> + </DropdownMenuItem> + <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'plaintext'"> + <Check v-if="sourceView === 'plaintext'" class="size-3.5" /> + <span :class="sourceView === 'plaintext' ? '' : 'pl-5.5'">Plaintext</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + <button + class="absolute top-3 right-6 z-10 inline-flex items-center justify-center rounded-md px-2.5 h-8 bg-transparent hover:bg-transparent group disabled:opacity-50 disabled:cursor-not-allowed transition-all" + :disabled="copied" + @click="copySource" + > + <svg v-if="!copied" class="size-5 text-gray-400 group-hover:text-gray-300" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></svg> + <svg v-else class="size-5 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg> + </button> + <div + v-show="sourceView === 'compiled'" + class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto" + v-html="sourceHtml" + /> + <div + ref="vueSourceEl" + v-show="sourceView === 'vue'" + class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto" + v-html="vueSourceHtml" + /> + <pre + v-show="sourceView === 'plaintext'" + class="h-full overflow-auto p-6 pt-14 text-sm leading-6 min-h-full text-gray-300 bg-[#27212e] whitespace-pre-wrap break-words" + >{{ plaintextContent }}</pre> + </div> + + <!-- Preview view --> + <div v-show="viewMode !== 'source'" class="absolute inset-0"> + <div class="relative h-full opacity-5" :style="stripeBg" /> + </div> + + <div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex flex-col"> + <div class="flex-1 min-h-0"> + <ResizablePanelGroup direction="vertical" class="h-full" @layout="onVerticalLayout"> + <ResizablePanel ref="topPanel" :default-size="0" @resize="(s: number) => { sideSizes.top = s; updateFullSize() }" /> + <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" /> + <ResizablePanel :default-size="100" :min-size="20"> + <ResizablePanelGroup direction="horizontal" class="h-full" @layout="onHorizontalLayout"> + <ResizablePanel ref="leftPanel" :default-size="0" @resize="(s: number) => { sideSizes.left = s; updateFullSize() }" /> + <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" /> + <ResizablePanel ref="previewEl" :default-size="100" :min-size="20"> + <iframe + ref="iframeEl" + :srcdoc="srcdoc" + class="h-full w-full border-0 bg-white" + /> + </ResizablePanel> + <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" /> + <ResizablePanel ref="rightPanel" :default-size="0" @resize="(s: number) => { sideSizes.right = s; updateFullSize() }" /> + </ResizablePanelGroup> + </ResizablePanel> + <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" /> + <ResizablePanel ref="bottomPanel" :default-size="0" @resize="(s: number) => { sideSizes.bottom = s; updateFullSize() }" /> + </ResizablePanelGroup> + </div> + </div> + </div> + + <!-- Tabs panel (always visible) --> + <div + class="shrink-0 bg-white dark:bg-gray-950 overflow-hidden" + :class="!tabsDragging ? 'transition-[height] duration-200 ease-in-out' : ''" + :style="{ height: `${tabsPanelHeight}px` }" + > + <div + class="relative h-px bg-gray-200 dark:bg-gray-800 cursor-row-resize before:absolute before:-top-2 before:left-0 before:right-0 before:h-5 before:content-['']" + @mousedown="onTabsDragStart" + /> + <Tabs :model-value="activeTab" class="flex flex-col min-h-0 h-full"> + <div class="flex items-center justify-between min-h-10 px-4 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''"> + <TabsList class="h-full bg-transparent! rounded-none! p-0 gap-1"> + <TabsTrigger value="compatibility" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('compatibility')"> + Compatibility + </TabsTrigger> + <TabsTrigger value="lint" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('lint')"> + Linter + </TabsTrigger> + <TabsTrigger value="stats" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('stats')"> + Stats + </TabsTrigger> + </TabsList> + <Button variant="ghost" size="icon" class="h-7 w-7 hover:bg-transparent!" @click="toggleBottomPanel"> + <ChevronUp v-if="!bottomPanelOpen" class="size-4" /> + <ChevronDown v-else class="size-4" /> + </Button> + </div> + <div class="flex-1 overflow-auto"> + <TabsContent value="compatibility" class="mt-0"> + <p v-if="compatibilityLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p> + <p v-else-if="compatibilityIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No compatibility issues found.</p> + <ul v-else class="text-xs divide-y"> + <li + v-for="(issue, i) in compatibilityIssues" + :key="i" + class="px-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5" + > + <div class="flex items-start justify-between gap-4"> + <div> + <a v-if="issue.url" :href="issue.url" target="_blank" rel="noopener" class="font-medium hover:underline" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'"> + {{ issue.title }} + </a> + <span v-else class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'"> + {{ issue.title }} + </span> + <div class="text-gray-500 dark:text-gray-400 mt-1 space-y-0.5"> + <div v-for="client in issue.clients" :key="client.name"> + <span class="text-gray-700 dark:text-gray-300">{{ client.name }}</span><span v-if="client.notes.length">: {{ client.notes.join('. ') }}</span> + </div> + </div> + </div> + <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button> + </div> + </li> + </ul> + </TabsContent> + <TabsContent value="lint" class="mt-0"> + <p v-if="lintLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p> + <p v-else-if="lintIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p> + <ul v-else class="text-xs divide-y"> + <li + v-for="(issue, i) in lintIssues" + :key="i" + class="px-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5" + > + <div class="flex items-start justify-between gap-4"> + <div> + <span class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'"> + {{ issue.title }} + </span> + <div class="text-gray-500 dark:text-gray-400 mt-0.5">{{ issue.message }}</div> + </div> + <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button> + </div> + </li> + </ul> + </TabsContent> + <TabsContent value="stats" class="mt-0"> + <p v-if="statsLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p> + <p v-else-if="!stats" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p> + <div v-else class="px-4 py-3 flex items-center gap-6 text-xs"> + <div class="flex items-center gap-1.5"> + <span class="text-gray-500 dark:text-gray-400">Size</span> + <span + class="font-medium tabular-nums" + :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-100'" + >{{ stats.size.formatted }}</span> + </div> + <div class="flex items-center gap-1.5"> + <span class="text-gray-500 dark:text-gray-400">Images</span> + <span class="font-medium tabular-nums">{{ stats.images }}</span> + </div> + <div class="flex items-center gap-1.5"> + <span class="text-gray-500 dark:text-gray-400">Links</span> + <span class="font-medium tabular-nums">{{ stats.links }}</span> + </div> + </div> + </TabsContent> + </div> + </Tabs> + </div> + </div> +</template> diff --git a/src/server/ui/stripes.svg b/src/server/ui/stripes.svg new file mode 100644 index 00000000..92c61d29 --- /dev/null +++ b/src/server/ui/stripes.svg @@ -0,0 +1,174 @@ +<svg width="696" height="691" viewBox="0 0 696 691" fill="none" xmlns="http://www.w3.org/2000/svg"> +<line x1="-1362.38" y1="1221.17" x2="5.61744" y2="-529.787" stroke="#4B5563"/> +<line x1="-1350.38" y1="1221.17" x2="17.6174" y2="-529.787" stroke="#4B5563"/> +<line x1="-1338.38" y1="1221.17" x2="29.6174" y2="-529.787" stroke="#4B5563"/> +<line x1="-1326.38" y1="1221.17" x2="41.6174" y2="-529.787" stroke="#4B5563"/> +<line x1="-1314.38" y1="1221.17" x2="53.6174" y2="-529.787" stroke="#4B5563"/> +<line x1="-1302.38" y1="1221.17" x2="65.6174" y2="-529.787" stroke="#4B5563"/> +<line x1="-1290.38" y1="1221.17" x2="77.6194" y2="-529.787" stroke="#4B5563"/> +<line x1="-1278.38" y1="1221.17" x2="89.6194" y2="-529.787" stroke="#4B5563"/> +<line x1="-1266.38" y1="1221.17" x2="101.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1254.38" y1="1221.17" x2="113.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1242.38" y1="1221.17" x2="125.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1230.38" y1="1221.17" x2="137.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1218.38" y1="1221.17" x2="149.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1206.38" y1="1221.17" x2="161.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1194.38" y1="1221.17" x2="173.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1182.38" y1="1221.17" x2="185.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1170.38" y1="1221.17" x2="197.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1158.38" y1="1221.17" x2="209.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1146.38" y1="1221.17" x2="221.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1134.38" y1="1221.17" x2="233.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1122.38" y1="1221.17" x2="245.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1110.38" y1="1221.17" x2="257.619" y2="-529.787" stroke="#4B5563"/> +<line x1="-1098.38" y1="1221.17" x2="269.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1086.38" y1="1221.17" x2="281.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1074.38" y1="1221.17" x2="293.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1062.38" y1="1221.17" x2="305.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1050.38" y1="1221.17" x2="317.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1038.38" y1="1221.17" x2="329.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1026.38" y1="1221.17" x2="341.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1014.38" y1="1221.17" x2="353.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-1002.38" y1="1221.17" x2="365.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-990.378" y1="1221.17" x2="377.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-978.378" y1="1221.17" x2="389.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-966.378" y1="1221.17" x2="401.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-954.378" y1="1221.17" x2="413.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-942.378" y1="1221.17" x2="425.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-930.378" y1="1221.17" x2="437.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-918.378" y1="1221.17" x2="449.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-906.378" y1="1221.17" x2="461.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-894.378" y1="1221.17" x2="473.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-882.378" y1="1221.17" x2="485.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-870.378" y1="1221.17" x2="497.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-858.378" y1="1221.17" x2="509.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-846.378" y1="1221.17" x2="521.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-834.378" y1="1221.17" x2="533.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-822.376" y1="1221.17" x2="545.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-810.376" y1="1221.17" x2="557.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-798.376" y1="1221.17" x2="569.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-786.376" y1="1221.17" x2="581.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-774.376" y1="1221.17" x2="593.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-762.376" y1="1221.17" x2="605.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-750.376" y1="1221.17" x2="617.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-738.376" y1="1221.17" x2="629.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-726.378" y1="1221.17" x2="641.621" y2="-529.787" stroke="#4B5563"/> +<line x1="-714.376" y1="1221.17" x2="653.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-702.376" y1="1221.17" x2="665.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-690.376" y1="1221.17" x2="677.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-678.376" y1="1221.17" x2="689.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-666.376" y1="1221.17" x2="701.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-654.376" y1="1221.17" x2="713.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-642.376" y1="1221.17" x2="725.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-630.376" y1="1221.17" x2="737.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-618.376" y1="1221.17" x2="749.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-606.376" y1="1221.17" x2="761.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-594.376" y1="1221.17" x2="773.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-582.376" y1="1221.17" x2="785.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-570.376" y1="1221.17" x2="797.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-558.376" y1="1221.17" x2="809.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-546.376" y1="1221.17" x2="821.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-534.376" y1="1221.17" x2="833.623" y2="-529.787" stroke="#4B5563"/> +<line x1="-522.374" y1="1221.17" x2="845.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-510.374" y1="1221.17" x2="857.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-498.374" y1="1221.17" x2="869.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-486.374" y1="1221.17" x2="881.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-474.374" y1="1221.17" x2="893.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-462.374" y1="1221.17" x2="905.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-450.374" y1="1221.17" x2="917.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-438.374" y1="1221.17" x2="929.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-426.374" y1="1221.17" x2="941.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-414.374" y1="1221.17" x2="953.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-402.374" y1="1221.17" x2="965.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-390.374" y1="1221.17" x2="977.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-378.374" y1="1221.17" x2="989.625" y2="-529.787" stroke="#4B5563"/> +<line x1="-366.374" y1="1221.17" x2="1001.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-354.374" y1="1221.17" x2="1013.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-342.374" y1="1221.17" x2="1025.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-330.374" y1="1221.17" x2="1037.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-318.374" y1="1221.17" x2="1049.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-306.374" y1="1221.17" x2="1061.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-294.374" y1="1221.17" x2="1073.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-282.374" y1="1221.17" x2="1085.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-270.374" y1="1221.17" x2="1097.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-258.374" y1="1221.17" x2="1109.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-246.374" y1="1221.17" x2="1121.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-234.374" y1="1221.17" x2="1133.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-222.374" y1="1221.17" x2="1145.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-210.374" y1="1221.17" x2="1157.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-198.374" y1="1221.17" x2="1169.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-186.374" y1="1221.17" x2="1181.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-174.374" y1="1221.17" x2="1193.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-162.374" y1="1221.17" x2="1205.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-150.374" y1="1221.17" x2="1217.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-138.374" y1="1221.17" x2="1229.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-126.374" y1="1221.17" x2="1241.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-114.374" y1="1221.17" x2="1253.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-102.374" y1="1221.17" x2="1265.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-90.3745" y1="1221.17" x2="1277.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-78.3745" y1="1221.17" x2="1289.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-66.3745" y1="1221.17" x2="1301.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-54.3745" y1="1221.17" x2="1313.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-42.3745" y1="1221.17" x2="1325.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-30.3745" y1="1221.17" x2="1337.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-18.3745" y1="1221.17" x2="1349.63" y2="-529.787" stroke="#4B5563"/> +<line x1="-6.37447" y1="1221.17" x2="1361.63" y2="-529.787" stroke="#4B5563"/> +<line x1="5.62553" y1="1221.17" x2="1373.63" y2="-529.787" stroke="#4B5563"/> +<line x1="17.6255" y1="1221.17" x2="1385.63" y2="-529.787" stroke="#4B5563"/> +<line x1="29.6255" y1="1221.17" x2="1397.63" y2="-529.787" stroke="#4B5563"/> +<line x1="41.6255" y1="1221.17" x2="1409.63" y2="-529.787" stroke="#4B5563"/> +<line x1="53.6255" y1="1221.17" x2="1421.63" y2="-529.787" stroke="#4B5563"/> +<line x1="65.6255" y1="1221.17" x2="1433.63" y2="-529.787" stroke="#4B5563"/> +<line x1="77.6255" y1="1221.17" x2="1445.63" y2="-529.787" stroke="#4B5563"/> +<line x1="89.6255" y1="1221.17" x2="1457.63" y2="-529.787" stroke="#4B5563"/> +<line x1="101.626" y1="1221.17" x2="1469.63" y2="-529.787" stroke="#4B5563"/> +<line x1="113.626" y1="1221.17" x2="1481.63" y2="-529.787" stroke="#4B5563"/> +<line x1="125.626" y1="1221.17" x2="1493.63" y2="-529.787" stroke="#4B5563"/> +<line x1="137.626" y1="1221.17" x2="1505.63" y2="-529.787" stroke="#4B5563"/> +<line x1="149.626" y1="1221.17" x2="1517.63" y2="-529.787" stroke="#4B5563"/> +<line x1="161.626" y1="1221.17" x2="1529.63" y2="-529.787" stroke="#4B5563"/> +<line x1="173.626" y1="1221.17" x2="1541.63" y2="-529.787" stroke="#4B5563"/> +<line x1="185.626" y1="1221.17" x2="1553.63" y2="-529.787" stroke="#4B5563"/> +<line x1="197.626" y1="1221.17" x2="1565.63" y2="-529.787" stroke="#4B5563"/> +<line x1="209.626" y1="1221.17" x2="1577.63" y2="-529.787" stroke="#4B5563"/> +<line x1="221.626" y1="1221.17" x2="1589.63" y2="-529.787" stroke="#4B5563"/> +<line x1="233.626" y1="1221.17" x2="1601.63" y2="-529.787" stroke="#4B5563"/> +<line x1="245.626" y1="1221.17" x2="1613.63" y2="-529.787" stroke="#4B5563"/> +<line x1="257.626" y1="1221.17" x2="1625.63" y2="-529.787" stroke="#4B5563"/> +<line x1="269.626" y1="1221.17" x2="1637.63" y2="-529.787" stroke="#4B5563"/> +<line x1="281.626" y1="1221.17" x2="1649.63" y2="-529.787" stroke="#4B5563"/> +<line x1="293.626" y1="1221.17" x2="1661.63" y2="-529.787" stroke="#4B5563"/> +<line x1="305.626" y1="1221.17" x2="1673.63" y2="-529.787" stroke="#4B5563"/> +<line x1="317.626" y1="1221.17" x2="1685.63" y2="-529.787" stroke="#4B5563"/> +<line x1="329.626" y1="1221.17" x2="1697.63" y2="-529.787" stroke="#4B5563"/> +<line x1="341.626" y1="1221.17" x2="1709.63" y2="-529.787" stroke="#4B5563"/> +<line x1="353.626" y1="1221.17" x2="1721.63" y2="-529.787" stroke="#4B5563"/> +<line x1="365.626" y1="1221.17" x2="1733.63" y2="-529.787" stroke="#4B5563"/> +<line x1="377.626" y1="1221.17" x2="1745.63" y2="-529.787" stroke="#4B5563"/> +<line x1="389.626" y1="1221.17" x2="1757.63" y2="-529.787" stroke="#4B5563"/> +<line x1="401.626" y1="1221.17" x2="1769.63" y2="-529.787" stroke="#4B5563"/> +<line x1="413.626" y1="1221.17" x2="1781.63" y2="-529.787" stroke="#4B5563"/> +<line x1="425.626" y1="1221.17" x2="1793.63" y2="-529.787" stroke="#4B5563"/> +<line x1="437.626" y1="1221.17" x2="1805.63" y2="-529.787" stroke="#4B5563"/> +<line x1="449.626" y1="1221.17" x2="1817.63" y2="-529.787" stroke="#4B5563"/> +<line x1="461.626" y1="1221.17" x2="1829.63" y2="-529.787" stroke="#4B5563"/> +<line x1="473.626" y1="1221.17" x2="1841.63" y2="-529.787" stroke="#4B5563"/> +<line x1="485.626" y1="1221.17" x2="1853.63" y2="-529.787" stroke="#4B5563"/> +<line x1="497.626" y1="1221.17" x2="1865.63" y2="-529.787" stroke="#4B5563"/> +<line x1="509.626" y1="1221.17" x2="1877.63" y2="-529.787" stroke="#4B5563"/> +<line x1="521.626" y1="1221.17" x2="1889.63" y2="-529.787" stroke="#4B5563"/> +<line x1="533.626" y1="1221.17" x2="1901.63" y2="-529.787" stroke="#4B5563"/> +<line x1="545.626" y1="1221.17" x2="1913.63" y2="-529.787" stroke="#4B5563"/> +<line x1="557.626" y1="1221.17" x2="1925.63" y2="-529.787" stroke="#4B5563"/> +<line x1="569.626" y1="1221.17" x2="1937.63" y2="-529.787" stroke="#4B5563"/> +<line x1="581.626" y1="1221.17" x2="1949.63" y2="-529.787" stroke="#4B5563"/> +<line x1="593.626" y1="1221.17" x2="1961.63" y2="-529.787" stroke="#4B5563"/> +<line x1="605.626" y1="1221.17" x2="1973.63" y2="-529.787" stroke="#4B5563"/> +<line x1="617.626" y1="1221.17" x2="1985.63" y2="-529.787" stroke="#4B5563"/> +<line x1="629.626" y1="1221.17" x2="1997.63" y2="-529.787" stroke="#4B5563"/> +<line x1="641.626" y1="1221.17" x2="2009.63" y2="-529.787" stroke="#4B5563"/> +<line x1="653.626" y1="1221.17" x2="2021.63" y2="-529.787" stroke="#4B5563"/> +<line x1="665.626" y1="1221.17" x2="2033.63" y2="-529.787" stroke="#4B5563"/> +<line x1="677.626" y1="1221.17" x2="2045.63" y2="-529.787" stroke="#4B5563"/> +<line x1="689.626" y1="1221.17" x2="2057.63" y2="-529.787" stroke="#4B5563"/> +</svg> diff --git a/src/server/views/404.html b/src/server/views/404.html deleted file mode 100644 index 8b5c61ef..00000000 --- a/src/server/views/404.html +++ /dev/null @@ -1,59 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>404 - Template not found</title> - <style> - html, body { - font-family: Helvetica, Arial, sans-serif; - margin: 0; - padding: 0; - box-sizing: border-box; - height: 100%; - } - - .container { - box-sizing: border-box; - height: 100vh; - display: flex; - align-items: center; - justify-content: center; - position: relative; - z-index: 1; - padding: 24px; - } - - .error-code { - font-size: 25rem; - font-weight: 700; - color: #f1f5f9; - position: fixed; - top: -1.5rem; - left: -3rem; - user-select: none; - } - </style> -</head> -<body> - <span class="error-code">404</span> - - <div class="container"> - <div style="text-align: center;"> - <h1 style="font-size: 3rem; color: #0F172A; margin: 2.25rem 0"> - Template Not Found - </h1> - <p style="margin: 0 0 2.25rem; font-size: 1.25rem; line-height: 1.5; color: #64748B;"> - The Template you are looking for was not found: - </p> - <p style="margin: 1rem 0 0; font-size: 1rem; line-height: 1.5; font-weight: 600; color: #334155;"> - {{ page.url }} - </p> - </div> - </div> - - <div style="position: fixed; bottom: 0; right: 0; pointer-events: none; user-select: none;"> - <svg width="883" height="536" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="1100" height="536"><path fill="#D9D9D9" d="M0 .955h1100V536H0z"/></mask><g mask="url(#a)" stroke="#94A3B8" stroke-miterlimit="10"><path d="M1056.93 92.587c0-50.03-43.95-90.587-98.168-90.587-54.22 0-98.174 40.557-98.174 90.587v483.125c0 50.029 43.954 90.586 98.174 90.586 54.218 0 98.168-40.557 98.168-90.586V92.587ZM646.241 92.587C646.241 42.556 602.287 2 548.067 2c-54.219 0-98.173 40.557-98.173 90.587v483.125c0 50.029 43.954 90.586 98.173 90.586 54.22 0 98.174-40.557 98.174-90.586V92.587Z"/><path d="M1036.18 148.383c33.41-39.402 25.88-96.336-16.82-127.164C976.657-9.61 914.955-2.66 881.544 36.742L471.586 520.215c-33.411 39.402-25.879 96.336 16.824 127.164 42.702 30.829 104.404 23.879 137.815-15.523l409.955-483.473ZM625.093 148.396c33.411-39.403 25.878-96.336-16.824-127.164C565.567-9.597 503.865-2.647 470.454 36.755L60.495 520.228c-33.41 39.402-25.878 96.336 16.825 127.164 42.702 30.829 104.404 23.879 137.815-15.523l409.958-483.473Z"/></g></svg> - </div> -</body> -</html> diff --git a/src/server/views/error.html b/src/server/views/error.html deleted file mode 100644 index 52c2cc4f..00000000 --- a/src/server/views/error.html +++ /dev/null @@ -1,83 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Error</title> - <style> - body { - font-family: Helvetica, Arial, sans-serif; - margin: 0; - padding: 0; - box-sizing: border-box; - } - - .container { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - position: relative; - z-index: 1; - padding: 24px; - } - - .stack-trace-wrapper { - width: 100%; - max-width: 90ch; - margin-top: 2.25rem; - } - - .stack-trace { - overflow-x: auto; - padding: 24px; - border-radius: 4px; - text-align: left; - font-size: 1rem; - box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.1); - border-left: 4px solid #FB7185; - background-color: rgba(248, 250, 252, 0.7); - backdrop-filter: blur(4px); - font-family: 'Courier New', Courier, monospace; - } - </style> -</head> -<body> - <div class="container"> - <h1 style="font-size: 3rem; color: #0F172A; margin: 2.25rem 0"> - <span style="color: #4f46e5">Oops!</span> - Something went wrong. - </h1> - - <p style="margin: 0 0 2.25rem; font-size: 1.25rem; line-height: 1.5; color: #64748B;"> - {{ page.error.message }} - </p> - - <span style="padding: 2px 12px; font-size: 1rem; line-height: 1.5; color: #fff; background-color: #64748B; border-radius: 8px;"> - {{ page.method }} - </span> - - <p style="margin: 1rem 0 0; font-size: 1rem; line-height: 1.5; font-weight: 600; color: #334155;"> - {{ page.url }} - </p> - - <if condition="page.error.code"> - <p style="margin: 1rem 0 0; font-size: 1rem; line-height: 1.5; font-weight: 600; color: #334155;"> - {{ page.error.code }} - </p> - </if> - - <div class="stack-trace-wrapper"> - <div class="stack-trace"> - <each loop="line, index in page.error.stack.split('\n')"> - <p>{{{ line }}}</p> - </each> - </div> - </div> - </div> - - <div style="position: fixed; bottom: 0; right: 0; pointer-events: none; user-select: none;"> - <svg width="883" height="536" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="1100" height="536"><path fill="#D9D9D9" d="M0 .955h1100V536H0z"/></mask><g mask="url(#a)" stroke="#94A3B8" stroke-miterlimit="10"><path d="M1056.93 92.587c0-50.03-43.95-90.587-98.168-90.587-54.22 0-98.174 40.557-98.174 90.587v483.125c0 50.029 43.954 90.586 98.174 90.586 54.218 0 98.168-40.557 98.168-90.586V92.587ZM646.241 92.587C646.241 42.556 602.287 2 548.067 2c-54.219 0-98.173 40.557-98.173 90.587v483.125c0 50.029 43.954 90.586 98.173 90.586 54.22 0 98.174-40.557 98.174-90.586V92.587Z"/><path d="M1036.18 148.383c33.41-39.402 25.88-96.336-16.82-127.164C976.657-9.61 914.955-2.66 881.544 36.742L471.586 520.215c-33.411 39.402-25.879 96.336 16.824 127.164 42.702 30.829 104.404 23.879 137.815-15.523l409.955-483.473ZM625.093 148.396c33.411-39.403 25.878-96.336-16.824-127.164C565.567-9.597 503.865-2.647 470.454 36.755L60.495 520.228c-33.41 39.402-25.878 96.336 16.825 127.164 42.702 30.829 104.404 23.879 137.815-15.523l409.958-483.473Z"/></g></svg> - </div> -</body> -</html> diff --git a/src/server/views/index.html b/src/server/views/index.html deleted file mode 100644 index 44d6e01c..00000000 --- a/src/server/views/index.html +++ /dev/null @@ -1,172 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Maizzle | Templates</title> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet" media="screen"> - <style> - body { - padding: 7rem 6.25rem; - } - - @media (max-width: 425px) { - body { - padding: 3rem 1.5rem; - } - } - - /* General styling */ - ul { - margin: 0; - padding: 0; - list-style-type: none; - font-family: Inter, Arial, Helvetica, sans-serif; - } - - /* Folder styling */ - li > strong { - font-size: 1.875rem; - line-height: 2.25rem; - color: #0F172A; - } - - li.folder.root { - padding-bottom: 1.5rem; - padding-top: 3rem; - } - - li.folder.root:first-child { - padding-top: 0 !important; - } - - li.folder.nested { - padding-top: 1.5rem; - } - - li.folder.nested > strong { - font-size: 1.25rem; - line-height: 1.75rem; - font-weight: 600; - } - - li > strong > span { - color: #94A3B8; - } - - /* File styling */ - li > a { - color: #4f46e5; - text-decoration: none; - font-size: 1.25rem; - line-height: 1.75rem; - } - - li > a:hover { - text-decoration: underline; - } - - /* Indentation based on depth */ - li { - padding-left: 1.5rem; - } - - li.folder.nested { - padding-top: 2.25rem; - padding-bottom: 0.625rem; - border-left: 1px solid #64748B; - } - - li.folder.nested > strong { - cursor: pointer; - display: block; - } - - li.folder.root { - padding-top: 3rem; - border: none; - } - - li.file { - border-left: 1px solid #64748B; - } - - li.file.nested { - line-height: 2.25rem; - } - - li.file.nested.collapsed { - display: none; - } - - li.file.nested.collapsed + .folder.nested { - padding-top: 0; - } - - - li.file.nested a { - padding-bottom: 0.75rem; - margin-left: -1.5rem; - padding-left: 1.5rem; - border-left: 1px solid #cbd5e1; - } - - li.file.nested:last-child a { - padding-bottom: 0; - } - - .insignia { - width: 64rem; - position: fixed; - z-index: -1; - right: -3rem; - bottom: 0; - } - - @media (max-width: 425px) { - .insignia { - width: 100%; - } - } - </style> -</head> -<body> - <ul> - <each loop="item in paths"> - <if condition="item.type === 'folder'"> - <li - style="padding-left: calc(1.5rem * {{ item.depth }});" - class="folder {{ item.depth > 0 ? 'nested' : 'root' }}" - > - <strong>{{{ item.name }}}</strong> - </li> - </if> - <if condition="item.type === 'file'"> - <li - style="padding-left: calc(1.5rem * {{ item.depth }});" - class="file {{ item.depth > 1 ? 'nested' : '' }}" - > - <a href="{{ item.href }}"> - {{ item.name }} - </a> - </li> - </if> - </each> - </ul> - - <svg fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 993 483" class="insignia"><mask id="a" style="mask-type:alpha;" maskUnits="userSpaceOnUse" x="0" y="0" width="993" height="483"><path fill="#D9D9D9" d="M0 0h993v483H0z"></path></mask><g mask="url(#a)" stroke="#e2e8f0" stroke-miterlimit="10"><path d="M954.124 81.816c0-45.163-39.678-81.774-88.624-81.774-48.945 0-88.624 36.611-88.624 81.774v436.13c0 45.163 39.679 81.775 88.624 81.775 48.946 0 88.624-36.612 88.624-81.775V81.816ZM583.379 81.816c0-45.163-39.678-81.774-88.624-81.774-48.945 0-88.624 36.611-88.624 81.774v436.13c0 45.163 39.679 81.775 88.624 81.775 48.946 0 88.624-36.612 88.624-81.775V81.816Z"></path><path d="M935.39 132.185c30.161-35.57 23.362-86.965-15.187-114.795S825.954-4.165 795.794 31.404L425.713 467.848c-30.161 35.57-23.361 86.965 15.187 114.795 38.549 27.829 94.249 21.555 124.41-14.014l370.08-436.444ZM564.288 132.196c30.161-35.569 23.362-86.964-15.187-114.794S454.852-4.154 424.692 31.416L54.611 467.86c-30.16 35.569-23.361 86.965 15.187 114.794 38.549 27.83 94.249 21.556 124.41-14.013l370.08-436.445Z"></path></g></svg> - <script> - document.querySelectorAll('.folder.nested').forEach(folder => { - folder.addEventListener('click', function() { - let nextElement = this.nextElementSibling; - while (nextElement && nextElement.classList.contains('file') && nextElement.classList.contains('nested')) { - nextElement.classList.toggle('collapsed'); - nextElement = nextElement.nextElementSibling; - } - }); - }); - </script> -</body> -</html> diff --git a/src/server/websockets.js b/src/server/websockets.js deleted file mode 100644 index 2494f56f..00000000 --- a/src/server/websockets.js +++ /dev/null @@ -1,27 +0,0 @@ -import WebSocket from 'ws' - -export function initWebSockets(wss, options = {}) { - options.shouldScroll = options.shouldScroll || false - options.useHmr = options.useHmr || true - - wss.on('connection', ws => { - // Handle incoming messages from the client - ws.on('message', message => { - const parsedMessage = JSON.parse(message) - - /** - * Broadcast message back to all connected clients - * We use it to send the scroll position back so other clients can follow - */ - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - ...parsedMessage, - scrollSync: options.shouldScroll, - hmr: options.useHmr - })) - } - }) - }) - }) -} diff --git a/src/tests/ast.test.ts b/src/tests/ast.test.ts new file mode 100644 index 00000000..dab85edb --- /dev/null +++ b/src/tests/ast.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest' +import { parse, walk, serialize } from '../utils/ast/index.ts' + +describe('parse', () => { + it('parses a simple HTML string into AST nodes', () => { + const dom = parse('<div>Hello</div>') + + expect(dom).toHaveLength(1) + expect(dom[0].type).toBe('tag') + }) + + it('parses multiple root elements', () => { + const dom = parse('<p>One</p><p>Two</p>') + + expect(dom).toHaveLength(2) + }) + + it('parses nested elements', () => { + const dom = parse('<div><span>Nested</span></div>') + const div = dom[0] as any + + expect(div.children).toHaveLength(1) + expect(div.children[0].name).toBe('span') + }) + + it('handles empty input', () => { + const dom = parse('') + + expect(dom).toHaveLength(0) + }) + + it('preserves attributes', () => { + const dom = parse('<a href="https://example.com" class="link">Click</a>') + const a = dom[0] as any + + expect(a.attribs.href).toBe('https://example.com') + expect(a.attribs.class).toBe('link') + }) + + it('decodes HTML entities', () => { + const dom = parse('<p>&amp; &lt; &gt;</p>') + const p = dom[0] as any + + expect(p.children[0].data).toBe('& < >') + }) +}) + +describe('walk', () => { + it('visits all nodes in the tree', () => { + const dom = parse('<div><p>Text</p><span>More</span></div>') + const visited: string[] = [] + + walk(dom, (node) => { + if (node.type === 'tag') { + visited.push((node as any).name) + } + }) + + expect(visited).toEqual(['div', 'p', 'span']) + }) + + it('visits text nodes', () => { + const dom = parse('<p>Hello</p>') + const texts: string[] = [] + + walk(dom, (node) => { + if (node.type === 'text') { + texts.push((node as any).data) + } + }) + + expect(texts).toEqual(['Hello']) + }) + + it('visits deeply nested nodes', () => { + const dom = parse('<div><ul><li><a href="#">Link</a></li></ul></div>') + const tags: string[] = [] + + walk(dom, (node) => { + if (node.type === 'tag') { + tags.push((node as any).name) + } + }) + + expect(tags).toEqual(['div', 'ul', 'li', 'a']) + }) + + it('handles empty AST', () => { + const visited: unknown[] = [] + + walk([], (node) => { + visited.push(node) + }) + + expect(visited).toHaveLength(0) + }) + + it('allows mutation of nodes', () => { + const dom = parse('<div class="old">Content</div>') + + walk(dom, (node) => { + if (node.type === 'tag' && (node as any).attribs?.class === 'old') { + (node as any).attribs.class = 'new' + } + }) + + expect(serialize(dom)).toBe('<div class="new">Content</div>') + }) +}) + +describe('serialize', () => { + it('serializes AST back to HTML', () => { + const html = '<div><p>Hello</p></div>' + const dom = parse(html) + + expect(serialize(dom)).toBe(html) + }) + + it('preserves attributes', () => { + const html = '<a href="https://example.com" class="btn">Click</a>' + const dom = parse(html) + + expect(serialize(dom)).toBe(html) + }) + + it('preserves self-closing tags', () => { + const dom = parse('<br><hr><img src="test.png">') + + expect(serialize(dom)).toBe('<br><hr><img src="test.png">') + }) + + it('handles empty AST', () => { + expect(serialize([])).toBe('') + }) + + it('roundtrips complex HTML', () => { + const html = '<table><tr><td style="padding: 10px;">Cell</td></tr></table>' + const dom = parse(html) + + expect(serialize(dom)).toBe(html) + }) +}) diff --git a/src/tests/build.test.ts b/src/tests/build.test.ts new file mode 100644 index 00000000..f58a7712 --- /dev/null +++ b/src/tests/build.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { build } from '../build.ts' + +function createTempProject() { + const dir = mkdtempSync(join(tmpdir(), 'maizzle-build-')) + return dir +} + +function writeSfc(dir: string, path: string, content: string) { + const full = join(dir, path) + mkdirSync(join(dir, ...path.split('/').slice(0, -1)), { recursive: true }) + writeFileSync(full, content) +} + +describe('build', () => { + let tempDir: string + const originalCwd = process.cwd() + + beforeEach(() => { + tempDir = createTempProject() + process.chdir(tempDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('builds a simple SFC to HTML', async () => { + writeSfc(tempDir, 'emails/welcome.vue', ` + <template> + <div>Hello World</div> + </template> + `) + + const result = await build() + + expect(result.files).toHaveLength(1) + expect(result.files[0]).toContain('welcome.html') + + const html = readFileSync(result.files[0], 'utf-8') + expect(html).toContain('Hello World') + }) + + it('respects output.path config', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <p>Test</p> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + output: { path: 'dist' } + } + `) + + const result = await build() + + expect(result.files[0]).toContain('/dist/') + }) + + it('respects output.extension config', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <p>Test</p> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + output: { extension: 'htm' } + } + `) + + const result = await build() + + expect(result.files[0]).toMatch(/\.htm$/) + }) + + it('supports output override via config', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <p>Test</p> + </template> + `) + + const result = await build({ + config: { output: { path: join(tempDir, 'custom-output') } }, + }) + + expect(result.files[0]).toContain('custom-output') + }) + + it('builds multiple templates', async () => { + writeSfc(tempDir, 'emails/one.vue', ` + <template><div>One</div></template> + `) + + writeSfc(tempDir, 'emails/two.vue', ` + <template><div>Two</div></template> + `) + + const result = await build() + + expect(result.files).toHaveLength(2) + + const htmls = result.files.map(f => readFileSync(f, 'utf-8')) + expect(htmls.some(h => h.includes('One'))).toBe(true) + expect(htmls.some(h => h.includes('Two'))).toBe(true) + }) + + it('returns empty files array when no templates found', async () => { + // No emails directory, no templates + const result = await build() + + expect(result.files).toHaveLength(0) + }) + + it('fires beforeRender event', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Hello</div> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + beforeRender({ template, config }) { + // Event fires with template content and config + globalThis.__beforeRenderFired = { template, hasConfig: !!config } + } + } + `) + + await build() + + const data = (globalThis as any).__beforeRenderFired + expect(data).toBeDefined() + expect(data.template).toContain('Hello') + expect(data.hasConfig).toBe(true) + delete (globalThis as any).__beforeRenderFired + }) + + it('fires afterRender event and uses modified HTML', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Original</div> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + afterRender({ html }) { + return html.replace('Original', 'Modified') + } + } + `) + + const result = await build() + const html = readFileSync(result.files[0], 'utf-8') + + expect(html).toContain('Modified') + expect(html).not.toContain('Original') + }) + + it('fires afterTransform event', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Content</div> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + afterTransform({ html }) { + return html + '<!-- transformed -->' + } + } + `) + + const result = await build() + const html = readFileSync(result.files[0], 'utf-8') + + expect(html).toContain('<!-- transformed -->') + }) + + it('fires afterBuild event with file list', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template><div>Test</div></template> + `) + + const marker = join(tempDir, 'afterbuild.marker') + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + import { writeFileSync } from 'node:fs' + + export default { + afterBuild({ files }) { + writeFileSync('${marker.replace(/\\/g, '\\\\')}', files.join('\\n')) + } + } + `) + + await build() + + expect(existsSync(marker)).toBe(true) + const content = readFileSync(marker, 'utf-8') + expect(content).toContain('test.html') + }) + + it('fires beforeCreate to modify config', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Test</div> + </template> + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + output: { path: 'original-output' }, + beforeCreate({ config }) { + config.output.path = 'modified-output' + } + } + `) + + const result = await build() + + expect(result.files[0]).toContain('modified-output') + }) + + it('does not leak Tailwind classes between templates', async () => { + symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules')) + + const sfc = ` + <template> + <html> + <head> + <style>@import "tailwindcss/utilities" important;</style> + </head> + <body> + <div class="[border:1px_solid_red]"></div> + </body> + </html> + </template> + ` + + writeSfc(tempDir, 'emails/first.vue', sfc) + writeSfc(tempDir, 'emails/nested/second.vue', sfc) + + const result = await build() + const htmls = result.files.map(f => readFileSync(f, 'utf-8')) + + // Both should contain the arbitrary value class compiled to CSS + for (const html of htmls) { + expect(html).toContain('border: 1px solid red') + } + + // Neither should have a leaked .border utility class + for (const html of htmls) { + expect(html).not.toMatch(/\.border\s*\{/) + } + }) + + describe('plaintext', () => { + it('generates .txt file alongside HTML with plaintext: true', async () => { + writeSfc(tempDir, 'emails/welcome.vue', ` + <template> + <div><h1>Hello</h1><p>World</p></div> + </template> + `) + + const result = await build({ + config: { plaintext: true }, + }) + + expect(result.files).toHaveLength(1) + + const txtPath = result.files[0].replace(/\.html$/, '.txt') + expect(existsSync(txtPath)).toBe(true) + + const txt = readFileSync(txtPath, 'utf-8') + expect(txt).toContain('Hello') + expect(txt).toContain('World') + expect(txt).not.toContain('<div>') + }) + + it('outputs to custom path with plaintext as string', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Hello</div> + </template> + `) + + const customPath = join(tempDir, 'plaintext-output') + + const result = await build({ + config: { plaintext: customPath }, + }) + + expect(result.files).toHaveLength(1) + expect(existsSync(join(customPath, 'test.txt'))).toBe(true) + }) + + it('passes options to string-strip-html with plaintext as object', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Hello</div><br/>World + </template> + `) + + const result = await build({ + config: { plaintext: { ignoreTags: ['br'] } }, + }) + + const txtPath = result.files[0].replace(/\.html$/, '.txt') + const txt = readFileSync(txtPath, 'utf-8') + expect(txt).toContain('<br>') + }) + + it('does not generate .txt without plaintext config', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>Hello</div> + </template> + `) + + const result = await build() + + const txtPath = result.files[0].replace(/\.html$/, '.txt') + expect(existsSync(txtPath)).toBe(false) + }) + + it('generates plaintext via usePlaintext() in SFC', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <script setup> + usePlaintext() + </script> + <template> + <div>Hello from SFC</div> + </template> + `) + + const result = await build() + + const txtPath = result.files[0].replace(/\.html$/, '.txt') + expect(existsSync(txtPath)).toBe(true) + + const txt = readFileSync(txtPath, 'utf-8') + expect(txt).toContain('Hello from SFC') + expect(txt).not.toContain('<div>') + }) + + it('usePlaintext() with custom extension', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <script setup> + usePlaintext({ extension: 'text' }) + </script> + <template> + <div>Hello</div> + </template> + `) + + const result = await build() + + const textPath = result.files[0].replace(/\.html$/, '.text') + expect(existsSync(textPath)).toBe(true) + }) + + it('usePlaintext() with custom destination', async () => { + const customDest = join(tempDir, 'custom-txt') + + writeSfc(tempDir, 'emails/test.vue', ` + <script setup> + usePlaintext({ destination: '${customDest.replace(/\\/g, '\\\\')}' }) + </script> + <template> + <div>Hello</div> + </template> + `) + + await build() + + expect(existsSync(join(customDest, 'test.txt'))).toBe(true) + }) + + it('only template with usePlaintext() gets plaintext generated', async () => { + writeSfc(tempDir, 'emails/with-plaintext.vue', ` + <script setup> + usePlaintext() + </script> + <template> + <div>With Plaintext</div> + </template> + `) + + writeSfc(tempDir, 'emails/without-plaintext.vue', ` + <template> + <div>Without Plaintext</div> + </template> + `) + + const result = await build() + + const withTxt = result.files.find(f => f.includes('with-plaintext'))!.replace(/\.html$/, '.txt') + const withoutTxt = result.files.find(f => f.includes('without-plaintext'))!.replace(/\.html$/, '.txt') + + expect(existsSync(withTxt)).toBe(true) + expect(existsSync(withoutTxt)).toBe(false) + }) + }) + + it('copies static assets to output', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template><div>Test</div></template> + `) + + mkdirSync(join(tempDir, 'public/images'), { recursive: true }) + writeFileSync(join(tempDir, 'public/images/logo.png'), 'fake-png-data') + + const result = await build() + + // Static files should be copied somewhere in the output + expect(result.files).toHaveLength(1) // only template files in result + }) +}) diff --git a/src/tests/components/CodeBlock.test.ts b/src/tests/components/CodeBlock.test.ts new file mode 100644 index 00000000..6856c360 --- /dev/null +++ b/src/tests/components/CodeBlock.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h, Suspense } from 'vue' +import { renderToString } from '@vue/server-renderer' +import CodeBlock from '../../components/CodeBlock.vue' + +function render(props: Record<string, string> = {}, slotContent?: string) { + const app = createSSRApp({ + render: () => h(Suspense, null, { + default: () => h(CodeBlock, props, slotContent + ? { default: () => slotContent } + : undefined + ), + }), + }) + + return renderToString(app) +} + +describe('CodeBlock', () => { + describe('syntax highlighting', () => { + it('highlights HTML code with default settings', async () => { + const html = await render({ code: '<div>hello</div>' }) + + expect(html).toContain('<code>') + expect(html).toContain('style="color:') + }) + + it('highlights CSS code', async () => { + const html = await render({ code: '.button { color: red; }', lang: 'css' }) + + expect(html).toContain('<code>') + expect(html).toContain('style="color:') + }) + + it('highlights JavaScript code', async () => { + const html = await render({ code: 'const x = 42', lang: 'javascript' }) + + expect(html).toContain('<code>') + expect(html).toContain('style="color:') + }) + }) + + describe('structure', () => { + it('wraps in table > tr > td > pre > code', async () => { + const html = await render({ code: '<div>test</div>' }) + + expect(html).toMatch(/<table[^>]*>.*<tr>.*<td[^>]*>.*<pre[^>]*>.*<code>.*<\/code>.*<\/pre>.*<\/td>.*<\/tr>.*<\/table>/s) + }) + + it('adds w-full class to the wrapping table', async () => { + const html = await render({ code: '<div>test</div>' }) + + expect(html).toContain('<table class="w-full">') + }) + + it('adds font-mono class to the pre element', async () => { + const html = await render({ code: '<div>test</div>' }) + + expect(html).toMatch(/<pre class="font-mono"/) + }) + + it('sets inline styles on the pre element', async () => { + const html = await render({ code: '<div>test</div>' }) + + expect(html).toContain('background-color:#fff') + expect(html).toContain('padding:24px') + expect(html).toContain('overflow:auto') + expect(html).toContain('white-space:pre') + expect(html).toContain('word-wrap:normal') + expect(html).toContain('word-break:normal') + expect(html).toContain('word-spacing:normal') + }) + + it('uses default td-class', async () => { + const html = await render({ code: '<div>test</div>' }) + + expect(html).toContain('<td class="max-w-0 mso-padding-alt-6">') + }) + + it('accepts custom td-class', async () => { + const html = await render({ code: '<div>test</div>', 'td-class': 'custom-class' }) + + expect(html).toContain('<td class="custom-class">') + }) + }) + + describe('attrs forwarding', () => { + it('merges class onto the pre element', async () => { + const html = await render({ code: '<div>test</div>', class: 'p-6 rounded-lg' }) + + expect(html).toMatch(/<pre class="font-mono p-6 rounded-lg"/) + }) + + it('merges style onto the pre element', async () => { + const html = await render({ code: '<div>test</div>', style: 'border:1px solid red' }) + + expect(html).toContain('border:1px solid red') + }) + }) + + describe('encodedCode prop', () => { + it('decodes base64-encoded code', async () => { + const code = '<div>hello</div>' + const encoded = Buffer.from(code).toString('base64') + const html = await render({ 'encoded-code': encoded }) + + expect(html).toContain('<code>') + expect(html).toContain('style="color:') + }) + + it('prefers encodedCode over code prop', async () => { + const encoded = Buffer.from('.foo { color: red; }').toString('base64') + const html = await render({ 'encoded-code': encoded, code: 'ignored', lang: 'css' }) + + // Should highlight the CSS from encodedCode, not the 'ignored' string + expect(html).toContain('style="color:') + }) + }) + + describe('themes', () => { + it('uses github-light theme by default', async () => { + const html = await render({ code: '.foo { color: red; }', lang: 'css' }) + + expect(html).toContain('background-color:#fff') + }) + + it('supports dark themes', async () => { + const html = await render({ code: '.foo { color: red; }', lang: 'css', theme: 'github-dark' }) + + expect(html).toContain('background-color:#24292e') + }) + }) + + describe('empty content', () => { + it('renders nothing when code is empty', async () => { + const html = await render({ code: '' }) + + expect(html).not.toContain('<code>') + }) + + it('renders nothing when code is only whitespace', async () => { + const html = await render({ code: ' \n ' }) + + expect(html).not.toContain('<code>') + }) + }) +}) diff --git a/src/tests/components/CodeInline.test.ts b/src/tests/components/CodeInline.test.ts new file mode 100644 index 00000000..cf25f6e2 --- /dev/null +++ b/src/tests/components/CodeInline.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h, Suspense } from 'vue' +import { renderToString } from '@vue/server-renderer' +import CodeInline from '../../components/CodeInline.vue' + +function render(props: Record<string, string> = {}, slotContent?: string) { + const app = createSSRApp({ + render: () => h(Suspense, null, { + default: () => h(CodeInline, props, slotContent + ? { default: () => slotContent } + : undefined + ), + }), + }) + + return renderToString(app) +} + +describe('CodeInline', () => { + describe('rendering', () => { + it('renders code from prop', async () => { + const html = await render({ code: 'npm install' }) + + expect(html).toContain('<code') + expect(html).toContain('npm install') + }) + + it('renders code from slot', async () => { + const html = await render({}, 'npm install') + + expect(html).toContain('npm install') + }) + + it('prefers code prop over slot', async () => { + const html = await render({ code: 'from prop' }, 'from slot') + + expect(html).toContain('from prop') + expect(html).not.toContain('from slot') + }) + + it('renders nothing when empty', async () => { + const html = await render({ code: '' }) + + expect(html).not.toContain('<code') + }) + }) + + describe('escaping', () => { + it('escapes HTML entities', async () => { + const html = await render({ code: '<div class="foo">' }) + + expect(html).toContain('&lt;div class=&quot;foo&quot;&gt;') + expect(html).not.toContain('<div class="foo">') + }) + + it('escapes ampersands', async () => { + const html = await render({ code: 'a && b' }) + + expect(html).toContain('a &amp;&amp; b') + }) + }) + + describe('styles', () => { + it('applies default inline styles', async () => { + const html = await render({ code: 'test' }) + + expect(html).toContain('white-space:normal') + expect(html).toContain('border-radius:6px') + expect(html).toContain('border:1px solid #d1d5db') + expect(html).toContain('background-color:#f3f4f6') + expect(html).toContain('padding:2px 6px') + expect(html).toContain('font-size:11px') + expect(html).toContain('color:inherit') + }) + + it('merges custom style', async () => { + const html = await render({ code: 'test', style: 'font-weight:bold' }) + + expect(html).toContain('font-weight:bold') + expect(html).toContain('white-space:normal') + }) + }) + + describe('attrs forwarding', () => { + it('merges class onto the code element', async () => { + const html = await render({ code: 'test', class: 'font-mono' }) + + expect(html).toContain('class="font-mono"') + }) + }) +}) diff --git a/src/tests/components/Divider.test.ts b/src/tests/components/Divider.test.ts new file mode 100644 index 00000000..865ea94d --- /dev/null +++ b/src/tests/components/Divider.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import Divider from '../../components/Divider.vue' + +describe('Divider', () => { + describe('defaults', () => { + it('renders a div with role="separator"', () => { + const wrapper = mount(Divider) + expect(wrapper.html()).toContain('role="separator"') + }) + + it('uses 1px height by default', () => { + const wrapper = mount(Divider) + expect(wrapper.html()).toContain('height: 1px') + expect(wrapper.html()).toContain('line-height: 1px') + }) + + it('uses default background color', () => { + const wrapper = mount(Divider) + expect(wrapper.html()).toContain('background-color: #cbd5e1') + }) + + it('applies default spaceY margins', () => { + const wrapper = mount(Divider) + // happy-dom collapses margin-top/bottom into shorthand + expect(wrapper.html()).toContain('margin: 24px 0px') + }) + + it('contains zero-width joiner', () => { + const wrapper = mount(Divider) + expect(wrapper.text()).toContain('\u200D') + }) + }) + + describe('height prop', () => { + it('accepts a string value', () => { + const wrapper = mount(Divider, { props: { height: '2px' } }) + expect(wrapper.html()).toContain('height: 2px') + expect(wrapper.html()).toContain('line-height: 2px') + }) + + it('accepts a number and adds px suffix', () => { + const wrapper = mount(Divider, { props: { height: 3 } }) + expect(wrapper.html()).toContain('height: 3px') + expect(wrapper.html()).toContain('line-height: 3px') + }) + }) + + describe('color prop', () => { + it('overrides the default background color', () => { + const wrapper = mount(Divider, { props: { color: '#ff0000' } }) + expect(wrapper.html()).toContain('background-color: #ff0000') + expect(wrapper.html()).not.toContain('background-color: #cbd5e1') + }) + }) + + describe('spacing props', () => { + it('spaceY sets top and bottom margins', () => { + const wrapper = mount(Divider, { props: { spaceY: '16px' } }) + expect(wrapper.html()).toContain('margin: 16px 0px') + }) + + it('spaceY accepts a number', () => { + const wrapper = mount(Divider, { props: { spaceY: 10 } }) + expect(wrapper.html()).toContain('margin: 10px 0px') + }) + + it('spaceY of 0 outputs 0px', () => { + const wrapper = mount(Divider, { props: { spaceY: 0 } }) + expect(wrapper.html()).toContain('margin: 0px') + }) + + it('spaceX sets left and right margins', () => { + const wrapper = mount(Divider, { props: { spaceX: '32px' } }) + // spaceY default (24px) still applies alongside spaceX + expect(wrapper.html()).toContain('margin: 24px 32px') + }) + + it('spaceX of 0 outputs 0px for horizontal margins', () => { + const wrapper = mount(Divider, { props: { spaceX: 0 } }) + // spaceY default (24px) still applies + expect(wrapper.html()).toContain('margin: 24px 0px') + }) + }) + + describe('individual margin props', () => { + it('top sets margin-top', () => { + const wrapper = mount(Divider, { props: { top: '8px' } }) + expect(wrapper.html()).toContain('8px') + }) + + it('bottom sets margin-bottom', () => { + const wrapper = mount(Divider, { props: { bottom: '12px' } }) + expect(wrapper.html()).toContain('12px') + }) + + it('left sets margin-left', () => { + const wrapper = mount(Divider, { props: { left: '4px' } }) + expect(wrapper.html()).toContain('4px') + }) + + it('right sets margin-right', () => { + const wrapper = mount(Divider, { props: { right: '4px' } }) + expect(wrapper.html()).toContain('4px') + }) + + it('individual margins accept numbers', () => { + const wrapper = mount(Divider, { props: { top: 5, bottom: 10 } }) + const html = wrapper.html() + expect(html).toContain('5px') + expect(html).toContain('10px') + }) + + it('individual margin overrides spaceY', () => { + const wrapper = mount(Divider, { props: { spaceY: '24px', top: '8px' } }) + // margin shorthand: top=8px right=0 bottom=24px + expect(wrapper.html()).toContain('margin: 8px 0px 24px') + }) + }) + + describe('bg class detection', () => { + it('omits default background-color when a bg- class is present', () => { + const wrapper = mount(Divider, { attrs: { class: 'bg-red-500' } }) + expect(wrapper.html()).not.toContain('background-color: #cbd5e1') + }) + + it('applies default background-color when no bg- class is present', () => { + const wrapper = mount(Divider, { attrs: { class: 'text-red-500' } }) + expect(wrapper.html()).toContain('background-color: #cbd5e1') + }) + }) +}) diff --git a/src/tests/components/NoWidows.test.ts b/src/tests/components/NoWidows.test.ts new file mode 100644 index 00000000..d9254909 --- /dev/null +++ b/src/tests/components/NoWidows.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { h } from 'vue' +import NoWidows from '../../components/NoWidows.vue' + +describe('NoWidows', () => { + describe('basic widow prevention', () => { + it('replaces space before last word with non-breaking space for text with >= 4 words', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'This is a test sentence' + } + }) + expect(wrapper.html()).toContain('This is a test&nbsp;sentence') + }) + + it('does not modify text with fewer than minWords words', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'Hello world' + } + }) + expect(wrapper.html()).toContain('Hello world') + }) + + it('handles text with exactly minWords words', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'One two three four' + } + }) + expect(wrapper.html()).toContain('One two three&nbsp;four') + }) + }) + + describe('minWords prop', () => { + it('respects custom minWords value (string)', () => { + const wrapper = mount(NoWidows, { + props: { + minWords: '3' + }, + slots: { + default: 'Hello world there' + } + }) + expect(wrapper.html()).toContain('Hello world&nbsp;there') + }) + + it('respects custom minWords value (number)', () => { + const wrapper = mount(NoWidows, { + props: { + minWords: 3 + }, + slots: { + default: 'Hello world there' + } + }) + expect(wrapper.html()).toContain('Hello world&nbsp;there') + }) + + it('uses default minWords of 4 when not specified', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'One two three' + } + }) + expect(wrapper.html()).toContain('One two three') + }) + }) + + describe('template expression handling', () => { + it('skips known ignored templating delimiters', () => { + const wrapper = mount(NoWidows, { + slots: { + default: '{% Hello world there test %}' + } + }) + expect(wrapper.html()).toContain('{% Hello world there test %}') + }) + + it('processes text inside HTML elements', () => { + const wrapper = mount(NoWidows, { + slots: { + default: '<p>This is a test paragraph</p>' + } + }) + expect(wrapper.html()).toContain('<p>This is a test&nbsp;paragraph</p>') + }) + }) + + describe('nested elements', () => { + it('processes text in nested elements', () => { + const wrapper = mount(NoWidows, { + slots: { + default: '<div><span>This is a nested sentence</span></div>' + } + }) + expect(wrapper.html()).toContain('<div><span>This is a nested&nbsp;sentence</span></div>') + }) + + it('handles multiple elements', () => { + const wrapper = mount(NoWidows, { + slots: { + default: '<p>First paragraph text here</p><p>Second paragraph text here</p>' + } + }) + const html = wrapper.html() + expect(html).toContain('<p>First paragraph text&nbsp;here</p>') + expect(html).toContain('<p>Second paragraph text&nbsp;here</p>') + }) + }) + + describe('edge cases', () => { + it('handles empty slots', () => { + const wrapper = mount(NoWidows) + expect(wrapper.html()).toBe('') + }) + + it('handles text with trailing whitespace', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'This is a test sentence ' + } + }) + expect(wrapper.html()).toContain('This is a test&nbsp;sentence') + }) + + it('handles multiple spaces between words', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'This is a test sentence' + } + }) + expect(wrapper.html()).toContain('This is a test&nbsp;sentence') + }) + + it('handles single word', () => { + const wrapper = mount(NoWidows, { + slots: { + default: 'Hello' + } + }) + expect(wrapper.html()).toContain('Hello') + }) + + it('handles text with newlines', () => { + const wrapper = mount(NoWidows, { + slots: { + default: `This is a test\nsentence` + } + }) + expect(wrapper.html()).toContain('This is a test&nbsp;sentence') + }) + }) + + describe('component preservation', () => { + it('does not modify component vnodes', () => { + const TestComponent = { + name: 'TestComponent', + template: '<span>Component text here</span>' + } + + const wrapper = mount(NoWidows, { + slots: { + default: () => [h(TestComponent)] + } + }) + + expect(wrapper.findComponent(TestComponent).exists()).toBe(true) + }) + }) +}) diff --git a/src/tests/components/NotOutlook.test.ts b/src/tests/components/NotOutlook.test.ts new file mode 100644 index 00000000..238ca0c5 --- /dev/null +++ b/src/tests/components/NotOutlook.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import NotOutlook from '../../components/NotOutlook.vue' + +function render(slotFn?: () => any) { + const app = createSSRApp({ + render: () => h(NotOutlook, null, { + default: slotFn ?? (() => h('p', 'Test')), + }), + }) + return renderToString(app) +} + +describe('NotOutlook', () => { + describe('conditional comments', () => { + it('renders the opening non-Outlook conditional comment', async () => { + const html = await render() + expect(html).toContain('<!--[if !mso]><!-->') + }) + + it('renders the closing comment', async () => { + const html = await render() + expect(html).toContain('<!--<![endif]-->') + }) + + it('renders slot content between the comments', async () => { + const html = await render() + expect(html).toContain('<p>Test</p>') + }) + + it('outputs comments and content in the correct order', async () => { + const html = await render() + + const startIdx = html.indexOf('<!--[if !mso]><!-->') + const contentIdx = html.indexOf('<p>Test</p>') + const endIdx = html.indexOf('<!--<![endif]-->') + + expect(startIdx).toBeGreaterThanOrEqual(0) + expect(startIdx).toBeLessThan(contentIdx) + expect(contentIdx).toBeLessThan(endIdx) + }) + }) + + describe('slot content', () => { + it('renders nested HTML in the slot', async () => { + const html = await render(() => h('table', [h('tr', [h('td', 'Hello')])])) + expect(html).toContain('<table>') + expect(html).toContain('<td>Hello</td>') + }) + + it('renders with an empty slot', async () => { + const app = createSSRApp({ render: () => h(NotOutlook) }) + const html = await renderToString(app) + expect(html).toContain('<!--[if !mso]><!-->') + expect(html).toContain('<!--<![endif]-->') + }) + }) +}) diff --git a/src/tests/components/Outlook.test.ts b/src/tests/components/Outlook.test.ts new file mode 100644 index 00000000..a636aab0 --- /dev/null +++ b/src/tests/components/Outlook.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import Outlook from '../../components/Outlook.vue' + +function renderRaw(props: Record<string, string> = {}, slotFn?: () => any) { + const app = createSSRApp({ + render: () => h(Outlook, props, { + default: slotFn ?? (() => h('p', 'Test')), + }), + }) + + return renderToString(app) +} + +describe('Outlook', () => { + describe('default (all Outlook versions)', () => { + it('wraps slot content in mso conditional comments', async () => { + const html = await renderRaw() + + expect(html).toContain('<!--[if mso]>') + expect(html).toContain('<p>Test</p>') + expect(html).toContain('<![endif]-->') + }) + + it('outputs comments in the correct order', async () => { + const html = await renderRaw() + + const startIdx = html.indexOf('<!--[if mso]>') + const contentIdx = html.indexOf('<p>Test</p>') + const endIdx = html.indexOf('<![endif]-->') + + expect(startIdx).toBeGreaterThanOrEqual(0) + expect(startIdx).toBeLessThan(contentIdx) + expect(contentIdx).toBeLessThan(endIdx) + }) + }) + + describe('only prop', () => { + it('targets a single Outlook version', async () => { + const html = await renderRaw({ only: '2007' }) + expect(html).toContain('<!--[if mso 12]>') + }) + + it('targets multiple Outlook versions', async () => { + const html = await renderRaw({ only: '2007, 2010' }) + expect(html).toContain('<!--[if (mso 12)|(mso 14)]>') + }) + + it('maps all known versions correctly', async () => { + const versionMap: Record<string, string> = { + '2003': '11', + '2007': '12', + '2010': '14', + '2013': '15', + '2016': '16', + '2019': '16', + } + + for (const [year, mso] of Object.entries(versionMap)) { + const html = await renderRaw({ only: year }) + expect(html).toContain(`<!--[if mso ${mso}]>`) + } + }) + }) + + describe('not prop', () => { + it('excludes a single Outlook version', async () => { + const html = await renderRaw({ not: '2007' }) + expect(html).toContain('<!--[if !mso 12]>') + }) + + it('excludes multiple Outlook versions', async () => { + const html = await renderRaw({ not: '2007, 2010' }) + expect(html).toContain('<!--[if !(mso 12|mso 14)]>') + }) + }) + + describe('range props', () => { + it('handles lt (less than)', async () => { + const html = await renderRaw({ lt: '2010' }) + expect(html).toContain('<!--[if (lt mso 14)]>') + }) + + it('handles lte (less than or equal)', async () => { + const html = await renderRaw({ lte: '2010' }) + expect(html).toContain('<!--[if (lte mso 14)]>') + }) + + it('handles gt (greater than)', async () => { + const html = await renderRaw({ gt: '2007' }) + expect(html).toContain('<!--[if (gt mso 12)]>') + }) + + it('handles gte (greater than or equal)', async () => { + const html = await renderRaw({ gte: '2007' }) + expect(html).toContain('<!--[if (gte mso 12)]>') + }) + + it('combines gt and lt for a range', async () => { + const html = await renderRaw({ gt: '2003', lt: '2016' }) + + expect(html).toContain('(lt mso 16)') + expect(html).toContain('(gt mso 11)') + expect(html).toContain('&') + }) + + it('combines gte and lte for an inclusive range', async () => { + const html = await renderRaw({ gte: '2007', lte: '2013' }) + + expect(html).toContain('(lte mso 15)') + expect(html).toContain('(gte mso 12)') + expect(html).toContain('&') + }) + }) + + describe('closing comment', () => { + it('always outputs the endif closing comment', async () => { + const html = await renderRaw({ only: '2007' }) + expect(html).toContain('<![endif]-->') + }) + }) + + describe('empty slot', () => { + it('renders conditional comments with no content between them', async () => { + const app = createSSRApp({ + render: () => h(Outlook), + }) + const html = await renderToString(app) + + expect(html).toContain('<!--[if mso]>') + expect(html).toContain('<![endif]-->') + }) + }) +}) diff --git a/src/tests/components/Preview.test.ts b/src/tests/components/Preview.test.ts new file mode 100644 index 00000000..0228bd87 --- /dev/null +++ b/src/tests/components/Preview.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import Preview from '../../components/Preview.vue' + +function render(props: Record<string, any> = {}, slotContent?: string) { + const app = createSSRApp({ + render: () => h(Preview, props, slotContent + ? { default: () => slotContent } + : undefined + ), + }) + + const ctx: Record<string, any> = {} + return renderToString(app, ctx).then(() => { + // Teleported content is in ctx.teleports keyed by target selector + const teleported = Object.values(ctx.teleports ?? {}).join('') + return teleported + }) +} + +describe('Preview', () => { + describe('structure', () => { + it('renders a hidden div', async () => { + const html = await render() + + expect(html).toContain('display:none') + }) + }) + + describe('slot content', () => { + it('renders slot content as preview text', async () => { + const html = await render({}, 'Hello preview!') + + expect(html).toContain('Hello preview!') + }) + }) + + describe('filler entities', () => { + it('renders 150 filler pairs by default', async () => { + const html = await render() + + const fillerCount = (html.match(/\u2007\u034F/g) || []).length + expect(fillerCount).toBe(150) + }) + + it('accepts custom filler count', async () => { + const html = await render({ fillerCount: 5 }) + + const fillerCount = (html.match(/\u2007\u034F/g) || []).length + expect(fillerCount).toBe(5) + }) + + it('renders zero fillers when set to 0', async () => { + const html = await render({ fillerCount: 0 }) + + expect(html).not.toContain('\u2007\u034F') + }) + }) + + describe('shy entities', () => { + it('renders 150 shy entities by default', async () => { + const html = await render() + + const shyCount = (html.match(/\u00AD/g) || []).length + expect(shyCount).toBe(150) + }) + + it('accepts custom shy count', async () => { + const html = await render({ shyCount: 3 }) + + const shyCount = (html.match(/\u00AD/g) || []).length + expect(shyCount).toBe(3) + }) + }) + + describe('nbsp', () => { + it('ends with a non-breaking space before closing div', async () => { + const html = await render() + + expect(html).toContain('\u00A0</div>') + }) + }) +}) diff --git a/src/tests/components/Spacer.test.ts b/src/tests/components/Spacer.test.ts new file mode 100644 index 00000000..1da6feb3 --- /dev/null +++ b/src/tests/components/Spacer.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import Spacer from '../../components/Spacer.vue' + +describe('Spacer', () => { + describe('defaults', () => { + it('renders a div with role="separator"', () => { + const wrapper = mount(Spacer) + expect(wrapper.html()).toContain('role="separator"') + }) + + it('renders without style attribute when no height is set', () => { + const wrapper = mount(Spacer) + expect(wrapper.html()).not.toContain('line-height:') + }) + + it('contains zero-width joiner', () => { + const wrapper = mount(Spacer) + expect(wrapper.text()).toContain('\u200D') + }) + }) + + describe('size prop', () => { + it('sets line-height when provided as string', () => { + const wrapper = mount(Spacer, { props: { size: '32px' } }) + expect(wrapper.html()).toContain('line-height: 32px') + }) + + it('accepts a number and adds px suffix', () => { + const wrapper = mount(Spacer, { props: { size: 24 } }) + expect(wrapper.html()).toContain('line-height: 24px') + }) + + it('preserves non-numeric string values', () => { + const wrapper = mount(Spacer, { props: { size: '2rem' } }) + expect(wrapper.html()).toContain('line-height: 2rem') + }) + }) + + describe('msoHeight prop', () => { + it('sets mso-line-height-alt', () => { + const wrapper = mount(Spacer, { props: { size: '32px', msoHeight: '40px' } }) + expect(wrapper.html()).toContain('mso-line-height-alt: 40px') + }) + + it('accepts a number and adds px suffix', () => { + const wrapper = mount(Spacer, { props: { size: '32px', msoHeight: 48 } }) + expect(wrapper.html()).toContain('mso-line-height-alt: 48px') + }) + }) + + describe('conditional rendering', () => { + it('renders with style when size is provided', () => { + const wrapper = mount(Spacer, { props: { size: '16px' } }) + expect(wrapper.html()).toContain('style=') + }) + + it('renders without style when no size is provided', () => { + const wrapper = mount(Spacer) + expect(wrapper.html()).not.toContain('style=') + }) + }) +}) diff --git a/src/tests/components/Vml.test.ts b/src/tests/components/Vml.test.ts new file mode 100644 index 00000000..3e96806d --- /dev/null +++ b/src/tests/components/Vml.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import Vml from '../../components/Vml.vue' + +function render(props: Record<string, unknown> = {}, slotFn?: () => any) { + const app = createSSRApp({ + render: () => h(Vml, props, { + default: slotFn ?? (() => h('p', 'Test')), + }), + }) + + return renderToString(app) +} + +describe('Vml', () => { + describe('defaults', () => { + it('wraps slot content in VML conditional comments', async () => { + const html = await render() + + expect(html).toContain('<!--[if mso]>') + expect(html).toContain('<![endif]-->') + }) + + it('renders v:rect with default width of 600px', async () => { + const html = await render() + + expect(html).toContain('<v:rect') + expect(html).toContain('style="width: 600px;"') + }) + + it('uses default fill and stroke values', async () => { + const html = await render() + + expect(html).toContain('fill="t"') + expect(html).toContain('stroke="f"') + }) + + it('uses default fillcolor of none', async () => { + const html = await render() + + expect(html).toContain('fillcolor="none"') + }) + + it('uses default src placeholder', async () => { + const html = await render() + + expect(html).toContain('src="https://via.placeholder.com/600x400"') + }) + + it('uses default type of frame', async () => { + const html = await render() + + expect(html).toContain('type="frame"') + }) + + it('uses default inset of 0,0,0,0', async () => { + const html = await render() + + expect(html).toContain('inset="0,0,0,0"') + }) + + it('renders slot content between VML wrappers', async () => { + const html = await render() + + expect(html).toContain('<p>Test</p>') + }) + + it('includes v:fill and v:textbox elements', async () => { + const html = await render() + + expect(html).toContain('<v:fill') + expect(html).toContain('<v:textbox') + }) + + it('sets mso-fit-shape-to-text on textbox', async () => { + const html = await render() + + expect(html).toContain('mso-fit-shape-to-text: true') + }) + + it('includes vml namespace on v:rect', async () => { + const html = await render() + + expect(html).toContain('xmlns:v="urn:schemas-microsoft-com:vml"') + }) + }) + + describe('width prop', () => { + it('accepts a string value', async () => { + const html = await render({ width: '400px' }) + + expect(html).toContain('style="width: 400px;"') + }) + + it('accepts a number and adds px suffix', async () => { + const html = await render({ width: 500 }) + + expect(html).toContain('style="width: 500px;"') + }) + }) + + describe('height prop', () => { + it('does not include height by default', async () => { + const html = await render() + + expect(html).not.toContain('height:') + }) + + it('includes height when provided', async () => { + const html = await render({ height: '300px' }) + + expect(html).toContain('height: 300px;') + }) + + it('accepts a number and adds px suffix', async () => { + const html = await render({ height: 250 }) + + expect(html).toContain('height: 250px;') + }) + }) + + describe('fill props', () => { + it('type sets v:fill type', async () => { + const html = await render({ type: 'tile' }) + + expect(html).toContain('type="tile"') + }) + + it('src sets v:fill src', async () => { + const html = await render({ src: 'https://example.com/bg.jpg' }) + + expect(html).toContain('src="https://example.com/bg.jpg"') + }) + + it('fillcolor sets fillcolor on v:rect', async () => { + const html = await render({ fillcolor: '#ff0000' }) + + expect(html).toContain('fillcolor="#ff0000"') + }) + + it('color sets color on v:fill', async () => { + const html = await render({ color: '#0000ff' }) + + expect(html).toContain('color="#0000ff"') + }) + }) + + describe('stroke props', () => { + it('strokecolor enables stroke and sets color', async () => { + const html = await render({ strokecolor: '#333333' }) + + expect(html).toContain('stroke="t"') + expect(html).toContain('strokecolor="#333333"') + }) + }) + + describe('optional v:fill attributes', () => { + it('sets sizes when provided', async () => { + const html = await render({ sizes: '100%' }) + + expect(html).toContain('sizes="100%"') + }) + + it('sets aspect when provided', async () => { + const html = await render({ aspect: 'atleast' }) + + expect(html).toContain('aspect="atleast"') + }) + + it('sets origin when provided', async () => { + const html = await render({ origin: '0,0' }) + + expect(html).toContain('origin="0,0"') + }) + + it('sets position when provided', async () => { + const html = await render({ position: '0.5,0.5' }) + + expect(html).toContain('position="0.5,0.5"') + }) + + it('omits optional attributes when not provided', async () => { + const html = await render() + + expect(html).not.toContain('sizes=') + expect(html).not.toContain('aspect=') + expect(html).not.toContain('origin=') + expect(html).not.toContain('position=') + expect(html).not.toMatch(/\scolor="/) + }) + }) + + describe('inset prop', () => { + it('sets custom inset', async () => { + const html = await render({ inset: '10,20,10,20' }) + + expect(html).toContain('inset="10,20,10,20"') + }) + }) + + describe('closing comment', () => { + it('includes closing VML tags in correct order', async () => { + const html = await render() + + expect(html).toContain('</v:textbox></v:rect><![endif]-->') + }) + }) + + describe('structure', () => { + it('outputs VML elements in correct order', async () => { + const html = await render() + + const rectIdx = html.indexOf('<v:rect') + const fillIdx = html.indexOf('<v:fill') + const textboxIdx = html.indexOf('<v:textbox') + const contentIdx = html.indexOf('<p>Test</p>') + const closingIdx = html.indexOf('</v:textbox></v:rect>') + + expect(rectIdx).toBeLessThan(fillIdx) + expect(fillIdx).toBeLessThan(textboxIdx) + expect(textboxIdx).toBeLessThan(contentIdx) + expect(contentIdx).toBeLessThan(closingIdx) + }) + }) +}) diff --git a/src/tests/components/WithUrl.test.ts b/src/tests/components/WithUrl.test.ts new file mode 100644 index 00000000..7e350499 --- /dev/null +++ b/src/tests/components/WithUrl.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import WithUrl from '../../components/WithUrl.vue' + +describe('WithUrl', () => { + // ─── base prop (base URL) ────────────────────────────────────────────────── + + describe('base — img src', () => { + it('prepends base URL to img src', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('img', { src: 'image.jpg' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg') + }) + + it('does not modify absolute URLs', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('img', { src: 'https://other.com/image.jpg' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://other.com/image.jpg') + }) + + it('does not modify data URIs', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('img', { src: 'data:image/png;base64,abc123' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,abc123') + }) + + it('does not modify protocol-relative URLs', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('img', { src: '//other.com/image.jpg' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('//other.com/image.jpg') + }) + }) + + describe('base — anchor href', () => { + it('prepends base URL to anchor href', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://example.com/' }, + slots: { + default: () => h('a', { href: 'page.html' }, 'Link'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://example.com/page.html') + }) + + it('does not modify mailto: URLs', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://example.com/' }, + slots: { + default: () => h('a', { href: 'mailto:test@example.com' }, 'Email'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('mailto:test@example.com') + }) + + it('does not modify fragment URLs', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://example.com/' }, + slots: { + default: () => h('a', { href: '#section' }, 'Jump'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('#section') + }) + }) + + describe('base — srcset', () => { + it('prepends base URL to srcset values', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('img', { srcset: 'small.jpg 320w, large.jpg 1024w' }), + }, + }) + + expect(wrapper.find('img').attributes('srcset')).toBe( + 'https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1024w' + ) + }) + }) + + describe('base — other elements', () => { + it('prepends base URL to video src and poster', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('video', { src: 'video.mp4', poster: 'poster.jpg' }), + }, + }) + + expect(wrapper.find('video').attributes('src')).toBe('https://cdn.example.com/video.mp4') + expect(wrapper.find('video').attributes('poster')).toBe('https://cdn.example.com/poster.jpg') + }) + + it('prepends base URL to link href', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('link', { href: 'styles.css' }), + }, + }) + + expect(wrapper.find('link').attributes('href')).toBe('https://cdn.example.com/styles.css') + }) + }) + + describe('base — nested elements', () => { + it('processes deeply nested elements', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('div', [ + h('table', [ + h('tr', [ + h('td', [ + h('img', { src: 'deep.jpg' }), + ]), + ]), + ]), + ]), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/deep.jpg') + }) + + it('processes multiple elements at different depths', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('div', [ + h('img', { src: 'top.jpg' }), + h('div', [ + h('a', { href: 'page.html' }, 'Link'), + ]), + ]), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/top.jpg') + expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/page.html') + }) + }) + + describe('base — scoping', () => { + it('does not affect elements outside the component', () => { + const wrapper = mount({ + template: ` + <div> + <img src="outside.jpg"> + <WithUrl base="https://cdn.example.com/"> + <img src="inside.jpg"> + </WithUrl> + </div> + `, + components: { WithUrl }, + }) + + const images = wrapper.findAll('img') + expect(images[0].attributes('src')).toBe('outside.jpg') + expect(images[1].attributes('src')).toBe('https://cdn.example.com/inside.jpg') + }) + }) + + describe('base — elements not in tag map', () => { + it('does not modify attributes on unknown elements', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h('div', { 'data-src': 'file.txt' }), + }, + }) + + expect(wrapper.find('div').attributes('data-src')).toBe('file.txt') + }) + }) + + describe('base — child components', () => { + it('rewrites URL props on child components', () => { + const Button = defineComponent({ + props: { href: String }, + setup(props, { slots }) { + return () => h('a', { href: props.href }, slots.default?.()) + }, + }) + + const wrapper = mount(WithUrl, { + props: { base: 'https://example.com/' }, + slots: { + default: () => h(Button, { href: 'test' }, () => 'click me'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://example.com/test') + }) + + it('rewrites src prop on child components', () => { + const Image = defineComponent({ + props: { src: String, alt: String }, + setup(props) { + return () => h('img', { src: props.src, alt: props.alt }) + }, + }) + + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { + default: () => h(Image, { src: 'photo.jpg', alt: 'A photo' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/photo.jpg') + expect(wrapper.find('img').attributes('alt')).toBe('A photo') + }) + + it('does not rewrite absolute URLs on child components', () => { + const Button = defineComponent({ + props: { href: String }, + setup(props, { slots }) { + return () => h('a', { href: props.href }, slots.default?.()) + }, + }) + + const wrapper = mount(WithUrl, { + props: { base: 'https://example.com/' }, + slots: { + default: () => h(Button, { href: 'https://other.com/page' }, () => 'click'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://other.com/page') + }) + }) + + // ─── base — slash normalisation ────────────────────────────────────────── + + describe('base — slash normalisation', () => { + it('works when base has no trailing slash and path has no leading slash', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com' }, + slots: { default: () => h('img', { src: 'image.jpg' }) }, + }) + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg') + }) + + it('works when base has trailing slash and path has no leading slash', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { default: () => h('img', { src: 'image.jpg' }) }, + }) + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg') + }) + + it('works when base has no trailing slash and path has leading slash', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com' }, + slots: { default: () => h('a', { href: '/about' }, 'About') }, + }) + expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/about') + }) + + it('works when base has trailing slash and path has leading slash', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/' }, + slots: { default: () => h('a', { href: '/about' }, 'About') }, + }) + expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/about') + }) + + it('works with a base that has a path prefix', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/assets' }, + slots: { default: () => h('img', { src: 'image.jpg' }) }, + }) + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/assets/image.jpg') + }) + + it('works with a base that has a path prefix and trailing slash', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com/assets/' }, + slots: { default: () => h('img', { src: 'image.jpg' }) }, + }) + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/assets/image.jpg') + }) + + it('normalises slashes in srcset entries', () => { + const wrapper = mount(WithUrl, { + props: { base: 'https://cdn.example.com' }, + slots: { + default: () => h('img', { srcset: 'small.jpg 320w, large.jpg 1024w' }), + }, + }) + expect(wrapper.find('img').attributes('srcset')).toBe( + 'https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1024w' + ) + }) + }) + + // ─── parameters prop (query params) ────────────────────────────────────── + + describe('parameters — anchor href', () => { + it('appends query params to anchor href', () => { + const wrapper = mount(WithUrl, { + props: { parameters: 'utm_source=newsletter&utm_medium=email' }, + slots: { + default: () => h('a', { href: 'https://example.com/page' }, 'Link'), + }, + }) + + const href = wrapper.find('a').attributes('href')! + expect(href).toContain('https://example.com/page?') + expect(href).toContain('utm_source=newsletter') + expect(href).toContain('utm_medium=email') + }) + + it('appends query params to relative href', () => { + const wrapper = mount(WithUrl, { + props: { parameters: 'foo=bar' }, + slots: { + default: () => h('a', { href: '/about' }, 'About'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('/about?foo=bar') + }) + + it('merges query params when href already has params', () => { + const wrapper = mount(WithUrl, { + props: { parameters: 'utm_source=newsletter' }, + slots: { + default: () => h('a', { href: 'https://example.com/page?existing=1' }, 'Link'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe( + 'https://example.com/page?existing=1&utm_source=newsletter' + ) + }) + + it('does not modify fragment-only hrefs', () => { + const wrapper = mount(WithUrl, { + props: { parameters: 'utm_source=newsletter' }, + slots: { + default: () => h('a', { href: '#section' }, 'Jump'), + }, + }) + + // Fragment URLs are treated as absolute (isAbsoluteUrl returns true for #) + // so no base rewriting; but parameters can still be appended by query-string + const href = wrapper.find('a').attributes('href') + // query-string will produce '#section?utm_source=newsletter' or '#section' depending on behaviour + // The important thing is it doesn't crash; we just assert it contains #section + expect(href).toContain('#section') + }) + }) + + describe('parameters — img src', () => { + it('appends query params to img src', () => { + const wrapper = mount(WithUrl, { + props: { parameters: 'v=2' }, + slots: { + default: () => h('img', { src: 'https://cdn.example.com/image.jpg' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg?v=2') + }) + }) + + describe('parameters — child components', () => { + it('appends query params to URL props on child components', () => { + const Button = defineComponent({ + props: { href: String }, + setup(props, { slots }) { + return () => h('a', { href: props.href }, slots.default?.()) + }, + }) + + const wrapper = mount(WithUrl, { + props: { parameters: 'utm_source=foo' }, + slots: { + default: () => h(Button, { href: 'https://example.com/page' }, () => 'click me'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://example.com/page?utm_source=foo') + }) + }) + + // ─── both props together ────────────────────────────────────────────────── + + describe('base + parameters combined', () => { + it('prepends base URL then appends query params', () => { + const wrapper = mount(WithUrl, { + props: { + base: 'https://example.com/', + parameters: 'utm_source=newsletter', + }, + slots: { + default: () => h('a', { href: 'about' }, 'About'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe( + 'https://example.com/about?utm_source=newsletter' + ) + }) + + it('applies base then params to img src', () => { + const wrapper = mount(WithUrl, { + props: { + base: 'https://cdn.example.com/', + parameters: 'v=2', + }, + slots: { + default: () => h('img', { src: 'photo.jpg' }), + }, + }) + + expect(wrapper.find('img').attributes('src')).toBe( + 'https://cdn.example.com/photo.jpg?v=2' + ) + }) + + it('applies base then params to child component URL props', () => { + const Button = defineComponent({ + props: { href: String }, + setup(props, { slots }) { + return () => h('a', { href: props.href }, slots.default?.()) + }, + }) + + const wrapper = mount(WithUrl, { + props: { + base: 'https://example.com/', + parameters: 'utm_campaign=spring', + }, + slots: { + default: () => h(Button, { href: 'shop' }, () => 'Buy now'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe( + 'https://example.com/shop?utm_campaign=spring' + ) + }) + + it('does not double-prepend base on absolute URLs but still appends params', () => { + const wrapper = mount(WithUrl, { + props: { + base: 'https://example.com/', + parameters: 'ref=email', + }, + slots: { + default: () => h('a', { href: 'https://other.com/page' }, 'Link'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://other.com/page?ref=email') + }) + }) + + // ─── no props ───────────────────────────────────────────────────────────── + + describe('no props', () => { + it('renders children unchanged when no props are provided', () => { + const wrapper = mount(WithUrl, { + props: {}, + slots: { + default: () => h('a', { href: 'https://example.com/' }, 'Link'), + }, + }) + + expect(wrapper.find('a').attributes('href')).toBe('https://example.com/') + }) + }) +}) diff --git a/src/tests/composables/defineConfig.test.ts b/src/tests/composables/defineConfig.test.ts new file mode 100644 index 00000000..4e20f2c7 --- /dev/null +++ b/src/tests/composables/defineConfig.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect } from 'vitest' +import { defineComponent, h, inject } from 'vue' +import { mount } from '@vue/test-utils' +import { defineConfig } from '../../composables/defineConfig.ts' +import { MaizzleConfigKey } from '../../composables/useConfig.ts' +import { RenderContextKey, type RenderContext } from '../../composables/renderContext.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +function createRenderContext(): RenderContext { + return { doctype: undefined, sfcConfig: undefined, sfcEventHandlers: [] } +} + +describe('defineConfig', () => { + describe('outside Vue (config file usage)', () => { + it('returns the config as-is (same reference)', () => { + const input = { content: ['emails/**/*.vue'], output: { path: 'dist' } } + const result = defineConfig(input) + + expect(result).toBe(input) + }) + + it('returns empty object when called with no args', () => { + const result = defineConfig() + + expect(result).toEqual({}) + }) + + it('preserves all config properties', () => { + const input = { + content: ['src/**/*.vue'], + output: { path: 'build', extension: 'html' }, + css: { safe: true, shorthand: true }, + server: { port: 4000 }, + } + + const result = defineConfig(input) + + expect(result).toBe(input) + expect(result.content).toEqual(['src/**/*.vue']) + expect(result.output?.path).toBe('build') + expect(result.css?.safe).toBe(true) + }) + + it('preserves arbitrary user data', () => { + const input = { company: 'Acme', theme: { primary: '#ff0000' } } + const result = defineConfig(input) + + expect(result.company).toBe('Acme') + expect((result as any).theme).toEqual({ primary: '#ff0000' }) + }) + }) + + describe('inside Vue SFC', () => { + it('merges SFC config with injected global config', () => { + let merged: MaizzleConfig | undefined + + const Comp = defineComponent({ + setup() { + merged = defineConfig({ css: { sixHex: true } }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: { + content: ['emails/**/*.vue'], + css: { safe: true }, + } as MaizzleConfig, + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + expect(merged).toBeDefined() + expect(merged!.content).toEqual(['emails/**/*.vue']) + expect(merged!.css?.sixHex).toBe(true) + expect(merged!.css?.safe).toBe(true) + }) + + it('SFC values take priority over global config', () => { + let merged: MaizzleConfig | undefined + + const Comp = defineComponent({ + setup() { + merged = defineConfig({ + output: { path: 'sfc-output' }, + }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: { + output: { path: 'global-output', extension: 'html' }, + } as MaizzleConfig, + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + expect(merged!.output?.path).toBe('sfc-output') + // Global values preserved for keys not overridden + expect(merged!.output?.extension).toBe('html') + }) + + it('replaces arrays instead of merging them', () => { + let merged: MaizzleConfig | undefined + + const Comp = defineComponent({ + setup() { + merged = defineConfig({ + content: ['sfc/**/*.vue'], + }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: { + content: ['global/**/*.vue', 'shared/**/*.vue'], + } as MaizzleConfig, + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + // Should replace, not concatenate + expect(merged!.content).toEqual(['sfc/**/*.vue']) + }) + + it('deep merges nested objects', () => { + let merged: MaizzleConfig | undefined + + const Comp = defineComponent({ + setup() { + merged = defineConfig({ + css: { shorthand: true }, + }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: { + css: { + safe: true, + preferUnitless: true, + }, + } as MaizzleConfig, + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + expect(merged!.css?.shorthand).toBe(true) + expect(merged!.css?.safe).toBe(true) + expect(merged!.css?.preferUnitless).toBe(true) + }) + + it('stores merged config in render context', () => { + const ctx = createRenderContext() + + expect(ctx.sfcConfig).toBeUndefined() + + const Comp = defineComponent({ + setup() { + defineConfig({ output: { path: 'stored' } }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: {} as MaizzleConfig, + [RenderContextKey as symbol]: ctx, + }, + }, + }) + + expect(ctx.sfcConfig).toBeDefined() + expect(ctx.sfcConfig!.output?.path).toBe('stored') + }) + + it('provides merged config to child components', () => { + let childConfig: MaizzleConfig | undefined + + const Child = defineComponent({ + setup() { + childConfig = inject(MaizzleConfigKey) + return () => h('span') + }, + }) + + const Parent = defineComponent({ + setup() { + defineConfig({ + css: { sixHex: true }, + }) + return () => h(Child) + }, + }) + + mount(Parent, { + global: { + provide: { + [MaizzleConfigKey as symbol]: { + content: ['emails/**/*.vue'], + } as MaizzleConfig, + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + expect(childConfig).toBeDefined() + expect(childConfig!.css?.sixHex).toBe(true) + expect(childConfig!.content).toEqual(['emails/**/*.vue']) + }) + + it('works when no global config is injected', () => { + let merged: MaizzleConfig | undefined + + const Comp = defineComponent({ + setup() { + merged = defineConfig({ output: { path: 'no-global' } }) + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [RenderContextKey as symbol]: createRenderContext(), + }, + }, + }) + + expect(merged).toBeDefined() + expect(merged!.output?.path).toBe('no-global') + }) + }) + + describe('render context interaction', () => { + it('each defineConfig call in SFC context updates render context', () => { + const ctx1 = createRenderContext() + const ctx2 = createRenderContext() + + const First = defineComponent({ + setup() { + defineConfig({ output: { path: 'first' } }) + return () => h('div') + }, + }) + + const Second = defineComponent({ + setup() { + defineConfig({ output: { path: 'second' } }) + return () => h('div') + }, + }) + + mount(First, { + global: { + provide: { + [MaizzleConfigKey as symbol]: {} as MaizzleConfig, + [RenderContextKey as symbol]: ctx1, + }, + }, + }) + + expect(ctx1.sfcConfig!.output?.path).toBe('first') + + mount(Second, { + global: { + provide: { + [MaizzleConfigKey as symbol]: {} as MaizzleConfig, + [RenderContextKey as symbol]: ctx2, + }, + }, + }) + + expect(ctx2.sfcConfig!.output?.path).toBe('second') + // First context is unaffected + expect(ctx1.sfcConfig!.output?.path).toBe('first') + }) + }) +}) diff --git a/src/tests/composables/useConfig.test.ts b/src/tests/composables/useConfig.test.ts new file mode 100644 index 00000000..46e32df7 --- /dev/null +++ b/src/tests/composables/useConfig.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { defineComponent, h } from 'vue' +import { mount } from '@vue/test-utils' +import { useConfig, MaizzleConfigKey } from '../../composables/useConfig.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +describe('useConfig', () => { + it('returns the provided config', () => { + let result: MaizzleConfig | undefined + + const provided: MaizzleConfig = { + content: ['emails/**/*.vue'], + output: { path: 'dist' }, + css: { safe: true }, + } + + const Comp = defineComponent({ + setup() { + result = useConfig() + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: provided, + }, + }, + }) + + expect(result).toBe(provided) + }) + + it('returns the same reference as provided', () => { + let result: MaizzleConfig | undefined + + const provided = { content: ['src/**/*.vue'] } as MaizzleConfig + + const Comp = defineComponent({ + setup() { + result = useConfig() + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: provided, + }, + }, + }) + + expect(result).toBe(provided) + expect(result!.content).toEqual(['src/**/*.vue']) + }) + + it('throws when no config is provided', () => { + const Comp = defineComponent({ + setup() { + useConfig() + return () => h('div') + }, + }) + + expect(() => mount(Comp)).toThrow( + 'useConfig() requires the Maizzle plugin to provide config' + ) + }) + + it('receives config from a parent component', () => { + let childConfig: MaizzleConfig | undefined + + const provided: MaizzleConfig = { + output: { path: 'build', extension: 'html' }, + css: { shorthand: true }, + } + + const Child = defineComponent({ + setup() { + childConfig = useConfig() + return () => h('span') + }, + }) + + const Parent = defineComponent({ + setup() { + return () => h(Child) + }, + }) + + mount(Parent, { + global: { + provide: { + [MaizzleConfigKey as symbol]: provided, + }, + }, + }) + + expect(childConfig).toBe(provided) + }) + + it('preserves arbitrary user data on the config', () => { + let result: MaizzleConfig | undefined + + const provided = { + company: 'Acme', + theme: { primary: '#ff0000' }, + } as MaizzleConfig + + const Comp = defineComponent({ + setup() { + result = useConfig() + return () => h('div') + }, + }) + + mount(Comp, { + global: { + provide: { + [MaizzleConfigKey as symbol]: provided, + }, + }, + }) + + expect(result!.company).toBe('Acme') + expect((result as any).theme).toEqual({ primary: '#ff0000' }) + }) +}) diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts new file mode 100644 index 00000000..05c6c580 --- /dev/null +++ b/src/tests/config.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { resolveConfig } from '../config/index.ts' +import { defaults } from '../config/defaults.ts' + +describe('resolveConfig', () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'maizzle-test-')) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('returns defaults when no config file exists', async () => { + const config = await resolveConfig(undefined, tempDir) + + expect(config.content).toEqual(defaults.content!.map(p => resolve(tempDir, p).replace(/\\/g, '/'))) + expect(config.output?.path).toBe('dist') + expect(config.output?.extension).toBe('html') + expect(config.server?.port).toBe(3000) + expect(config.useTransformers).toBe(true) + expect(config.css?.preferUnitless).toBe(true) + expect(config.css?.resolveCalc).toBe(true) + expect(config.css?.resolveProps).toBe(true) + }) + + it('loads maizzle.config.js from cwd', async () => { + writeFileSync( + join(tempDir, 'maizzle.config.js'), + 'export default { content: ["src/**/*.vue"] }' + ) + + const config = await resolveConfig(undefined, tempDir) + + expect(config.content).toEqual([resolve(tempDir, 'src/**/*.vue').replace(/\\/g, '/')]) + // defaults are still applied for missing keys + expect(config.output?.path).toBe('dist') + }) + + it('loads maizzle.config.ts from cwd', async () => { + writeFileSync( + join(tempDir, 'maizzle.config.ts'), + 'export default { output: { path: "dist", extension: "htm" } }' + ) + + const config = await resolveConfig(undefined, tempDir) + + expect(config.output?.path).toBe('dist') + expect(config.output?.extension).toBe('htm') + // defaults for non-overridden keys + expect(config.content).toEqual(defaults.content!.map(p => resolve(tempDir, p).replace(/\\/g, '/'))) + }) + + it('prefers maizzle.config.ts over maizzle.config.js', async () => { + writeFileSync( + join(tempDir, 'maizzle.config.ts'), + 'export default { content: ["from-ts"] }' + ) + writeFileSync( + join(tempDir, 'maizzle.config.js'), + 'export default { content: ["from-js"] }' + ) + + const config = await resolveConfig(undefined, tempDir) + + expect(config.content).toEqual([resolve(tempDir, 'from-ts').replace(/\\/g, '/')]) + }) + + it('loads from explicit config path', async () => { + writeFileSync( + join(tempDir, 'custom.config.js'), + 'export default { content: ["custom/**/*.vue"] }' + ) + + const config = await resolveConfig('custom.config.js', tempDir) + + expect(config.content).toEqual([resolve(tempDir, 'custom/**/*.vue').replace(/\\/g, '/')]) + }) + + it('throws when explicit config path does not exist', async () => { + await expect( + resolveConfig('nonexistent.config.js', tempDir) + ).rejects.toThrow('Config file not found') + }) + + it('merges user config with defaults using defu', async () => { + writeFileSync( + join(tempDir, 'maizzle.config.js'), + 'export default { css: { sixHex: true }, server: { port: 4000 } }' + ) + + const config = await resolveConfig(undefined, tempDir) + + // User values + expect(config.css?.sixHex).toBe(true) + expect(config.server?.port).toBe(4000) + // Defaults preserved for unset nested keys + expect(config.css?.preferUnitless).toBe(true) + expect(config.server?.watch).toEqual([]) + }) + + it('resolves components.source string relative to cwd', async () => { + const config = await resolveConfig({ + root: 'project', + components: { source: 'src/shared' }, + }, tempDir) + + expect(config.components?.source).toEqual([resolve(tempDir, 'src/shared')]) + }) + + it('resolves components.source array relative to cwd', async () => { + const config = await resolveConfig({ + root: 'project', + components: { source: ['layouts', 'partials'] }, + }, tempDir) + + expect(config.components?.source).toEqual([ + resolve(tempDir, 'layouts'), + resolve(tempDir, 'partials'), + ]) + }) + + it('passes through arbitrary user data', async () => { + writeFileSync( + join(tempDir, 'maizzle.config.js'), + 'export default { foo: "bar", myData: { nested: true } }' + ) + + const config = await resolveConfig(undefined, tempDir) + + expect(config.foo).toBe('bar') + expect((config as any).myData).toEqual({ nested: true }) + }) +}) diff --git a/src/tests/plaintext.test.ts b/src/tests/plaintext.test.ts new file mode 100644 index 00000000..9c9804c3 --- /dev/null +++ b/src/tests/plaintext.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { createPlaintext } from '../plaintext.ts' + +describe('createPlaintext', () => { + it('strips HTML tags from simple HTML', () => { + const result = createPlaintext('<p>Hello World</p>') + expect(result).toBe('Hello World') + }) + + it('preserves text content', () => { + const result = createPlaintext('<div><h1>Title</h1><p>Some paragraph text here.</p></div>') + expect(result).toContain('Title') + expect(result).toContain('Some paragraph text here.') + }) + + it('handles empty input', () => { + const result = createPlaintext('') + expect(result).toBe('') + }) + + it('handles input with no HTML tags', () => { + const result = createPlaintext('Just plain text') + expect(result).toBe('Just plain text') + }) + + it('passes options through to string-strip-html', () => { + const result = createPlaintext( + '<div>Hello</div><br/>World', + { ignoreTags: ['br'] }, + ) + expect(result).toContain('<br/>') + }) + + it('strips a full email template', () => { + const html = `<!DOCTYPE html> +<html> +<head><title>Email</title><style>.red { color: red; }</style></head> +<body> + <table><tr><td> + <h1>Welcome</h1> + <p>Thank you for signing up.</p> + </td></tr></table> +</body> +</html>` + + const result = createPlaintext(html) + expect(result).toContain('Welcome') + expect(result).toContain('Thank you for signing up.') + expect(result).not.toContain('<table>') + expect(result).not.toContain('<html>') + expect(result).not.toContain('color: red') + }) +}) diff --git a/src/tests/render.test.ts b/src/tests/render.test.ts new file mode 100644 index 00000000..6b75154c --- /dev/null +++ b/src/tests/render.test.ts @@ -0,0 +1,813 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { defineComponent, h } from 'vue' +import { render } from '../render/index.ts' + +function createTempProject() { + const dir = mkdtempSync(join(tmpdir(), 'maizzle-render-')) + return dir +} + +function writeSfc(dir: string, path: string, content: string) { + const full = join(dir, path) + mkdirSync(join(dir, ...path.split('/').slice(0, -1)), { recursive: true }) + writeFileSync(full, content) +} + +describe('render', () => { + let tempDir: string + const originalCwd = process.cwd() + + beforeEach(() => { + tempDir = createTempProject() + process.chdir(tempDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe('raw SFC source', () => { + it('renders a simple template', async () => { + const result = await render(` + <template> + <div>Hello World</div> + </template> + `) + + expect(result.html).toContain('Hello World') + expect(result.html).toContain('<div>') + expect(result.html).not.toContain('<template>') + }) + + it('renders expressions', async () => { + const result = await render(` + <template> + <div>{{ 1 + 1 }}</div> + </template> + `) + + expect(result.html).toContain('2') + }) + + it('renders script setup variables', async () => { + const result = await render(` + <script setup> + const name = 'Maizzle' + </script> + <template> + <div>{{ name }}</div> + </template> + `) + + expect(result.html).toContain('<div>Maizzle</div>') + }) + }) + + describe('file path', () => { + it('renders a .vue file from disk', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <div>From File</div> + </template> + `) + + const result = await render(join(tempDir, 'emails/test.vue')) + + expect(result.html).toContain('From File') + }) + }) + + describe('return value', () => { + it('returns html and config', async () => { + const result = await render(` + <template> + <div>Test</div> + </template> + `) + + expect(result).toHaveProperty('html') + expect(result).toHaveProperty('config') + expect(typeof result.html).toBe('string') + expect(typeof result.config).toBe('object') + }) + + it('config contains defaults', async () => { + const result = await render(` + <template> + <div>Test</div> + </template> + `) + + expect(result.config.content).toEqual([resolve(tempDir, 'emails/**/*.{vue,md}').replace(/\\/g, '/')]) + expect(result.config.css?.inline).toBe(undefined) + }) + }) + + describe('doctype', () => { + it('prepends default doctype', async () => { + const result = await render(` + <template> + <div>Test</div> + </template> + `) + + expect(result.html).toMatch(/^<!DOCTYPE html>\n/) + }) + + it('uses custom doctype from config', async () => { + const result = await render(` + <template> + <div>Test</div> + </template> + `, { + config: { + doctype: '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">', + }, + }) + + expect(result.html).toMatch(/^<!DOCTYPE html PUBLIC/) + expect(result.html).not.toMatch(/^<!DOCTYPE html>\n/) + }) + + it('uses useDoctype() from SFC', async () => { + const result = await render(` + <script setup> + useDoctype('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">') + </script> + <template> + <div>Test</div> + </template> + `) + + expect(result.html).toMatch(/^<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD HTML 4\.01 Transitional\/\/EN">/) + }) + + it('useDoctype() takes priority over config doctype', async () => { + const result = await render(` + <script setup> + useDoctype('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">') + </script> + <template> + <div>Test</div> + </template> + `, { + config: { + doctype: '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0//EN">', + }, + }) + + expect(result.html).toMatch(/^<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD HTML 4\.01\/\/EN">/) + }) + }) + + describe('config', () => { + it('merges programmatic config with defaults', async () => { + const result = await render(` + <template> + <div>Test</div> + </template> + `, { + config: { + css: { resolveCalc: false }, + }, + }) + + expect(result.config.css?.resolveCalc).toBe(false) + // Defaults still present + expect(result.config.css?.preferUnitless).toBe(true) + }) + + it('uses template-level defineConfig()', async () => { + const result = await render(` + <script setup> + defineConfig({ + css: { inline: true }, + }) + </script> + <template> + <div>Test</div> + </template> + `) + + expect(result.config.css?.inline).toBe(true) + }) + + it('template defineConfig() merges on top of global config', async () => { + const result = await render(` + <script setup> + defineConfig({ + css: { shorthand: true }, + }) + </script> + <template> + <div>Test</div> + </template> + `, { + config: { + css: { inline: true }, + }, + }) + + expect(result.config.css?.shorthand).toBe(true) + expect(result.config.css?.inline).toBe(true) + }) + }) + + describe('transformers', () => { + it('compiles Tailwind CSS utilities', async () => { + symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules')) + + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <html> + <head> + <style>@import "tailwindcss/utilities" important;</style> + </head> + <body> + <div class="[border:1px_solid_red]">Test</div> + </body> + </html> + </template> + `) + + const result = await render(join(tempDir, 'emails/test.vue')) + + expect(result.html).toContain('border: 1px solid red') + }) + + it('skips transformers when useTransformers is false', async () => { + const result = await render(` + <script setup> + defineConfig({ + useTransformers: false, + }) + </script> + <template> + <html> + <head> + <style>.test { color: red; }</style> + </head> + <body> + <div class="test">Test</div> + </body> + </html> + </template> + `) + + // CSS should remain in <style>, not inlined + expect(result.html).toContain('<div class="test">Test</div>') + }) + + it('inlines CSS when enabled', async () => { + symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules')) + + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <html> + <head> + <style>.greeting { color: red; }</style> + </head> + <body> + <div class="greeting">Hello</div> + </body> + </html> + </template> + `) + + const result = await render(join(tempDir, 'emails/test.vue'), { + config: { + css: { inline: true }, + }, + }) + + expect(result.html).toContain('style="color: red;"') + }) + + it('does not inline CSS by default', async () => { + symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules')) + + writeSfc(tempDir, 'emails/test.vue', ` + <template> + <html> + <head> + <style>.greeting { color: red; }</style> + </head> + <body> + <div class="greeting">Hello</div> + </body> + </html> + </template> + `) + + const result = await render(join(tempDir, 'emails/test.vue')) + + expect(result.html).not.toContain('style="color: red"') + }) + + it('replaces strings', async () => { + const result = await render(` + <template> + <div>Hello {name}</div> + </template> + `, { + config: { + replaceStrings: { '{name}': 'World' }, + }, + }) + + expect(result.html).toContain('Hello World') + expect(result.html).not.toContain('{name}') + }) + }) + + describe('Vue features', () => { + it('renders v-for loops', async () => { + const result = await render(` + <script setup> + const items = ['one', 'two', 'three'] + </script> + <template> + <ul> + <li v-for="item in items" :key="item">{{ item }}</li> + </ul> + </template> + `) + + expect(result.html).toContain('one') + expect(result.html).toContain('two') + expect(result.html).toContain('three') + }) + + it('renders slots with default content', async () => { + const result = await render(` + <template> + <div> + <slot>Default Content</slot> + </div> + </template> + `) + + expect(result.html).toContain('Default Content') + }) + }) + + describe('edge cases', () => { + it('handles empty template', async () => { + const result = await render(` + <template> + <div></div> + </template> + `) + + expect(result.html).toContain('<div></div>') + expect(result.html).not.toContain('<template>') + }) + + it('handles template with only text', async () => { + const result = await render(` + <template> + Just text + </template> + `) + + expect(result.html).toContain('Just text') + expect(result.html).not.toContain('<template>') + }) + + it('strips Vue SSR comments', async () => { + const result = await render(` + <script setup> + const items = ['a', 'b'] + </script> + <template> + <div> + <span v-for="item in items" :key="item">{{ item }}</span> + </div> + </template> + `) + + expect(result.html).not.toContain('<!--[-->') + expect(result.html).not.toContain('<!--]-->') + }) + + it('handles multiple root elements', async () => { + const result = await render(` + <template> + <div>First</div> + <div>Second</div> + </template> + `) + + expect(result.html).toContain('<div>First</div>') + expect(result.html).toContain('<div>Second</div>') + }) + }) + + describe('teleport', () => { + it('teleports content to head', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="head"> + <style>h1 { color: red }</style> + </Teleport> + <h1>Hello</h1> + </body> + </html> + </template> + `) + + // Vue SSR places teleported content in the target with anchor comments, + // which are stripped by the transformer pipeline + expect(result.html).toContain('color: red') + expect(result.html).toContain('<h1>Hello</h1>') + // No teleport anchor comments in output + expect(result.html).not.toContain('<!--teleport') + }) + + it('teleports content to body (appends by default)', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="body"> + <img src="tracking.gif"> + </Teleport> + <h1>Hello</h1> + </body> + </html> + </template> + `) + + expect(result.html).toContain('<h1>Hello</h1>') + expect(result.html).toContain('src="tracking.gif"') + // Teleported img should be after h1 (appended to body) + const bodyContent = result.html.match(/<body>([\s\S]*?)<\/body>/)?.[1] ?? '' + expect(bodyContent.indexOf('<h1>')).toBeLessThan(bodyContent.indexOf('tracking.gif')) + }) + + it('teleports to body:start prepends content', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="body:start"> + <div class="preheader">Preview text</div> + </Teleport> + <h1>Hello</h1> + </body> + </html> + </template> + `) + + expect(result.html).toContain('Preview text') + // Preheader should come before h1 in body + const bodyContent = result.html.match(/<body>([\s\S]*?)<\/body>/)?.[1] ?? '' + expect(bodyContent.indexOf('preheader')).toBeLessThan(bodyContent.indexOf('<h1>')) + }) + + it('teleports to element by id', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="#footer"> + <p>Footer content</p> + </Teleport> + <h1>Hello</h1> + <div id="footer"></div> + </body> + </html> + </template> + `) + + expect(result.html).toContain('Footer content') + expect(result.html).toContain('id="footer"') + // Content should be inside the footer div + expect(result.html).toMatch(/<div id="footer">.*Footer content.*<\/div>/) + }) + + it('teleports to element by class', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to=".sidebar"> + <p>Sidebar content</p> + </Teleport> + <h1>Hello</h1> + <div class="sidebar"></div> + </body> + </html> + </template> + `) + + expect(result.html).toContain('Sidebar content') + expect(result.html).toMatch(/<div class="sidebar">.*Sidebar content.*<\/div>/) + }) + + it('supports :start for arbitrary targets', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="#wrapper:start"> + <div class="preheader">Preview</div> + </Teleport> + <div id="wrapper"> + <h1>Hello</h1> + </div> + </body> + </html> + </template> + `) + + // Preheader should be inside wrapper, before h1 + const wrapper = result.html.match(/<div id="wrapper">([\s\S]*)<\/div>/)?.[1] ?? '' + expect(wrapper).toContain('Preview') + expect(wrapper.indexOf('preheader')).toBeLessThan(wrapper.indexOf('<h1>')) + }) + + it('strips all teleport anchor comments', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <Teleport to="head"> + <meta name="test" content="value"> + </Teleport> + <h1>Hello</h1> + </body> + </html> + </template> + `) + + expect(result.html).not.toContain('<!--teleport start anchor-->') + expect(result.html).not.toContain('<!--teleport anchor-->') + expect(result.html).not.toContain('<!--teleport start-->') + expect(result.html).not.toContain('<!--teleport end-->') + }) + }) + + describe('self-closing tags', () => { + it('omits trailing slash for HTML5 doctype (default)', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <br> + <img src="test.jpg"> + </body> + </html> + </template> + `) + + expect(result.html).toContain('<br>') + expect(result.html).not.toContain('<br />') + expect(result.html).not.toContain('/>') + }) + + it('adds trailing slash for XHTML doctype', async () => { + const result = await render(` + <script setup> + useDoctype('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">') + </script> + <template> + <html> + <head></head> + <body> + <br> + <img src="test.jpg"> + </body> + </html> + </template> + `) + + expect(result.html).toContain('<br />') + expect(result.html).toMatch(/<img [^>]*\/>/) + }) + + it('adds trailing slash for XHTML doctype from config', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <hr> + </body> + </html> + </template> + `, { + config: { + doctype: '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">', + }, + }) + + expect(result.html).toContain('<hr />') + }) + + it('preserves trailing slash for XHTML even with format enabled', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <br> + <img src="test.jpg"> + </body> + </html> + </template> + `, { + config: { + doctype: '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">', + html: { format: true }, + }, + }) + + expect(result.html).toContain('<br />') + expect(result.html).toMatch(/<img [^>]*\/>/) + }) + + it('strips trailing slash for HTML5 even with format enabled', async () => { + const result = await render(` + <template> + <html> + <head></head> + <body> + <br> + <img src="test.jpg"> + </body> + </html> + </template> + `, { + config: { + html: { format: true }, + }, + }) + + expect(result.html).not.toContain('/>') + }) + }) + + describe('components.source', () => { + it('auto-imports components from custom dirs', async () => { + writeSfc(tempDir, 'custom-components/MyButton.vue', ` + <template> + <a href="#">Click me</a> + </template> + `) + + const result = await render(` + <template> + <div><MyButton /></div> + </template> + `, { + config: { + root: tempDir, + components: { source: ['custom-components'] }, + }, + }) + + expect(result.html).toContain('Click me') + expect(result.html).toContain('<a href="#">') + }) + }) + + describe('plaintext', () => { + it('returns plaintext when config.plaintext is true', async () => { + const result = await render(` + <template> + <div><h1>Hello</h1><p>World</p></div> + </template> + `, { + config: { plaintext: true }, + }) + + expect(result.plaintext).toBeDefined() + expect(result.plaintext).toContain('Hello') + expect(result.plaintext).toContain('World') + expect(result.plaintext).not.toContain('<div>') + }) + + it('does not return plaintext by default', async () => { + const result = await render(` + <template> + <div>Hello</div> + </template> + `) + + expect(result.plaintext).toBeUndefined() + }) + + it('returns plaintext when usePlaintext() is called in SFC', async () => { + const result = await render(` + <script setup> + usePlaintext() + </script> + <template> + <div>Hello from SFC</div> + </template> + `) + + expect(result.plaintext).toBeDefined() + expect(result.plaintext).toContain('Hello from SFC') + expect(result.plaintext).not.toContain('<div>') + }) + + it('passes strip options from config object', async () => { + const result = await render(` + <template> + <div>Hello</div><br/>World + </template> + `, { + config: { plaintext: { ignoreTags: ['br'] } }, + }) + + expect(result.plaintext).toContain('<br>') + }) + }) + + describe('component input', () => { + it('renders an imported Vue component', async () => { + const EmailComponent = defineComponent({ + render() { + return h('html', [ + h('head'), + h('body', [ + h('div', 'Hello from component'), + ]), + ]) + }, + }) + + const result = await render(EmailComponent) + + expect(result.html).toContain('<div>Hello from component</div>') + }) + + it('applies transformers to component output', async () => { + const EmailComponent = defineComponent({ + render() { + return h('html', [ + h('head'), + h('body', [ + h('div', { style: 'padding: 10px 20px 10px 20px' }, 'Test'), + ]), + ]) + }, + }) + + const result = await render(EmailComponent, { + config: { css: { shorthand: true } }, + }) + + expect(result.html).toContain('padding: 10px 20px') + }) + + it('compiles Tailwind CSS utilities', async () => { + symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules')) + + const EmailComponent = defineComponent({ + render() { + return h('html', [ + h('head', [ + h('style', '@import "tailwindcss/utilities" important;'), + ]), + h('body', [ + h('div', { class: '[color:red]' }, 'Test'), + ]), + ]) + }, + }) + + const result = await render(EmailComponent) + + expect(result.html).toContain('color: red') + }) + + it('prepends doctype', async () => { + const EmailComponent = defineComponent({ + render() { + return h('html', [h('head'), h('body')]) + }, + }) + + const result = await render(EmailComponent) + + expect(result.html.startsWith('<!DOCTYPE html>')).toBe(true) + }) + }) +}) diff --git a/src/tests/transformers/addAttributes.test.ts b/src/tests/transformers/addAttributes.test.ts new file mode 100644 index 00000000..c4344184 --- /dev/null +++ b/src/tests/transformers/addAttributes.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from 'vitest' +import { addAttributes } from '../../transformers/addAttributes.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { AttributesConfig } from '../../types/config.ts' + +function run(html: string, add?: false | Record<string, Record<string, string | boolean | number>>): string { + return serialize(addAttributes(parse(html), { add } satisfies AttributesConfig)) +} + +describe('addAttributes', () => { + describe('default behavior', () => { + it('adds default attributes to table elements', () => { + const result = run('<table><tr><td>Test</td></tr></table>') + expect(result).toBe('<table cellpadding="0" cellspacing="0" role="none"><tr><td>Test</td></tr></table>') + }) + + it('adds default alt attribute to img elements', () => { + const result = run('<img src="test.jpg">') + expect(result).toBe('<img src="test.jpg" alt>') + }) + + it('applies defaults when config is undefined', () => { + const result = serialize(addAttributes(parse('<table><tr><td></td></tr></table>'), {})) + expect(result).toBe('<table cellpadding="0" cellspacing="0" role="none"><tr><td></td></tr></table>') + }) + + it('does not overwrite existing attributes', () => { + const result = run('<table cellpadding="10"><tr><td></td></tr></table>') + expect(result).toBe('<table cellpadding="10" cellspacing="0" role="none"><tr><td></td></tr></table>') + }) + + it('does not overwrite existing alt on img', () => { + const result = run('<img src="test.jpg" alt="A photo">') + expect(result).toBe('<img src="test.jpg" alt="A photo">') + }) + + it('handles multiple table and img elements', () => { + const html = '<table><tr><td></td></tr></table><img src="a.jpg"><table><tr><td></td></tr></table>' + const result = run(html) + expect(result).toContain('cellpadding="0"') + expect(result).toContain(' alt>') + expect(result.match(/role="none"/g)).toHaveLength(2) + }) + }) + + describe('config: disabled', () => { + it('returns HTML unchanged when set to false', () => { + const html = '<table><tr><td>Test</td></tr></table>' + const result = run(html, false) + expect(result).toBe(html) + }) + + it('does not add img alt when disabled', () => { + const html = '<img src="test.jpg">' + const result = run(html, false) + expect(result).toBe(html) + }) + }) + + describe('works with user config and defaults', () => { + it('user attributes for same selector merge with defaults', () => { + const result = run('<table><tr><td></td></tr></table>', { + table: { border: 0 }, + }) + expect(result).toContain('cellpadding="0"') + expect(result).toContain('cellspacing="0"') + expect(result).toContain('role="none"') + expect(result).toContain('border="0"') + }) + + it('user value wins over default for same attribute', () => { + const result = run('<table><tr><td></td></tr></table>', { + table: { role: 'presentation' }, + }) + expect(result).toContain('role="presentation"') + expect(result).toContain('cellpadding="0"') + expect(result).toContain('cellspacing="0"') + }) + + it('user can add new selectors while keeping defaults', () => { + const result = run('<table><tr><td></td></tr></table><div>Test</div>', { + div: { role: 'article' }, + }) + expect(result).toContain('cellpadding="0"') + expect(result).toContain('role="article"') + }) + + it('user img attributes merge with default alt', () => { + const result = run('<img src="test.jpg">', { + img: { loading: 'lazy' }, + }) + expect(result).toContain(' alt') + expect(result).toContain('loading="lazy"') + }) + }) + + describe('tag selectors', () => { + it('matches by tag name', () => { + const result = run('<div>Test</div>', { + div: { role: 'article' }, + }) + expect(result).toBe('<div role="article">Test</div>') + }) + + it('matches multiple elements of same tag', () => { + const result = run('<p>One</p><p>Two</p>', { + p: { class: 'text' }, + }) + expect(result).toBe('<p class="text">One</p><p class="text">Two</p>') + }) + }) + + describe('class selectors', () => { + it('matches elements by class name', () => { + const result = run('<div class="test">Content</div><div>Other</div>', { + '.test': { 'data-id': 'matched' }, + }) + expect(result).toContain('data-id="matched"') + expect(result).toBe('<div class="test" data-id="matched">Content</div><div>Other</div>') + }) + + it('matches when element has multiple classes', () => { + const result = run('<div class="foo bar">Content</div>', { + '.bar': { 'data-matched': 'true' }, + }) + expect(result).toContain('data-matched="true"') + }) + }) + + describe('id selectors', () => { + it('matches elements by id', () => { + const result = run('<div id="header">Content</div><div>Other</div>', { + '#header': { role: 'banner' }, + }) + expect(result).toBe('<div id="header" role="banner">Content</div><div>Other</div>') + }) + + it('does not match different id', () => { + const html = '<div id="footer">Content</div>' + const result = run(html, { + '#header': { role: 'banner' }, + }) + expect(result).toBe(html) + }) + }) + + describe('attribute selectors', () => { + it('matches by attribute existence [attr]', () => { + const result = run('<div data-test>Content</div><div>Other</div>', { + '[data-test]': { class: 'matched' }, + }) + expect(result).toContain('class="matched"') + }) + + it('matches by attribute value [attr=value]', () => { + const result = run('<div role="alert">Warning</div><div role="status">Info</div>', { + '[role=alert]': { class: 'alert-box' }, + }) + expect(result).toBe('<div role="alert" class="alert-box">Warning</div><div role="status">Info</div>') + }) + + it('matches tag with attribute selector', () => { + const result = run('<div role="alert">Div</div><span role="alert">Span</span>', { + 'div[role=alert]': { class: 'danger' }, + }) + expect(result).toBe('<div role="alert" class="danger">Div</div><span role="alert">Span</span>') + }) + + it('matches tag with attribute existence', () => { + const result = run('<input type="text" required><input type="text">', { + 'input[required]': { class: 'required-field' }, + }) + expect(result).toContain('class="required-field"') + // Only the first input should have the class + expect(result.match(/required-field/g)).toHaveLength(1) + }) + }) + + describe('comma-separated selectors', () => { + it('matches multiple selectors', () => { + const result = run('<div>One</div><p>Two</p><span>Three</span>', { + 'div, p': { class: 'content' }, + }) + expect(result).toBe('<div class="content">One</div><p class="content">Two</p><span>Three</span>') + }) + + it('handles whitespace around commas', () => { + const result = run('<div>One</div><p>Two</p>', { + 'div , p': { 'data-type': 'text' }, + }) + expect(result).toContain('<div data-type="text">') + expect(result).toContain('<p data-type="text">') + }) + }) + + describe('class attribute merging', () => { + it('merges new classes with existing classes', () => { + const result = run('<div class="existing">Content</div>', { + div: { class: 'added' }, + }) + expect(result).toBe('<div class="existing added">Content</div>') + }) + + it('does not duplicate existing classes', () => { + const result = run('<div class="foo bar">Content</div>', { + div: { class: 'foo baz' }, + }) + expect(result).toBe('<div class="foo bar baz">Content</div>') + }) + + it('adds class when element has no class attribute', () => { + const result = run('<div>Content</div>', { + div: { class: 'new-class' }, + }) + expect(result).toBe('<div class="new-class">Content</div>') + }) + + it('handles multiple space-separated classes in config', () => { + const result = run('<div class="a">Content</div>', { + div: { class: 'b c d' }, + }) + expect(result).toBe('<div class="a b c d">Content</div>') + }) + }) + + describe('attribute value types', () => { + it('handles boolean true values', () => { + const result = run('<div>Content</div>', { + div: { hidden: true }, + }) + expect(result).toBe('<div hidden="true">Content</div>') + }) + + it('handles number values', () => { + const result = run('<td>Content</td>', { + td: { colspan: 2 }, + }) + expect(result).toBe('<td colspan="2">Content</td>') + }) + + it('handles string values', () => { + const result = run('<a>Link</a>', { + a: { target: '_blank' }, + }) + expect(result).toBe('<a target="_blank">Link</a>') + }) + }) + + describe('nested elements', () => { + it('processes nested matching elements', () => { + const result = run('<table><tr><td><table><tr><td></td></tr></table></td></tr></table>') + // Both tables should get default attributes + expect(result.match(/cellpadding="0"/g)).toHaveLength(2) + }) + + it('handles deeply nested targets', () => { + const result = run('<div><span><img src="deep.jpg"></span></div>') + expect(result).toContain(' alt>') + }) + }) + + describe('short-circuit', () => { + it('returns original string when no elements match', () => { + const html = '<span>No tables or images here</span>' + const result = run(html) + expect(result).toBe(html) + }) + + it('returns original string when all attributes already exist', () => { + const html = '<table cellpadding="5" cellspacing="5" role="grid"><tr><td></td></tr></table>' + const result = run(html) + expect(result).toBe(html) + }) + + it('returns original string when empty config and no defaults match', () => { + const html = '<div>Content</div>' + const result = serialize(addAttributes(parse(html), {})) + expect(result).toBe(html) + }) + }) + + describe('edge cases', () => { + it('handles empty HTML', () => { + const result = run('') + expect(result).toBe('') + }) + + it('handles HTML with no matching elements', () => { + const html = '<div>Just text</div>' + const result = run(html) + expect(result).toBe(html) + }) + + it('handles self-closing elements', () => { + const result = run('<br><hr>', { + br: { class: 'break' }, + }) + expect(result).toContain('<br class="break"><hr>') + }) + + it('preserves existing attributes when adding new ones', () => { + const result = run('<div id="main" data-existing="yes">Content</div>', { + div: { class: 'added' }, + }) + expect(result).toContain('id="main"') + expect(result).toContain('data-existing="yes"') + expect(result).toContain('class="added"') + }) + }) +}) diff --git a/src/tests/transformers/attributeToStyle.test.ts b/src/tests/transformers/attributeToStyle.test.ts new file mode 100644 index 00000000..ea891152 --- /dev/null +++ b/src/tests/transformers/attributeToStyle.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest' +import { attributeToStyle as attributeToStyleTransformer } from '../../transformers/attributeToStyle.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { CssConfig } from '../../types/config.ts' + +function run(html: string, attributeToStyle?: boolean | string[]): string { + return serialize(attributeToStyleTransformer(parse(html), { + inline: { attributeToStyle }, + } satisfies CssConfig)) +} + +describe('attributeToStyle', () => { + describe('config: css.inline.attributeToStyle', () => { + it('returns unchanged when attributeToStyle is not set', () => { + const html = '<table width="100%"></table>' + expect(serialize(attributeToStyleTransformer(parse(html), {}))).toBe(html) + }) + + it('returns unchanged when attributeToStyle is false', () => { + const html = '<table width="100%"></table>' + expect(run(html, false)).toBe(html) + }) + + it('returns unchanged when attributeToStyle is an empty array', () => { + const html = '<table width="100%"></table>' + expect(run(html, [])).toBe(html) + }) + + it('processes all default attributes when attributeToStyle is true', () => { + const html = '<table width="100%" height="200" bgcolor="#fff" background="image.jpg" align="center" valign="top"></table>' + const result = run(html, true) + expect(result).toContain('style="width: 100%; height: 200px; background-color: #fff; background-image: url(\'image.jpg\'); margin-left: auto; margin-right: auto; vertical-align: top"') + }) + }) + + describe('width attribute', () => { + it('converts width attribute to style with percentage value', () => { + const html = '<table width="100%"></table>' + const result = run(html, ['width']) + expect(result).toBe('<table width="100%" style="width: 100%"></table>') + }) + + it('converts width attribute to style with px value', () => { + const html = '<table width="600"></table>' + const result = run(html, ['width']) + expect(result).toBe('<table width="600" style="width: 600px"></table>') + }) + + it('preserves existing px unit', () => { + const html = '<table width="600px"></table>' + const result = run(html, ['width']) + expect(result).toBe('<table width="600px" style="width: 600px"></table>') + }) + }) + + describe('height attribute', () => { + it('converts height attribute to style with percentage value', () => { + const html = '<table height="50%"></table>' + const result = run(html, ['height']) + expect(result).toBe('<table height="50%" style="height: 50%"></table>') + }) + + it('converts height attribute to style with px value', () => { + const html = '<table height="200"></table>' + const result = run(html, ['height']) + expect(result).toBe('<table height="200" style="height: 200px"></table>') + }) + }) + + describe('bgcolor attribute', () => { + it('converts bgcolor to background-color', () => { + const html = '<table bgcolor="#ffffff"></table>' + const result = run(html, ['bgcolor']) + expect(result).toBe('<table bgcolor="#ffffff" style="background-color: #ffffff"></table>') + }) + + it('converts bgcolor with named color', () => { + const html = '<td bgcolor="red"></td>' + const result = run(html, ['bgcolor']) + expect(result).toBe('<td bgcolor="red" style="background-color: red"></td>') + }) + }) + + describe('background attribute', () => { + it('converts background to background-image', () => { + const html = '<td background="image.jpg"></td>' + const result = run(html, ['background']) + expect(result).toBe(`<td background="image.jpg" style="background-image: url('image.jpg')"></td>`) + }) + }) + + describe('align attribute', () => { + it('converts align to text-align on non-table elements', () => { + const html = '<p align="center"></p>' + const result = run(html, ['align']) + expect(result).toBe('<p align="center" style="text-align: center"></p>') + }) + + it('converts align="left" to float on table elements', () => { + const html = '<table align="left"></table>' + const result = run(html, ['align']) + expect(result).toBe('<table align="left" style="float: left"></table>') + }) + + it('converts align="right" to float on table elements', () => { + const html = '<table align="right"></table>' + const result = run(html, ['align']) + expect(result).toBe('<table align="right" style="float: right"></table>') + }) + + it('converts align="center" to margin auto on table elements', () => { + const html = '<table align="center"></table>' + const result = run(html, ['align']) + expect(result).toBe('<table align="center" style="margin-left: auto; margin-right: auto"></table>') + }) + }) + + describe('valign attribute', () => { + it('converts valign to vertical-align', () => { + const html = '<td valign="top"></td>' + const result = run(html, ['valign']) + expect(result).toBe('<td valign="top" style="vertical-align: top"></td>') + }) + + it('converts valign="middle"', () => { + const html = '<td valign="middle"></td>' + const result = run(html, ['valign']) + expect(result).toBe('<td valign="middle" style="vertical-align: middle"></td>') + }) + }) + + describe('multiple attributes', () => { + it('handles multiple attributes on the same element', () => { + const html = '<table width="600" height="200" bgcolor="#fff"></table>' + const result = run(html, ['width', 'height', 'bgcolor']) + expect(result).toBe('<table width="600" height="200" bgcolor="#fff" style="width: 600px; height: 200px; background-color: #fff"></table>') + }) + + it('appends to existing style attribute', () => { + const html = '<table width="600" style="border: 1px solid black"></table>' + const result = run(html, ['width']) + expect(result).toBe('<table width="600" style="border: 1px solid black; width: 600px"></table>') + }) + + it('handles existing style without trailing semicolon', () => { + const html = '<table width="600" style="border: 1px solid black;"></table>' + const result = run(html, ['width']) + expect(result).toBe('<table width="600" style="border: 1px solid black;; width: 600px"></table>') + }) + }) + + describe('nested elements', () => { + it('processes attributes on nested elements', () => { + const html = '<table width="100%"><tr><td height="50">Content</td></tr></table>' + const result = run(html, ['width', 'height']) + expect(result).toContain('<table width="100%" style="width: 100%">') + expect(result).toContain('<td height="50" style="height: 50px">') + }) + }) + + describe('short-circuit behavior', () => { + it('returns original string when no matching attributes found', () => { + const html = '<table><tr><td>Content</td></tr></table>' + const result = run(html, ['width']) + expect(result).toBe(html) + }) + + it('returns original string when configured attributes are not present', () => { + const html = '<table height="200"></table>' + const result = run(html, ['width']) + expect(result).toBe(html) + }) + }) +}) diff --git a/src/tests/transformers/base.test.ts b/src/tests/transformers/base.test.ts new file mode 100644 index 00000000..55637e55 --- /dev/null +++ b/src/tests/transformers/base.test.ts @@ -0,0 +1,546 @@ +import { describe, it, expect } from 'vitest' +import { base } from '../../transformers/base.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { UrlConfig } from '../../types/config.ts' + +function run(html: string, config: { url?: UrlConfig } = {}): string { + return serialize(base(parse(html), config.url)) +} + +describe('base URL', () => { + describe('default behavior', () => { + it('does nothing when url.base is not set', () => { + const html = '<img src="image.jpg">' + expect(run(html)).toBe(html) + }) + + it('does nothing when url.base is false', () => { + const html = '<img src="image.jpg">' + expect(run(html, { url: { base: false as unknown as undefined } })).toBe(html) + }) + + it('does nothing when url.base is empty string', () => { + const html = '<img src="image.jpg">' + expect(run(html, { url: { base: '' } })).toBe(html) + }) + }) + + describe('string url.base', () => { + it('prepends base URL to img src', () => { + const result = run('<img src="image.jpg">', { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<img src="https://cdn.example.com/image.jpg">') + }) + + it('prepends base URL to anchor href', () => { + const result = run('<a href="page.html">Link</a>', { url: { base: 'https://example.com/' } }) + expect(result).toBe('<a href="https://example.com/page.html">Link</a>') + }) + + it('prepends base URL to multiple img src', () => { + const html = '<img src="a.jpg"><img src="b.jpg">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + expect(result).toContain('src="https://cdn.example.com/b.jpg"') + }) + + it('does not modify absolute URLs', () => { + const html = '<img src="https://other.com/image.jpg">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + + it('does not modify data URIs', () => { + const html = '<img src="data:image/png;base64,abc123">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + + it('handles mailto: URLs', () => { + const html = '<a href="mailto:test@example.com">Email</a>' + const result = run(html, { url: { base: 'https://example.com/' } }) + expect(result).toBe(html) + }) + + it('handles tel: URLs', () => { + const html = '<a href="tel:+1234567890">Call</a>' + const result = run(html, { url: { base: 'https://example.com/' } }) + expect(result).toBe(html) + }) + + it('does not modify protocol-relative URLs', () => { + const html = '<img src="//cdn.example.com/image.jpg">' + const result = run(html, { url: { base: 'https://example.com/' } }) + expect(result).toBe(html) + }) + + it('does not modify fragment-only URLs', () => { + const html = '<a href="#section">Jump</a>' + const result = run(html, { url: { base: 'https://example.com/' } }) + expect(result).toBe(html) + }) + + it('does not modify query-only URLs', () => { + const html = '<a href="?param=value">Link</a>' + const result = run(html, { url: { base: 'https://example.com/' } }) + expect(result).toBe(html) + }) + }) + + describe('srcset attribute', () => { + it('prepends base URL to srcset URLs', () => { + const html = '<img srcset="image.jpg 1x, image@2x.jpg 2x">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<img srcset="https://cdn.example.com/image.jpg 1x, https://cdn.example.com/image@2x.jpg 2x">') + }) + + it('preserves width descriptors in srcset', () => { + const html = '<img srcset="small.jpg 320w, large.jpg 1024w">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<img srcset="https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1024w">') + }) + + it('does not modify absolute URLs in srcset', () => { + const html = '<img srcset="https://other.com/image.jpg 1x">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + }) + + describe('video elements', () => { + it('prepends base URL to video src', () => { + const html = '<video src="video.mp4"></video>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<video src="https://cdn.example.com/video.mp4"></video>') + }) + + it('prepends base URL to video poster', () => { + const html = '<video poster="poster.jpg"></video>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<video poster="https://cdn.example.com/poster.jpg"></video>') + }) + }) + + describe('source elements', () => { + it('prepends base URL to source src', () => { + const html = '<source src="video.mp4">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<source src="https://cdn.example.com/video.mp4">') + }) + + it('prepends base URL to source srcset', () => { + const html = '<source srcset="video.mp4">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<source srcset="https://cdn.example.com/video.mp4">') + }) + }) + + describe('link elements', () => { + it('prepends base URL to link href', () => { + const html = '<link rel="stylesheet" href="styles.css">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<link rel="stylesheet" href="https://cdn.example.com/styles.css">') + }) + }) + + describe('script elements', () => { + it('prepends base URL to script src', () => { + const html = '<script src="app.js"></script>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<script src="https://cdn.example.com/app.js"></script>') + }) + }) + + describe('object and embed elements', () => { + it('prepends base URL to object data', () => { + const html = '<object data="file.swf"></object>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<object data="https://cdn.example.com/file.swf"></object>') + }) + + it('prepends base URL to embed src', () => { + const html = '<embed src="file.swf">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<embed src="https://cdn.example.com/file.swf">') + }) + }) + + describe('iframe elements', () => { + it('prepends base URL to iframe src', () => { + const html = '<iframe src="page.html"></iframe>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<iframe src="https://cdn.example.com/page.html"></iframe>') + }) + }) + + describe('CSS in style attribute', () => { + it('prepends base URL to url() in style attribute', () => { + const html = '<div style="background-image: url(bg.jpg)"></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('prepends base URL to background in style attribute', () => { + const html = '<div style="background: url(bg.jpg)"></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('does not process style without url()', () => { + const html = '<div style="color: red; font-size: 16px"></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + + it('handles quoted url() in style attribute', () => { + const html = '<div style="background: url(\'bg.jpg\')"></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('https://cdn.example.com/bg.jpg') + }) + + it('does not modify absolute url() in style attribute', () => { + const html = '<div style="background: url(https://other.com/bg.jpg)"></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://other.com/bg.jpg)') + }) + }) + + describe('CSS in style tags', () => { + it('prepends base URL to url() in style tags', () => { + const html = '<style>.bg { background-image: url(bg.jpg) }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('handles @font-face url() in style tags', () => { + const html = '<style>@font-face { src: url(font.woff2); }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/font.woff2)') + }) + + it('handles multiple url() in a single declaration', () => { + const html = '<style>.bg { background: url(a.jpg), url(b.jpg) }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/a.jpg)') + expect(result).toContain('url(https://cdn.example.com/b.jpg)') + }) + + it('does not modify absolute url() in style tags', () => { + const html = '<style>.bg { background: url(https://other.com/bg.jpg) }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://other.com/bg.jpg)') + }) + + it('handles multiple style tags', () => { + const html = '<style>.a { background: url(a.jpg) }</style><style>.b { background: url(b.jpg) }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/a.jpg)') + expect(result).toContain('url(https://cdn.example.com/b.jpg)') + }) + + it('does not modify style tag without url()', () => { + const html = '<style>.text { color: red }</style>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + }) + + describe('inlineCss option', () => { + it('disables CSS processing in style attribute when inlineCss is false', () => { + const html = '<div style="background-image: url(bg.jpg)"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', inlineCss: false } } }) + expect(result).toContain('url(bg.jpg)') + expect(result).not.toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('still processes style tags when only inlineCss is false', () => { + const html = '<style>.bg { background: url(bg.jpg) }</style><div style="background: url(bg.jpg)"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', inlineCss: false } } }) + expect(result).toContain('<style>.bg { background: url(https://cdn.example.com/bg.jpg) }</style>') + expect(result).toContain('style="background: url(bg.jpg)"') + }) + }) + + describe('styleTag option', () => { + it('disables CSS processing in style tags when styleTag is false', () => { + const html = '<style>.bg { background-image: url(bg.jpg) }</style>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', styleTag: false } } }) + expect(result).toContain('url(bg.jpg)') + expect(result).not.toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('still processes inline styles when only styleTag is false', () => { + const html = '<style>.bg { background: url(bg.jpg) }</style><div style="background: url(inline.jpg)"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', styleTag: false } } }) + expect(result).toContain('url(bg.jpg)') + expect(result).toContain('url(https://cdn.example.com/inline.jpg)') + }) + }) + + describe('tags option', () => { + it('applies base URL only to specified tags (array)', () => { + const html = '<img src="a.jpg"><a href="b.html">Link</a>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', tags: ['img'] } } }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + expect(result).toContain('href="b.html"') + }) + + it('accepts array of multiple tags', () => { + const html = '<img src="a.jpg"><script src="b.js"></script>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', tags: ['img', 'script'] } } }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + expect(result).toContain('src="https://cdn.example.com/b.js"') + }) + + it('accepts object-format tags with per-attribute config', () => { + const html = '<img src="a.jpg" srcset="b.jpg 1x"><a href="page.html">Link</a>' + const result = run(html, { + url: { + base: { + url: 'https://cdn.example.com/', + tags: { + img: { src: true, srcset: true }, + }, + }, + }, + }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + expect(result).toContain('srcset="https://cdn.example.com/b.jpg 1x"') + // <a> not in the tags config, should be untouched + expect(result).toContain('href="page.html"') + }) + + it('supports per-attribute custom base URL in object-format tags', () => { + const html = '<img src="photo.jpg">' + const result = run(html, { + url: { + base: { + url: 'https://cdn.example.com/', + tags: { + img: { src: 'https://images.example.com/' }, + }, + }, + }, + }) + expect(result).toContain('src="https://images.example.com/photo.jpg"') + }) + + it('uses custom per-attribute URL for srcset', () => { + const html = '<img srcset="small.jpg 320w, large.jpg 1024w">' + const result = run(html, { + url: { + base: { + url: 'https://cdn.example.com/', + tags: { + img: { srcset: 'https://images.example.com/' }, + }, + }, + }, + }) + expect(result).toContain('https://images.example.com/small.jpg 320w') + expect(result).toContain('https://images.example.com/large.jpg 1024w') + }) + + it('skips attributes not listed in object-format tag config', () => { + const html = '<img src="photo.jpg" srcset="photo.jpg 1x">' + const result = run(html, { + url: { + base: { + url: 'https://cdn.example.com/', + tags: { + img: { src: true }, + }, + }, + }, + }) + expect(result).toContain('src="https://cdn.example.com/photo.jpg"') + expect(result).toContain('srcset="photo.jpg 1x"') + }) + }) + + describe('custom attributes', () => { + it('prepends base URL to custom attributes', () => { + const html = '<div data-url="file.txt"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', attributes: { 'data-url': 'https://cdn.example.com/' } } } }) + expect(result).toContain('data-url="https://cdn.example.com/file.txt"') + }) + + it('does not modify absolute custom attributes', () => { + const html = '<div data-url="https://other.com/file.txt"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/', attributes: { 'data-url': 'https://cdn.example.com/' } } } }) + expect(result).toContain('data-url="https://other.com/file.txt"') + }) + + it('custom attributes work regardless of tags filter', () => { + const html = '<div data-bg="bg.jpg"></div>' + const result = run(html, { + url: { + base: { + url: 'https://cdn.example.com/', + tags: ['img'], + attributes: { 'data-bg': 'https://assets.example.com/' }, + }, + }, + }) + expect(result).toContain('data-bg="https://assets.example.com/bg.jpg"') + }) + }) + + describe('VML elements', () => { + it('handles v:image elements', () => { + const html = '<v:image src="image.png"/>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/image.png"') + }) + + it('handles v:fill elements', () => { + const html = '<v:fill src="bg.png"/>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/bg.png"') + }) + + it('handles v:image inside MSO comments', () => { + const html = '<!--[if gte vml 1]><v:image src="image.png"/><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/image.png"') + }) + + it('handles v:fill inside MSO comments', () => { + const html = '<!--[if gte vml 1]><v:fill src="bg.png"/><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/bg.png"') + }) + + it('does not modify absolute v:image URLs', () => { + const html = '<v:image src="https://other.com/image.png"/>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://other.com/image.png"') + }) + + it('does not modify absolute v:fill URLs', () => { + const html = '<v:fill src="https://other.com/bg.png"/>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://other.com/bg.png"') + }) + }) + + describe('MSO comments', () => { + it('handles URLs inside MSO conditional comments', () => { + const html = '<!--[if mso]><img src="a.jpg"><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + }) + + it('does not modify absolute URLs in MSO comments', () => { + const html = '<!--[if mso]><img src="https://other.com/a.jpg"><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://other.com/a.jpg"') + }) + + it('rewrites srcset inside MSO comments', () => { + const html = '<!--[if mso]><img srcset="small.jpg 1x, large.jpg 2x"><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('https://cdn.example.com/small.jpg 1x') + expect(result).toContain('https://cdn.example.com/large.jpg 2x') + }) + + it('rewrites style url() inside MSO comments', () => { + const html = '<!--[if mso]><div style="background: url(bg.jpg)"></div><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('url(https://cdn.example.com/bg.jpg)') + }) + + it('does not modify style without url() inside MSO comments', () => { + const html = '<!--[if mso]><div style="color: red"></div><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('style="color: red"') + }) + + it('rewrites multiple attributes inside MSO comments', () => { + const html = '<!--[if mso]><a href="page.html"><img src="a.jpg"></a><![endif]-->' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('href="https://cdn.example.com/page.html"') + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + }) + }) + + describe('trailing slash handling', () => { + it('handles base URL without trailing slash', () => { + const html = '<img src="folder/image.jpg">' + const result = run(html, { url: { base: 'https://cdn.example.com' } }) + expect(result).toBe('<img src="https://cdn.example.comfolder/image.jpg">') + }) + + it('handles absolute URLs correctly', () => { + const html = '<img src="/folder/image.jpg">' + const result = run(html, { url: { base: 'https://cdn.example.com/img/' } }) + expect(result).toBe('<img src="https://cdn.example.com/img//folder/image.jpg">') + }) + }) + + describe('object-format base config', () => { + it('works with url property', () => { + const html = '<img src="a.jpg">' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/' } } }) + expect(result).toContain('src="https://cdn.example.com/a.jpg"') + }) + + it('short-circuits when url is empty string', () => { + const html = '<img src="a.jpg">' + const result = run(html, { url: { base: { url: '' } } }) + expect(result).toBe(html) + }) + + it('short-circuits when object has no url property', () => { + const html = '<img src="a.jpg">' + const result = run(html, { url: { base: { tags: ['img'] } as any } }) + expect(result).toBe(html) + }) + + it('defaults styleTag and inlineCss to true', () => { + const html = '<style>.bg { background: url(a.jpg) }</style><div style="background: url(b.jpg)"></div>' + const result = run(html, { url: { base: { url: 'https://cdn.example.com/' } } }) + expect(result).toContain('url(https://cdn.example.com/a.jpg)') + expect(result).toContain('url(https://cdn.example.com/b.jpg)') + }) + }) + + describe('edge cases', () => { + it('handles empty HTML', () => { + expect(run('', { url: { base: 'https://cdn.example.com/' } })).toBe('') + }) + + it('handles HTML with no matching elements', () => { + const html = '<div>Content</div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + + it('handles elements without attributes', () => { + const html = '<div><span>Text</span></div>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe(html) + }) + + it('preserves existing attributes', () => { + const html = '<img src="a.jpg" alt="Photo" class="img">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('alt="Photo"') + expect(result).toContain('class="img"') + }) + + it('skips empty attribute values', () => { + const html = '<img src="">' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toBe('<img src>') + }) + + it('handles mixed absolute and relative URLs in the same document', () => { + const html = '<img src="local.jpg"><img src="https://other.com/remote.jpg"><a href="page.html">Link</a><a href="mailto:test@test.com">Email</a>' + const result = run(html, { url: { base: 'https://cdn.example.com/' } }) + expect(result).toContain('src="https://cdn.example.com/local.jpg"') + expect(result).toContain('src="https://other.com/remote.jpg"') + expect(result).toContain('href="https://cdn.example.com/page.html"') + expect(result).toContain('href="mailto:test@test.com"') + }) + }) +}) diff --git a/src/tests/transformers/entities.test.ts b/src/tests/transformers/entities.test.ts new file mode 100644 index 00000000..ff870ec9 --- /dev/null +++ b/src/tests/transformers/entities.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import { entities } from '../../transformers/entities.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { EntitiesConfig } from '../../types/config.ts' + +function run(html: string, decodeEntities?: EntitiesConfig): string { + return serialize(entities(parse(html), decodeEntities)) +} + +describe('entities', () => { + describe('config: disabled', () => { + it('encodes entities by default when decodeEntities is undefined', () => { + const html = '<p>Hello\u00A0world</p>' + const result = run(html, undefined) + expect(result).toBe('<p>Hello&nbsp;world</p>') + }) + + it('returns unchanged when decodeEntities is false', () => { + const html = '<p>Hello\u00A0world</p>' + const result = run(html, false) + expect(result).toBe(html) + }) + }) + + describe('config: enabled with true', () => { + it('uses default entity map when set to true', () => { + const result = run('<p>\u00A0</p>', true) + expect(result).toBe('<p>&nbsp;</p>') + }) + }) + + describe('default entity replacements', () => { + it('replaces zero-width joiner', () => { + const result = run('<p>\u200D</p>', true) + expect(result).toBe('<p>&zwj;</p>') + }) + }) + + describe('multiple replacements', () => { + it('replaces multiple different entities in one pass', () => { + const result = run('<p>\u00A0\u2014\u2022</p>', true) + expect(result).toBe('<p>&nbsp;&mdash;&bull;</p>') + }) + + it('replaces multiple occurrences of the same entity', () => { + const result = run('<p>\u00A0\u00A0\u00A0</p>', true) + expect(result).toBe('<p>&nbsp;&nbsp;&nbsp;</p>') + }) + + it('replaces entities across multiple text nodes', () => { + const result = run('<p>\u00A0</p><p>\u2014</p>', true) + expect(result).toBe('<p>&nbsp;</p><p>&mdash;</p>') + }) + }) + + describe('element coverage', () => { + it('processes text in nested elements', () => { + const result = run('<div><span>\u00A0</span></div>', true) + expect(result).toBe('<div><span>&nbsp;</span></div>') + }) + + it('processes text in deeply nested structures', () => { + const result = run('<table><tr><td>\u2022 item</td></tr></table>', true) + expect(result).toBe('<table><tr><td>&bull; item</td></tr></table>') + }) + + it('only processes text nodes, not attributes', () => { + const result = run('<div title="\u00A0">\u00A0</div>', true) + expect(result).toContain('>&nbsp;<') + // Attribute should remain unchanged (unicode char or already encoded) + expect(result).toMatch(/title="[^"]*"/) + }) + }) + + describe('config: custom overrides', () => { + it('user entity overrides a default', () => { + const result = run('<p>\u00A0</p>', { + '\u00A0': '&custom_nbsp;', + }) + expect(result).toBe('<p>&custom_nbsp;</p>') + }) + + it('user adds a new entity while keeping defaults', () => { + const result = run('<p>\u00A9\u00A0</p>', { + '\u00A9': '&copy;', + }) + expect(result).toBe('<p>&copy;&nbsp;</p>') + }) + + it('user overrides one default, others still apply', () => { + const result = run('<p>\u2014\u2013</p>', { + '\u2014': '---', + }) + expect(result).toBe('<p>---&ndash;</p>') + }) + + it('empty custom map still uses all defaults', () => { + const result = run('<p>\u00A0</p>', {}) + expect(result).toBe('<p>&nbsp;</p>') + }) + }) + + describe('short-circuit', () => { + it('returns serialized HTML even when no entities are found', () => { + const html = '<p>Plain text</p>' + const result = run(html, true) + expect(result).toBe('<p>Plain text</p>') + }) + + it('preserves normal text content', () => { + const html = '<p>Hello world</p>' + const result = run(html, true) + expect(result).toBe(html) + }) + }) + + describe('edge cases', () => { + it('handles empty HTML', () => { + const result = run('', true) + expect(result).toBe('') + }) + + it('handles text-only input (no tags)', () => { + const result = run('\u00A0', true) + expect(result).toBe('&nbsp;') + }) + + it('handles entity next to regular text', () => { + const result = run('<p>Price:\u00A020\u20AC</p>', { + '\u20AC': '&euro;', + }) + expect(result).toBe('<p>Price:&nbsp;20&euro;</p>') + }) + + it('handles entities at start and end of text', () => { + const result = run('<p>\u201CHello\u201D</p>', true) + expect(result).toBe('<p>&ldquo;Hello&rdquo;</p>') + }) + }) +}) diff --git a/src/tests/transformers/filters.test.ts b/src/tests/transformers/filters.test.ts new file mode 100644 index 00000000..4a9568b1 --- /dev/null +++ b/src/tests/transformers/filters.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect } from 'vitest' +import { filters } from '../../transformers/filters/index.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { FiltersConfig } from '../../transformers/filters/index.ts' + +function run(html: string, config?: FiltersConfig): string { + return serialize(filters(parse(html), config)) +} + +describe('filters', () => { + describe('disabled', () => { + it('returns HTML unchanged when set to false', () => { + const html = '<p uppercase>foo</p>' + expect(run(html, false)).toBe(html) + }) + + it('applies defaults when config is undefined', () => { + expect(run('<p uppercase>foo</p>')).toBe('<p>FOO</p>') + }) + + it('applies defaults when config is empty object', () => { + expect(run('<p uppercase>foo</p>', {})).toBe('<p>FOO</p>') + }) + }) + + describe('removes filter attributes', () => { + it('removes boolean filter attribute', () => { + const result = run('<p uppercase>foo</p>') + expect(result).not.toContain('uppercase') + expect(result).toBe('<p>FOO</p>') + }) + + it('removes filter attribute with value', () => { + const result = run('<p append=" bar">foo</p>') + expect(result).not.toContain('append') + expect(result).toBe('<p>foo bar</p>') + }) + + it('preserves non-filter attributes', () => { + const result = run('<p class="text" uppercase>foo</p>') + expect(result).toBe('<p class="text">FOO</p>') + }) + }) + + describe('multiple filters', () => { + it('applies multiple filters in attribute order', () => { + const result = run('<p prepend="foo " uppercase>bar</p>') + expect(result).toBe('<p>FOO BAR</p>') + }) + + it('chains filters correctly', () => { + const result = run('<p append=" world" uppercase>hello</p>') + expect(result).toBe('<p>HELLO WORLD</p>') + }) + + it('applies trim then uppercase', () => { + const result = run('<p trim uppercase> foo </p>') + expect(result).toBe('<p>FOO</p>') + }) + }) + + describe('custom filters', () => { + it('supports custom filter functions', () => { + const result = run('<p reverse>hello</p>', { + reverse: str => str.split('').reverse().join(''), + }) + expect(result).toBe('<p>olleh</p>') + }) + + it('custom filters override defaults', () => { + const result = run('<p uppercase>foo</p>', { + uppercase: () => 'custom', + }) + expect(result).toBe('<p>custom</p>') + }) + + it('custom filters merge with defaults', () => { + const result = run('<p uppercase>foo</p><p reverse>bar</p>', { + reverse: str => str.split('').reverse().join(''), + }) + expect(result).toBe('<p>FOO</p><p>rab</p>') + }) + + it('custom filter receives attribute value', () => { + const result = run('<p repeat="3">ab</p>', { + repeat: (str, value) => str.repeat(Number.parseInt(value, 10)), + }) + expect(result).toBe('<p>ababab</p>') + }) + }) + + describe('nested elements', () => { + it('processes child filters before parent filters', () => { + const result = run('<div uppercase><p append=" world">hello</p></div>') + expect(result).toBe('<div><p>HELLO WORLD</p></div>') + }) + + it('handles sibling elements with different filters', () => { + const result = run('<p uppercase>foo</p><p lowercase>BAR</p>') + expect(result).toBe('<p>FOO</p><p>bar</p>') + }) + + it('preserves nested HTML structure', () => { + const result = run('<div uppercase>hello <strong>world</strong></div>') + expect(result).toBe('<div>HELLO <strong>WORLD</strong></div>') + }) + }) + + describe('elements without filters', () => { + it('leaves elements without filter attributes unchanged', () => { + const html = '<p>hello</p>' + expect(run(html)).toBe(html) + }) + + it('leaves non-matching attributes unchanged', () => { + const html = '<p class="foo" id="bar">hello</p>' + expect(run(html)).toBe(html) + }) + }) + + describe('edge cases', () => { + it('handles empty HTML', () => { + expect(run('')).toBe('') + }) + + it('handles empty element content', () => { + expect(run('<p uppercase></p>')).toBe('<p></p>') + }) + + it('handles self-closing elements', () => { + expect(run('<br><p uppercase>test</p>')).toBe('<br><p>TEST</p>') + }) + }) + + describe('default filters', () => { + describe('append', () => { + it('appends text', () => { + expect(run('<p append=" bar">foo</p>')).toBe('<p>foo bar</p>') + }) + }) + + describe('prepend', () => { + it('prepends text', () => { + expect(run('<p prepend="foo ">bar</p>')).toBe('<p>foo bar</p>') + }) + }) + + describe('uppercase', () => { + it('uppercases the string', () => { + expect(run('<p uppercase>foo</p>')).toBe('<p>FOO</p>') + }) + }) + + describe('lowercase', () => { + it('lowercases the string', () => { + expect(run('<p lowercase>FOO</p>')).toBe('<p>foo</p>') + }) + }) + + describe('capitalize', () => { + it('capitalizes first letter', () => { + expect(run('<p capitalize>foo</p>')).toBe('<p>Foo</p>') + }) + }) + + describe('ceil', () => { + it('rounds up to nearest integer', () => { + expect(run('<p ceil>1.2</p>')).toBe('<p>2</p>') + }) + + it('handles negative numbers', () => { + expect(run('<p ceil>-1.8</p>')).toBe('<p>-1</p>') + }) + }) + + describe('floor', () => { + it('rounds down to nearest integer', () => { + expect(run('<p floor>1.8</p>')).toBe('<p>1</p>') + }) + }) + + describe('round', () => { + it('rounds to nearest integer', () => { + expect(run('<p round>1234.567</p>')).toBe('<p>1235</p>') + }) + + it('rounds down when below .5', () => { + expect(run('<p round>1.4</p>')).toBe('<p>1</p>') + }) + }) + + describe('escape', () => { + it('escapes HTML entities', () => { + // Parser decodes &amp; to &, then escape re-encodes it + expect(run('<p escape>foo &amp; bar</p>')).toBe('<p>foo &amp; bar</p>') + }) + + it('escapes quotes', () => { + expect(run('<p escape>say "hello"</p>')).toBe('<p>say &#34;hello&#34;</p>') + }) + }) + + describe('escape-once', () => { + it('does not double-escape entities', () => { + expect(run('<p escape-once>foo &amp; bar</p>')).toBe('<p>foo &amp; bar</p>') + }) + }) + + describe('lstrip', () => { + it('removes leading whitespace', () => { + expect(run('<p lstrip> test </p>')).toBe('<p>test </p>') + }) + }) + + describe('rstrip', () => { + it('removes trailing whitespace', () => { + expect(run('<p rstrip> test </p>')).toBe('<p> test</p>') + }) + }) + + describe('trim', () => { + it('removes leading and trailing whitespace', () => { + expect(run('<p trim> test </p>')).toBe('<p>test</p>') + }) + }) + + describe('minus', () => { + it('subtracts numbers', () => { + expect(run('<p minus="2">3</p>')).toBe('<p>1</p>') + }) + }) + + describe('plus', () => { + it('adds numbers', () => { + expect(run('<p plus="2">3</p>')).toBe('<p>5</p>') + }) + }) + + describe('multiply', () => { + it('multiplies numbers', () => { + expect(run('<p multiply="2">1.2</p>')).toBe('<p>2.4</p>') + }) + }) + + describe('times', () => { + it('is an alias for multiply', () => { + expect(run('<p times="2">1.2</p>')).toBe('<p>2.4</p>') + }) + }) + + describe('divide-by', () => { + it('divides numbers', () => { + expect(run('<div divide-by="2">1.2</div>')).toBe('<div>0.6</div>') + }) + }) + + describe('divide', () => { + it('is an alias for divide-by', () => { + expect(run('<div divide="2">1.2</div>')).toBe('<div>0.6</div>') + }) + }) + + describe('modulo', () => { + it('returns remainder', () => { + expect(run('<p modulo="2">3</p>')).toBe('<p>1</p>') + }) + }) + + describe('newline-to-br', () => { + it('replaces newlines with br tags', () => { + expect(run('<p newline-to-br>\ntest\ntest\n</p>')).toBe('<p><br>test<br>test<br></p>') + }) + }) + + describe('strip-newlines', () => { + it('removes newlines', () => { + expect(run('<p strip-newlines>\n test\n test\n</p>')).toBe('<p> test test</p>') + }) + }) + + describe('remove', () => { + it('removes all occurrences', () => { + expect(run('<p remove="rain">I strained to see the train through the rain</p>')) + .toBe('<p>I sted to see the t through the </p>') + }) + }) + + describe('remove-first', () => { + it('removes first occurrence', () => { + expect(run('<p remove-first="rain">I strained to see the train through the rain</p>')) + .toBe('<p>I sted to see the train through the rain</p>') + }) + + it('returns unchanged if not found', () => { + expect(run('<p remove-first="xyz">hello</p>')).toBe('<p>hello</p>') + }) + }) + + describe('replace', () => { + it('replaces all occurrences', () => { + expect(run('<p replace="t|1">test</p>')).toBe('<p>1es1</p>') + }) + }) + + describe('replace-first', () => { + it('replaces first occurrence', () => { + expect(run('<p replace-first="t|b">test</p>')).toBe('<p>best</p>') + }) + + it('returns unchanged if not found', () => { + expect(run('<p replace-first="x|y">hello</p>')).toBe('<p>hello</p>') + }) + }) + + describe('size', () => { + it('returns string length', () => { + expect(run('<p size>one</p>')).toBe('<p>3</p>') + }) + }) + + describe('slice', () => { + it('slices from index', () => { + expect(run('<p slice="1">test</p>')).toBe('<p>est</p>') + }) + + it('slices with start and end', () => { + expect(run('<p slice="0,-1">test</p>')).toBe('<p>tes</p>') + }) + }) + + describe('truncate', () => { + it('truncates with default ellipsis', () => { + expect(run('<p truncate="17">Ground control to Major Tom.</p>')) + .toBe('<p>Ground control to...</p>') + }) + + it('truncates with custom ellipsis', () => { + expect(run('<p truncate="17, no one">Ground control to Major Tom.</p>')) + .toBe('<p>Ground control to no one</p>') + }) + + it('returns unchanged if shorter than limit', () => { + expect(run('<p truncate="100">short</p>')).toBe('<p>short</p>') + }) + }) + + describe('truncate-words', () => { + it('truncates by word count', () => { + expect(run('<p truncate-words="2">Ground control to Major Tom.</p>')) + .toBe('<p>Ground control...</p>') + }) + + it('truncates with custom ellipsis', () => { + expect(run('<p truncate-words="2, over and out">Ground control to Major Tom.</p>')) + .toBe('<p>Ground control over and out</p>') + }) + + it('returns unchanged if fewer words than limit', () => { + expect(run('<p truncate-words="10">two words</p>')).toBe('<p>two words</p>') + }) + }) + + describe('url-decode', () => { + it('decodes URL-encoded string', () => { + expect(run('<p url-decode>%27Stop%21%27+said+Fred</p>')) + .toBe("<p>'Stop!' said Fred</p>") + }) + }) + + describe('url-encode', () => { + it('encodes string for URL', () => { + expect(run('<p url-encode>user@example.com</p>')) + .toBe('<p>user%40example.com</p>') + }) + }) + }) +}) diff --git a/src/tests/transformers/fixtures/test.css b/src/tests/transformers/fixtures/test.css new file mode 100644 index 00000000..073df881 --- /dev/null +++ b/src/tests/transformers/fixtures/test.css @@ -0,0 +1,4 @@ +.test { + color: red; + font-size: 16px; +} diff --git a/src/tests/transformers/format.test.ts b/src/tests/transformers/format.test.ts new file mode 100644 index 00000000..bfa448c6 --- /dev/null +++ b/src/tests/transformers/format.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { format } from '../../transformers/format.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +async function run(html: string, option?: boolean | Record<string, unknown>): Promise<string> { + const config: MaizzleConfig = option === undefined ? {} : { html: { format: option } } + return format(html, config) +} + +// --------------------------------------------------------------------------- +// Core feature — indents HTML +// --------------------------------------------------------------------------- + +describe('format', () => { + it('indents nested HTML elements', async () => { + const html = `<html><body><p>hello</p></body></html>` + const result = await run(html, true) + expect(result).toContain('\n') + expect(result).toMatch(/<body>\s+<p>/) + }) + + it('preserves all content', async () => { + const html = `<html><head><style>.foo{color:red}</style></head><body><p class="foo">hi</p></body></html>` + const result = await run(html, true) + expect(result).toContain('.foo') + expect(result).toContain('color:red') + expect(result).toContain('class="foo"') + expect(result).toContain('hi') + }) + + // --------------------------------------------------------------------------- + // Custom options — user values win + // --------------------------------------------------------------------------- + + it('uses tabs when useTabs is set', async () => { + const html = `<html><body><p>hi</p></body></html>` + const result = await run(html, { useTabs: true }) + expect(result).toContain('\t') + }) + + it('respects custom tabWidth', async () => { + const html = `<html><body><p>hi</p></body></html>` + const result = await run(html, { tabWidth: 4 }) + // With tabWidth: 4 the body content should be indented with 4 spaces + expect(result).toMatch(/^ {4}/m) + }) + + it('respects singleAttributePerLine', async () => { + const html = `<html><body><div class="foo" id="bar"><p>hi</p></div></body></html>` + const result = await run(html, { singleAttributePerLine: true }) + // Each attribute on its own line + expect(result).toMatch(/class="foo"\n/) + expect(result).toMatch(/id="bar"\n/) + }) + + // --------------------------------------------------------------------------- + // Formatting normalisation + // --------------------------------------------------------------------------- + + it('condenses multiple blank lines', async () => { + const html = `<html><body>\n\n\n<p>hi</p>\n\n\n</body></html>` + const result = await run(html, true) + expect(result).not.toMatch(/\n{3,}/) + }) + + it('trims leading whitespace', async () => { + const html = ` <html><body><p>hi</p></body></html>` + const result = await run(html, true) + expect(result).not.toMatch(/^\s+/) + }) + + // --------------------------------------------------------------------------- + // Disabled / short-circuit + // --------------------------------------------------------------------------- + + it('returns html unchanged when format is false', async () => { + const html = `<html><body><p>hi</p></body></html>` + expect(await run(html, false)).toBe(html) + }) + + it('returns html unchanged when format is not set', async () => { + const html = `<html><body><p>hi</p></body></html>` + expect(await run(html)).toBe(html) + }) + + it('returns html unchanged when config is not provided', async () => { + const html = `<html><body><p>hi</p></body></html>` + expect(await format(html)).toBe(html) + }) +}) diff --git a/src/tests/transformers/inlineCSS.test.ts b/src/tests/transformers/inlineCSS.test.ts new file mode 100644 index 00000000..c0d99559 --- /dev/null +++ b/src/tests/transformers/inlineCSS.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest' +import { inlineCSS } from '../../transformers/inlineCSS.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { CssConfig } from '../../types/config.ts' + +function run(html: string, inline?: boolean | Record<string, unknown>): string { + return serialize(inlineCSS(parse(html), { inline } as CssConfig)) +} + +describe('inlineCSS', () => { + describe('config: css.inline', () => { + it('returns unchanged when inline is not set', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + expect(serialize(inlineCSS(parse(html), {}))).toBe(html) + }) + + it('returns unchanged when inline is false', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + expect(run(html, false)).toBe(html) + }) + + it('inlines CSS when inline is true', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + const result = run(html, true) + expect(result).toContain('style="color: red;"') + }) + }) + + describe('basic CSS inlining', () => { + it('inlines multiple CSS properties', () => { + const html = '<style>.btn { color: white; background: blue; padding: 10px; }</style><a class="btn">Click</a>' + const result = run(html, true) + expect(result).toContain('color: white') + expect(result).toContain('background: blue') + expect(result).toContain('padding: 10px') + }) + }) + + describe('styleToAttribute', () => { + it('converts background-color to bgcolor', () => { + const html = '<style>.bg-blue { background-color: blue; }</style><table class="bg-blue"></table>' + const result = run(html, { styleToAttribute: { 'background-color': 'bgcolor' } }) + expect(result).toContain('bgcolor="blue"') + expect(result).toContain('style="background-color: blue;"') + }) + + it('converts text-align to align on table cells', () => { + const html = '<style>.center { text-align: center; }</style><td class="center">Text</td>' + const result = run(html, { styleToAttribute: { 'text-align': 'align' } }) + expect(result).toContain('align="center"') + expect(result).toContain('style="text-align: center;"') + }) + }) + + describe('removeInlinedSelectors', () => { + it('removes inlined selectors', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + const result = run(html, { removeInlinedSelectors: true }) + expect(result).not.toContain('<style>.red { color: red; }</style>') + expect(result).toContain('<p class="red" style="color: red;">Text</p>') + }) + + it('keeps classes when removeInlinedSelectors is false', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + const result = run(html, { removeInlinedSelectors: false }) + expect(result).toContain('class="red"') + expect(result).toContain('style=') + }) + }) + + describe('preferUnitlessValues', () => { + it('converts 0px to 0 by default', () => { + const html = '<style>.zero { margin: 0px; }</style><p class="zero">Text</p>' + const result = run(html, true) + expect(result).toContain('margin: 0') + expect(result).not.toContain('margin: 0px') + }) + + it('keeps units when preferUnitlessValues is false', () => { + const html = '<style>.zero { margin: 0px; }</style><p class="zero">Text</p>' + const result = run(html, { preferUnitlessValues: false }) + expect(result).toContain('margin: 0px') + }) + }) + + describe('safelist', () => { + it('preserves safelisted classes', () => { + const html = '<style>.red { color: red; }</style><p class="red">Text</p>' + const result = run(html, { safelist: ['red'] }) + expect(result).toContain('class="red"') + expect(result).toContain('style=') + }) + + it('preserves classes matching substring', () => { + const html = '<style>.text-red { color: red; }</style><p class="text-red">Text</p>' + const result = run(html, { safelist: ['text-red'] }) + expect(result).toContain('class="text-red"') + }) + }) + + describe('excludedProperties', () => { + it('excludes specified properties from inlining', () => { + const html = '<style>.box { color: red; padding: 10px; }</style><p class="box">Text</p>' + const result = run(html, { excludedProperties: ['padding'] }) + expect(result).toContain('color: red') + expect(result).not.toContain('padding: 10px') + }) + }) + + describe('codeBlocks', () => { + it('ignores EJS code blocks by default', () => { + const html = '<style>.red { color: <%= colorVar %>; }</style><p class="red">Text</p>' + const result = run(html, true) + expect(result).toContain('<%= colorVar %>') + }) + + it('ignores Handlebars code blocks by default', () => { + const html = '<p class="{{ dynamicClass }}">Text</p>' + const result = run(html, true) + expect(result).toContain('{{ dynamicClass }}') + }) + + it('registers custom code blocks', () => { + const html = '<style>.red { color: {{% colorVar %}}; }</style><p class="red">Text</p>' + const result = run(html, { + codeBlocks: { + Twig: { start: '{{%', end: '%}}' }, + }, + }) + expect(result).toContain('{{% colorVar %}}') + }) + }) + + describe('embedded styles', () => { + it('preserves styles with data-embed attribute', () => { + const html = '<style data-embed>.keep { color: blue; }</style><p class="keep">Text</p>' + const result = run(html, { removeInlinedSelectors: false }) + // Juice strips data-embed but the style content should be preserved + expect(result).toContain('<style') + expect(result).toContain('.keep') + }) + + it('preserves styles with embed attribute', () => { + const html = '<style embed>.keep { color: blue; }</style><p class="keep">Text</p>' + const result = run(html, { removeInlinedSelectors: false }) + expect(result).toContain('embed') + expect(result).toContain('.keep') + }) + + it('adds embed when data-embed has a truthy value', () => { + const html = '<style data-embed="true">.keep { color: blue; }</style><p class="keep">Text</p>' + const result = run(html, { removeInlinedSelectors: false }) + expect(result).toContain('embed') + expect(result).toContain('.keep') + }) + + it('preserves styles with no-inline attribute', () => { + const html = '<style no-inline>.keep { color: blue; }</style><p>Text</p>' + const result = run(html, true) + expect(result).toContain('<style no-inline>') + }) + }) + + describe('width/height attributes', () => { + it('applies width attributes to img elements', () => { + const html = '<style>img { width: 100px; }</style><img src="test.jpg">' + const result = run(html, true) + expect(result).toContain('width="100"') + }) + + it('applies height attributes to img elements', () => { + const html = '<style>img { height: 200px; }</style><img src="test.jpg">' + const result = run(html, true) + expect(result).toContain('height="200"') + }) + + it('respects custom widthElements', () => { + const html = '<style>table { width: 500px; }</style><table></table>' + const result = run(html, { widthElements: ['table'] }) + expect(result).toContain('width="500"') + }) + + it('disables width attributes when applyWidthAttributes is false', () => { + const html = '<style>img { width: 100px; }</style><img src="test.jpg">' + const result = run(html, { applyWidthAttributes: false, applyHeightAttributes: false }) + // Note: Juice may still add width attributes depending on its internal behavior + // This test verifies the option is passed through correctly + expect(result).toContain('style=') + }) + }) + + describe('customCSS', () => { + it('inlines extra CSS not in the HTML', () => { + const html = '<p class="red">Text</p>' + const result = run(html, { customCSS: '.red { color: red; }' }) + expect(result).toContain('style="color: red;"') + }) + + it('merges customCSS with existing style tags', () => { + const html = '<style>.blue { background: blue; }</style><p class="red blue">Text</p>' + const result = run(html, { customCSS: '.red { color: red; }' }) + expect(result).toContain('color: red') + expect(result).toContain('background: blue') + }) + }) + + describe('edge cases', () => { + it('handles empty HTML', () => { + expect(run('', true)).toBe('') + }) + + it('handles HTML without style tags', () => { + const html = '<p>No styles here</p>' + expect(run(html, true)).toBe(html) + }) + + it('handles multiple style tags', () => { + const html = '<style>.red { color: red; }</style><style>.blue { background: blue; }</style><p class="red blue">Text</p>' + const result = run(html, true) + expect(result).toContain('color: red') + expect(result).toContain('background: blue') + }) + + it('handles pseudo-selectors', () => { + const html = '<style>a:hover { color: red; }</style><a>Link</a>' + const result = run(html, true) + expect(result).not.toContain('style=') + }) + }) +}) diff --git a/src/tests/transformers/inlineLink.test.ts b/src/tests/transformers/inlineLink.test.ts new file mode 100644 index 00000000..e49b1618 --- /dev/null +++ b/src/tests/transformers/inlineLink.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { resolve } from 'node:path' +import { inlineLink } from '../../transformers/inlineLink.ts' +import { parse, serialize } from '../../utils/ast/index.ts' + +function run(html: string, filePath?: string): Promise<string> { + return inlineLink(parse(html), filePath).then(serialize) +} + +const fixturesDir = resolve(import.meta.dirname, 'fixtures') +const fakeFilePath = resolve(fixturesDir, 'template.html') + +describe('inlineLink', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + describe('local files', () => { + it('inlines a local stylesheet', async () => { + const html = '<link rel="stylesheet" href="test.css">' + const result = await run(html, fakeFilePath) + expect(result).toContain('<style>') + expect(result).toContain('.test') + expect(result).toContain('color: red') + expect(result).not.toContain('<link') + }) + + it('resolves path relative to filePath', async () => { + const html = '<link rel="stylesheet" href="./test.css">' + const result = await run(html, fakeFilePath) + expect(result).toContain('.test') + expect(result).toContain('color: red') + }) + + it('leaves link unchanged if file does not exist', async () => { + const html = '<link rel="stylesheet" href="missing.css">' + const result = await run(html, fakeFilePath) + expect(result).toContain('<link') + expect(result).toContain('href="missing.css"') + }) + + it('skips local files when filePath is not provided', async () => { + const html = '<link rel="stylesheet" href="test.css">' + const result = await run(html) + expect(result).toContain('<link') + expect(result).toContain('href="test.css"') + }) + }) + + describe('remote URLs', () => { + it('skips remote URLs without inline attribute', async () => { + const html = '<link rel="stylesheet" href="https://example.com/styles.css">' + const result = await run(html) + expect(result).toContain('<link') + expect(result).toContain('href="https://example.com/styles.css"') + }) + + it('inlines remote URL with inline attribute', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + text: () => Promise.resolve('body { margin: 0; }'), + })) + + const html = '<link rel="stylesheet" href="https://example.com/styles.css" inline>' + const result = await run(html) + expect(result).toContain('<style>') + expect(result).toContain('body { margin: 0; }') + expect(result).not.toContain('<link') + }) + + it('handles http:// URLs with inline attribute', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + text: () => Promise.resolve('p { color: blue; }'), + })) + + const html = '<link rel="stylesheet" href="http://example.com/styles.css" inline>' + const result = await run(html) + expect(result).toContain('<style>') + expect(result).toContain('p { color: blue; }') + }) + + it('leaves link unchanged if fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const html = '<link rel="stylesheet" href="https://example.com/styles.css" inline>' + const result = await run(html) + expect(result).toContain('<link') + }) + }) + + describe('filtering', () => { + it('ignores links without rel="stylesheet"', async () => { + const html = '<link rel="icon" href="favicon.ico">' + const result = await run(html, fakeFilePath) + expect(result).toContain('<link') + expect(result).toContain('rel="icon"') + }) + + it('ignores links without href', async () => { + const html = '<link rel="stylesheet">' + const result = await run(html, fakeFilePath) + expect(result).toContain('<link') + }) + }) + + describe('edge cases', () => { + it('handles multiple link tags', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + text: () => Promise.resolve('h1 { font-size: 24px; }'), + })) + + const html = '<link rel="stylesheet" href="test.css"><link rel="stylesheet" href="https://example.com/remote.css" inline>' + const result = await run(html, fakeFilePath) + expect(result).toContain('color: red') + expect(result).toContain('h1 { font-size: 24px; }') + expect(result).not.toContain('<link') + }) + + it('returns unchanged DOM when no links present', async () => { + const html = '<div>Hello</div>' + const result = await run(html) + expect(result).toBe('<div>Hello</div>') + }) + + it('handles link nested in head', async () => { + const html = '<head><link rel="stylesheet" href="test.css"></head>' + const result = await run(html, fakeFilePath) + expect(result).toContain('<head><style>') + expect(result).toContain('color: red') + expect(result).toContain('</style></head>') + }) + + it('works without filePath - remote with inline still works', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + text: () => Promise.resolve('a { color: green; }'), + })) + + const html = '<link rel="stylesheet" href="https://example.com/styles.css" inline>' + const result = await run(html) + expect(result).toContain('<style>') + expect(result).toContain('a { color: green; }') + }) + }) +}) diff --git a/src/tests/transformers/minify.test.ts b/src/tests/transformers/minify.test.ts new file mode 100644 index 00000000..3db42911 --- /dev/null +++ b/src/tests/transformers/minify.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { minify } from '../../transformers/minify.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +function run(html: string, config: MaizzleConfig = {}): string { + return minify(html, config) +} + +describe('minify', () => { + describe('core — removeLineBreaks (default)', () => { + it('removes line breaks between tags', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + const result = run(html, { html: { minify: true } }) + expect(result).not.toContain('\n') + }) + + it('collapses indentation between tags', () => { + const html = '<table>\n <tr>\n <td>Hello</td>\n </tr>\n</table>' + const result = run(html, { html: { minify: true } }) + // html-crush breaks before closing tags per breakToTheLeftOf defaults, + // but removes leading indentation whitespace + expect(result).not.toContain(' <tr>') + expect(result).not.toContain(' <td>') + expect(result).toContain('Hello') + expect(result).toContain('</td>') + }) + + it('preserves tag content', () => { + const html = '<p>Hello World</p>' + const result = run(html, { html: { minify: true } }) + expect(result).toContain('Hello World') + }) + + it('preserves style tag contents', () => { + const html = '<style>.foo { color: red; }</style>\n<div class="foo">Hi</div>' + const result = run(html, { html: { minify: true } }) + expect(result).toContain('.foo') + expect(result).toContain('color:red') + }) + }) + + describe('config: enabled', () => { + it('minifies when minify: true', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + expect(run(html, { html: { minify: true } })).not.toContain('\n') + }) + + it('minifies when minify is an options object', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + expect(run(html, { html: { minify: {} } })).not.toContain('\n') + }) + }) + + describe('config: disabled', () => { + it('returns input unchanged when minify is false', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + expect(run(html, { html: { minify: false } })).toBe(html) + }) + + it('returns input unchanged when minify is not set', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + expect(run(html, {})).toBe(html) + }) + }) + + describe('config: custom options', () => { + it('keeps line breaks when removeLineBreaks is overridden to false', () => { + const html = '<div>\n <p>Hello</p>\n</div>' + const result = run(html, { html: { minify: { removeLineBreaks: false } } }) + expect(result).toContain('\n') + }) + + it('removes HTML comments when removeHTMLComments: 1', () => { + const html = '<div><!-- a comment --><p>Hello</p></div>' + const result = run(html, { html: { minify: { removeHTMLComments: 1 } } }) + expect(result).not.toContain('<!-- a comment -->') + expect(result).toContain('Hello') + }) + + it('preserves Outlook conditional comments when removeHTMLComments: 1', () => { + const html = '<!--[if mso]><v:rect></v:rect><![endif]--><p>Hello</p>' + const result = run(html, { html: { minify: { removeHTMLComments: 1 } } }) + expect(result).toContain('<!--[if mso]>') + }) + + it('removes all HTML comments including Outlook when removeHTMLComments: 2', () => { + const html = '<!--[if mso]><v:rect></v:rect><![endif]--><p>Hello</p>' + const result = run(html, { html: { minify: { removeHTMLComments: 2 } } }) + expect(result).not.toContain('<!--[if mso]>') + expect(result).toContain('Hello') + }) + + it('keeps CSS comments when removeCSSComments is false', () => { + const html = '<style>/* my comment */ .foo { color: red; }</style>' + const result = run(html, { html: { minify: { removeCSSComments: false } } }) + expect(result).toContain('/* my comment */') + }) + }) + + describe('short-circuit', () => { + it('returns original string when minify is not set', () => { + const html = '<div>Hello</div>' + expect(run(html)).toBe(html) + }) + + it('returns original string when minify is false', () => { + const html = '<div>Hello</div>' + expect(run(html, { html: { minify: false } })).toBe(html) + }) + + it('handles empty string', () => { + expect(run('', { html: { minify: true } })).toBe('') + }) + }) +}) diff --git a/src/tests/transformers/optimizeCss.test.ts b/src/tests/transformers/optimizeCss.test.ts new file mode 100644 index 00000000..db8522dc --- /dev/null +++ b/src/tests/transformers/optimizeCss.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest' +import postcss from 'postcss' +import { tailwindCleanup } from '../../plugins/postcss/tailwindCleanup.ts' +import { mergeMediaQueries } from '../../plugins/postcss/mergeMediaQueries.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +async function run(css: string, config: MaizzleConfig = {}): Promise<string> { + const plugins: postcss.Plugin[] = [...tailwindCleanup(config)] + + const mediaPlugin = mergeMediaQueries(config) + if (mediaPlugin) plugins.push(mediaPlugin) + + const result = await postcss(plugins).process(css, { from: undefined }) + + return result.css +} + +describe('optimizeCss', () => { + describe(':host — default', () => { + it('removes a standalone :host rule', async () => { + expect(await run(':host { color: red }')).not.toContain(':host') + }) + + it('strips :host from :root, :host compound selector', async () => { + const result = await run(':root, :host { --color: red }') + expect(result).not.toContain(':host') + expect(result).toContain(':root') + }) + + it('strips :host from html, :host compound selector', async () => { + const result = await run('html, :host { box-sizing: border-box }') + expect(result).not.toContain(':host') + expect(result).toContain('html') + }) + }) + + describe(':lang — default', () => { + it('removes a standalone :lang() rule', async () => { + expect(await run(':lang(x) { quotes: auto }')).not.toContain(':lang') + }) + + it('strips :lang() from a compound selector, preserving other parts', async () => { + const result = await run('*, :lang(x) { box-sizing: border-box }') + expect(result).not.toContain(':lang') + expect(result).toContain('*') + }) + + it('removes all :lang() parts when that is all there is (multi-value list)', async () => { + const css = `:lang(ar), +:lang(fa), +:lang(he) { + direction: rtl; +}` + expect(await run(css)).not.toContain(':lang') + }) + + it('handles the lightningcss-flattened Tailwind RTL block', async () => { + const css = `:lang(ar), +:lang(arc), +:lang(bcc), +:lang(bqi), +:lang(ckb), +:lang(dv), +:lang(fa), +:lang(glk), +:lang(he), +:lang(ku), +:lang(mzn), +:lang(nqo), +:lang(pnb), +:lang(ps), +:lang(sd), +:lang(ug), +:lang(ur), +:lang(yi) { + left: 4px; +} +.keep { color: green }` + const result = await run(css) + expect(result).not.toContain(':lang') + expect(result).toContain('.keep') + }) + }) + + describe(':lang inside @media — default', () => { + it('removes :lang() rules nested inside @media blocks', async () => { + const css = `@media screen { + :lang(ar) { direction: rtl } + .keep { color: red } +}` + const result = await run(css) + expect(result).not.toContain(':lang') + expect(result).toContain('.keep') + }) + }) + + describe('@layer and @property — default', () => { + it('removes @layer blocks', async () => { + const result = await run('@layer base { .foo { color: red } } .keep { color: green }') + expect(result).not.toContain('@layer') + expect(result).toContain('.keep') + }) + + it('removes @property blocks', async () => { + const result = await run('@property --my-color { syntax: "<color>"; inherits: false; initial-value: red; } .keep { color: green }') + expect(result).not.toContain('@property') + expect(result).toContain('.keep') + }) + }) + + describe('config: custom removeSelectors', () => { + it('uses a custom list instead of defaults', async () => { + const result = await run(':host { color: red } .foo { color: blue }', { postcss: { removeSelectors: ['.foo'] } }) + expect(result).not.toContain('.foo') + expect(result).toContain(':host') + }) + + it('empty list removes nothing', async () => { + const result = await run(':host { color: red } :lang(x) { quotes: auto }', { postcss: { removeSelectors: [], removeAtRules: [] } }) + expect(result).toContain(':host') + expect(result).toContain(':lang') + }) + }) + + describe('config: custom removeAtRules', () => { + it('uses a custom list instead of defaults', async () => { + const result = await run('@layer base { .foo { color: red } } @media screen { .bar { color: blue } }', { postcss: { removeAtRules: ['media'] } }) + expect(result).not.toContain('@media') + expect(result).toContain('@layer') + }) + + it('empty list removes nothing', async () => { + const result = await run('@layer base { .foo { color: red } }', { postcss: { removeAtRules: [] } }) + expect(result).toContain('@layer') + }) + }) + + describe('mergeMediaQueries — on by default', () => { + it('merges duplicate media queries when css.media is not set (on by default)', async () => { + const css = `@media (max-width: 600px) { .a { color: red } } +@media (max-width: 600px) { .b { color: blue } }` + const result = await run(css) + expect(result.match(/@media/g)?.length).toBe(1) + expect(result).toContain('.a') + expect(result).toContain('.b') + }) + + it('does not merge media queries when css.media is false', async () => { + const css = `@media (max-width: 600px) { .a { color: red } } +@media (max-width: 600px) { .b { color: blue } }` + const result = await run(css, { css: { media: false } }) + expect(result.match(/@media/g)?.length).toBe(2) + }) + }) + + describe('mergeMediaQueries — enabled', () => { + it('merges duplicate media queries when css.media is true', async () => { + const css = `@media (max-width: 600px) { .a { color: red } } +@media (max-width: 600px) { .b { color: blue } }` + const result = await run(css, { css: { media: true } }) + expect(result.match(/@media/g)?.length).toBe(1) + expect(result).toContain('.a') + expect(result).toContain('.b') + }) + + it('sorts mobile-first by default', async () => { + const css = `@media (max-width: 600px) { .sm { color: red } } +@media (min-width: 768px) { .md { color: blue } }` + const result = await run(css, { css: { media: true } }) + expect(result.indexOf('min-width')).toBeLessThan(result.indexOf('max-width')) + }) + + it('sorts desktop-first when configured', async () => { + const css = `@media (min-width: 768px) { .md { color: blue } } +@media (max-width: 600px) { .sm { color: red } }` + const result = await run(css, { css: { media: { sort: 'desktop-first' } } }) + expect(result.indexOf('max-width')).toBeLessThan(result.indexOf('min-width')) + }) + }) +}) diff --git a/src/tests/transformers/purgeCSS.test.ts b/src/tests/transformers/purgeCSS.test.ts new file mode 100644 index 00000000..0828bce6 --- /dev/null +++ b/src/tests/transformers/purgeCSS.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest' +import { purgeCSS } from '../../transformers/purgeCSS.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { CssConfig } from '../../types/config.ts' + +function run(html: string, option?: CssConfig['purge']): string { + const config: CssConfig = option === undefined ? {} : { purge: option } + return serialize(purgeCSS(parse(html), config)) +} + +// --------------------------------------------------------------------------- +// Core feature — removes unused CSS +// --------------------------------------------------------------------------- + +describe('purgeCSS', () => { + it('removes unused CSS class from <style> and class attribute', () => { + // Note: do not use '.unused' — it is in the default safelist (Notes 8 client) + const html = `<html><head><style>.used { color: red } .discarded { color: blue }</style></head><body><p class="used">hi</p></body></html>` + const result = run(html, true) + expect(result).toContain('.used') + expect(result).not.toContain('.discarded') + }) + + it('preserves used classes in both style tag and class attribute', () => { + const html = `<html><head><style>.foo { color: red }</style></head><body><p class="foo">hi</p></body></html>` + const result = run(html, true) + expect(result).toContain('.foo') + expect(result).toContain('class="foo"') + }) + + it('removes entire style rule when no matching element exists', () => { + const html = `<html><head><style>.gone { margin: 0 }</style></head><body><p>hi</p></body></html>` + const result = run(html, true) + expect(result).not.toContain('.gone') + }) + + it('preserves all id selectors by default (due to #* in default safelist)', () => { + // #* is in the default safelist for Freenet webmail compatibility, + // so all id selectors are preserved even when unused. + const html = `<html><head><style>#header { color: red } #used { color: blue }</style></head><body><div id="used">hi</div></body></html>` + const result = run(html, true) + expect(result).toContain('#used') + expect(result).toContain('#header') + }) + + // --------------------------------------------------------------------------- + // Default safelist — email client reset selectors are preserved + // --------------------------------------------------------------------------- + + it('preserves *body* selector by default', () => { + const html = `<html><head><style>body[yahoo] .foo { color: red } .unused { color: blue }</style></head><body><p>hi</p></body></html>` + const result = run(html, true) + // *body* pattern matches "body[yahoo]" + expect(result).toContain('body[yahoo]') + }) + + it('preserves .gmail* selector by default', () => { + const html = `<html><head><style>.gmail-fix { display: none }</style></head><body><p>hi</p></body></html>` + const result = run(html, true) + expect(result).toContain('.gmail-fix') + }) + + it('preserves .outlook* selector by default', () => { + const html = `<html><head><style>.outlook-fix { mso-line-height-rule: exactly }</style></head><body><p>hi</p></body></html>` + const result = run(html, true) + expect(result).toContain('.outlook-fix') + }) + + it('preserves .moz-text-html selector by default', () => { + const html = `<html><head><style>.moz-text-html .body { color: red }</style></head><body></body></html>` + const result = run(html, true) + expect(result).toContain('.moz-text-html') + }) + + // --------------------------------------------------------------------------- + // HTML comments handling + // --------------------------------------------------------------------------- + + it('removes HTML comments by default', () => { + const html = `<html><head></head><body><!-- a comment --><p>hi</p></body></html>` + const result = run(html, true) + expect(result).not.toContain('<!-- a comment -->') + }) + + it('preserves Outlook conditional comments by default', () => { + const html = `<html><head></head><body><!--[if mso]><p>mso</p><![endif]--><p>hi</p></body></html>` + const result = run(html, true) + expect(result).toContain('<!--[if mso]>') + expect(result).toContain('<![endif]-->') + }) + + it('preserves HTML comments when removeHTMLComments is false', () => { + const html = `<html><head></head><body><!-- keep me --><p>hi</p></body></html>` + const result = run(html, { removeHTMLComments: false }) + expect(result).toContain('<!-- keep me -->') + }) + + // --------------------------------------------------------------------------- + // CSS comments handling + // --------------------------------------------------------------------------- + + it('removes CSS comments by default', () => { + const html = `<html><head><style>/* this is a comment */ .foo { color: red }</style></head><body><p class="foo">hi</p></body></html>` + const result = run(html, true) + expect(result).not.toContain('/* this is a comment */') + }) + + it('preserves CSS comments when removeCSSComments is false', () => { + const html = `<html><head><style>/* keep */ .foo { color: red }</style></head><body><p class="foo">hi</p></body></html>` + const result = run(html, { removeCSSComments: false }) + expect(result).toContain('/* keep */') + }) + + // --------------------------------------------------------------------------- + // safelist option — user additions append to defaults + // --------------------------------------------------------------------------- + + it('preserves classes matching user safelist entries', () => { + const html = `<html><head><style>.External { color: red } .ReadMsgBody { margin: 0 }</style></head><body></body></html>` + const result = run(html, { safelist: ['.External*', '.ReadMsgBody'] }) + expect(result).toContain('.External') + expect(result).toContain('.ReadMsgBody') + }) + + it('user safelist appends to default safelist (does not replace it)', () => { + const html = `<html><head><style>.gmail-fix { color: red } .custom { color: blue }</style></head><body></body></html>` + const result = run(html, { safelist: ['.custom'] }) + // Both the default safelist entry and the user one should survive + expect(result).toContain('.gmail-fix') + expect(result).toContain('.custom') + }) + + // --------------------------------------------------------------------------- + // backend option — template tags inside class attributes are preserved + // --------------------------------------------------------------------------- + + it('ignores default backend delimiters {{ }} so they are not treated as class names', () => { + const html = `<html><head><style>.text-sm { font-size: 14px }</style></head><body><p class="{{ computed }} text-sm">hi</p></body></html>` + // Should not throw and should preserve text-sm (it exists in the HTML) + const result = run(html, true) + expect(result).toContain('.text-sm') + }) + + it('accepts custom backend delimiters', () => { + const html = `<html><head><style>.text-sm { font-size: 14px }</style></head><body><p class="[[ computed ]] text-sm">hi</p></body></html>` + const result = run(html, { backend: [{ heads: '[[', tails: ']]' }] }) + expect(result).toContain('.text-sm') + }) + + // --------------------------------------------------------------------------- + // uglify option + // --------------------------------------------------------------------------- + + it('renames classes when uglify is true', () => { + const html = `<html><head><style>.a-very-long-class-name { color: red }</style></head><body><p class="a-very-long-class-name">hi</p></body></html>` + const result = run(html, { uglify: true }) + // The original long name should be gone, replaced by a short one + expect(result).not.toContain('a-very-long-class-name') + }) + + // --------------------------------------------------------------------------- + // Disabled / short-circuit + // --------------------------------------------------------------------------- + + it('returns html unchanged when css.purge is false', () => { + const html = `<html><head><style>.unused { color: red }</style></head><body><p>hi</p></body></html>` + expect(run(html, false)).toBe(html) + }) + + it('returns html unchanged when css.purge is not set', () => { + const html = `<html><head><style>.unused { color: red }</style></head><body><p>hi</p></body></html>` + expect(run(html)).toBe(html) + }) + + it('returns html unchanged when config is not provided', () => { + const html = `<html><head><style>.unused { color: red }</style></head><body><p>hi</p></body></html>` + expect(serialize(purgeCSS(parse(html)))).toBe(html) + }) +}) diff --git a/src/tests/transformers/removeAttributes.test.ts b/src/tests/transformers/removeAttributes.test.ts new file mode 100644 index 00000000..20f8f03a --- /dev/null +++ b/src/tests/transformers/removeAttributes.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest' +import { removeAttributes } from '../../transformers/removeAttributes.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { AttributesConfig } from '../../types/config.ts' + +function run(html: string, remove?: Array<string | { name: string; value?: string | RegExp }>): string { + return serialize(removeAttributes(parse(html), { remove } satisfies AttributesConfig)) +} + +describe('removeAttributes', () => { + describe('default behavior', () => { + it('removes empty style attributes by default', () => { + const html = '<div style="">Content</div>' + const result = run(html) + expect(result).toBe('<div>Content</div>') + }) + + it('removes empty class attributes by default', () => { + const html = '<div class="">Content</div>' + const result = run(html) + expect(result).toBe('<div>Content</div>') + }) + + it('keeps non-empty style attributes', () => { + const html = '<div style="color: red;">Content</div>' + const result = run(html) + expect(result).toBe('<div style="color: red;">Content</div>') + }) + + it('keeps non-empty class attributes', () => { + const html = '<div class="test">Content</div>' + const result = run(html) + expect(result).toBe('<div class="test">Content</div>') + }) + }) + + describe('remove by attribute name (empty values)', () => { + it('removes empty data-src attributes', () => { + const html = '<img src="test.jpg" data-src="" alt="Test">' + const result = run(html, ['data-src']) + expect(result).toBe('<img src="test.jpg" alt="Test">') + }) + + it('removes attributes without values', () => { + const html = '<img src="test.jpg" data-src alt="Test">' + const result = run(html, ['data-src']) + expect(result).toBe('<img src="test.jpg" alt="Test">') + }) + + it('keeps non-empty data-src attributes', () => { + const html = '<img src="test.jpg" data-src="original.jpg" alt="Test">' + const result = run(html, ['data-src']) + expect(result).toBe('<img src="test.jpg" data-src="original.jpg" alt="Test">') + }) + + it('handles multiple attribute names', () => { + const html = '<div data-a="" data-b="" data-c="keep">Content</div>' + const result = run(html, ['data-a', 'data-b']) + expect(result).toBe('<div data-c="keep">Content</div>') + }) + }) + + describe('remove by name and exact value', () => { + it('removes id attribute with specific value', () => { + const html = '<div id="test">Content</div>' + const result = run(html, [{ name: 'id', value: 'test' }]) + expect(result).toBe('<div>Content</div>') + }) + + it('keeps id attribute with different value', () => { + const html = '<div id="other">Content</div>' + const result = run(html, [{ name: 'id', value: 'test' }]) + expect(result).toBe('<div id="other">Content</div>') + }) + + it('handles multiple elements with same attribute', () => { + const html = '<div id="test">A</div><div id="test">B</div><div id="other">C</div>' + const result = run(html, [{ name: 'id', value: 'test' }]) + expect(result).toBe('<div>A</div><div>B</div><div id="other">C</div>') + }) + }) + + describe('remove by name and regex value', () => { + it('removes data-id when value contains digits', () => { + const html = '<div data-id="test"></div><div data-id="99"></div>' + const result = run(html, [{ name: 'data-id', value: /\d/ }]) + expect(result).toBe('<div data-id="test"></div><div></div>') + }) + + it('removes class when value matches pattern', () => { + const html = '<div class="temp-123"></div><div class="keep"></div>' + const result = run(html, [{ name: 'class', value: /^temp-/ }]) + expect(result).toBe('<div></div><div class="keep"></div>') + }) + + it('handles complex regex patterns', () => { + const html = '<div data-val="abc-123"></div><div data-val="xyz-456"></div><div data-val="no-match"></div>' + const result = run(html, [{ name: 'data-val', value: /^[a-z]+-\d+$/ }]) + expect(result).toBe('<div></div><div></div><div data-val="no-match"></div>') + }) + }) + + describe('combined options', () => { + it('handles strings and objects together', () => { + const html = '<div data-empty="" id="remove" class="keep">Content</div>' + const result = run(html, ['data-empty', { name: 'id', value: 'remove' }]) + expect(result).toBe('<div class="keep">Content</div>') + }) + + it('processes in order', () => { + const html = '<div class="" style="" data-test="">Content</div>' + const result = run(html, ['data-test']) + // style and class are removed by default, data-test by user config + expect(result).toBe('<div>Content</div>') + }) + }) + + describe('edge cases', () => { + it('returns original HTML when no config', () => { + const html = '<div data-test="value">Content</div>' + // No attributes configured for removal, and no empty style/class + expect(serialize(removeAttributes(parse(html), {}))).toBe(html) + }) + + it('handles HTML without attributes', () => { + const html = '<div>Content</div>' + const result = run(html, ['data-test']) + expect(result).toBe('<div>Content</div>') + }) + + it('handles nested elements', () => { + const html = '<div class=""><span data-empty="">Text</span></div>' + const result = run(html, ['data-empty']) + expect(result).toBe('<div><span>Text</span></div>') + }) + + it('handles empty remove array', () => { + const html = '<div class="">Content</div>' + const result = run(html, []) + // Should still remove empty style and class by default + expect(result).toBe('<div>Content</div>') + }) + + it('works with non-existent attributes', () => { + const html = '<div>Content</div>' + const result = run(html, ['data-missing']) + expect(result).toBe('<div>Content</div>') + }) + }) +}) diff --git a/src/tests/transformers/replaceStrings.test.ts b/src/tests/transformers/replaceStrings.test.ts new file mode 100644 index 00000000..88eaef5c --- /dev/null +++ b/src/tests/transformers/replaceStrings.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { replaceStrings } from '../../transformers/replaceStrings.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +function run(html: string, replacements?: Record<string, string>): string { + const config: MaizzleConfig = replacements !== undefined ? { replaceStrings: replacements } : {} + return replaceStrings(html, config) +} + +describe('replaceStrings', () => { + describe('core', () => { + it('replaces an exact string', () => { + expect(run('Hello World', { 'World': 'Maizzle' })).toBe('Hello Maizzle') + }) + + it('replaces all occurrences globally', () => { + expect(run('foo foo foo', { 'foo': 'bar' })).toBe('bar bar bar') + }) + + it('replacement is case-insensitive', () => { + expect(run('Hello HELLO hello', { 'hello': 'hi' })).toBe('hi hi hi') + }) + + it('applies multiple replacements in order', () => { + const result = run('foo bar', { 'foo': 'baz', 'bar': 'qux' }) + expect(result).toBe('baz qux') + }) + + it('replaces using a regex pattern', () => { + // \s?data-src="" — remove empty data-src attributes + expect(run('<img data-src="" src="a.jpg">', { '\\s?data-src=""': '' })).toBe('<img src="a.jpg">') + }) + + it('supports regex character classes', () => { + // \\d+ matches one or more digits + expect(run('item-123 item-456', { '\\d+': 'X' })).toBe('item-X item-X') + }) + + it('replaces with empty string (deletion)', () => { + expect(run('<div class="">Hello</div>', { '\\s?class=""': '' })).toBe('<div>Hello</div>') + }) + + it('replaces across the whole HTML string', () => { + const html = '<p>Hello World</p><p>World again</p>' + expect(run(html, { 'World': 'Maizzle' })).toBe('<p>Hello Maizzle</p><p>Maizzle again</p>') + }) + }) + + describe('config: disabled / empty', () => { + it('returns input unchanged when replaceStrings is not set', () => { + const html = '<p>Hello</p>' + expect(run(html)).toBe(html) + }) + + it('returns input unchanged when replaceStrings is an empty object', () => { + const html = '<p>Hello</p>' + expect(run(html, {})).toBe(html) + }) + }) + + describe('short-circuit', () => { + it('returns the exact same string reference when nothing to replace', () => { + const html = '<div>Hello</div>' + expect(run(html)).toBe(html) + }) + + it('handles empty input string', () => { + expect(run('', { 'foo': 'bar' })).toBe('') + }) + }) +}) diff --git a/src/tests/transformers/safeClassNames.test.ts b/src/tests/transformers/safeClassNames.test.ts new file mode 100644 index 00000000..36286257 --- /dev/null +++ b/src/tests/transformers/safeClassNames.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest' +import { safeClassNames } from '../../transformers/safeClassNames.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { CssConfig } from '../../types/config.ts' + +// Shorthand: run transformer with safe enabled by default so feature tests +// can stay concise. Pass an explicit value to test config behaviour. +function run(html: string, safe?: false | true | Record<string, string>): string { + return serialize(safeClassNames(parse(html), { safe: safe ?? true } satisfies CssConfig)) +} + +describe('safeClassNames — class attributes', () => { + it('replaces : with - (Tailwind responsive/variant prefix)', () => { + expect(run('<div class="sm:text-base"></div>')).toBe('<div class="sm-text-base"></div>') + }) + + it('replaces / with - (Tailwind fraction utilities)', () => { + expect(run('<div class="w-1/2"></div>')).toBe('<div class="w-1-2"></div>') + }) + + it('replaces . with _ (decimal values)', () => { + expect(run('<div class="w-2.5"></div>')).toBe('<div class="w-2_5"></div>') + }) + + it('replaces % with pc', () => { + expect(run('<div class="w-[50%]"></div>')).toBe('<div class="w-50pc"></div>') + }) + + it('removes [ and ]', () => { + expect(run('<div class="text-[14px]"></div>')).toBe('<div class="text-14px"></div>') + }) + + it('replaces ! with i- (Tailwind important modifier)', () => { + expect(run('<div class="text-red-500!"></div>')).toBe('<div class="text-red-500-i"></div>') + }) + + it('replaces @ with at-', () => { + expect(run('<div class="@sm:flex"></div>')).toBe('<div class="at-sm-flex"></div>') + }) + + it('replaces # with _', () => { + expect(run('<div class="color-#fff"></div>')).toBe('<div class="color-_fff"></div>') + }) + + it('replaces & with and-', () => { + expect(run('<div class="[&>span]:flex"></div>')).toBe('<div class="and-gt-span-flex"></div>') + }) + + it('handles multiple classes on the same element', () => { + expect(run('<div class="sm:text-base hover:text-red-500 font-bold"></div>')) + .toBe('<div class="sm-text-base hover-text-red-500 font-bold"></div>') + }) + + it('handles multiple special chars within one class', () => { + // sm:w-1/2 → sm-w-1-2 + expect(run('<div class="sm:w-1/2"></div>')).toBe('<div class="sm-w-1-2"></div>') + }) + + it('leaves class names with no special chars unchanged', () => { + const html = '<div class="font-bold text-red-500 p-4"></div>' + expect(run(html)).toBe(html) + }) + + it('handles extra whitespace between classes gracefully', () => { + expect(run('<div class=" sm:flex md:block "></div>')) + .toBe('<div class="sm-flex md-block"></div>') + }) + + it('processes class attrs on nested elements', () => { + expect(run('<table class="sm:w-full"><tr><td class="hover:bg-red-100">x</td></tr></table>')) + .toBe('<table class="sm-w-full"><tr><td class="hover-bg-red-100">x</td></tr></table>') + }) +}) + +describe('safeClassNames — style tag selectors', () => { + it('replaces \\: with - in CSS selectors', () => { + const html = '<style>.sm\\:text-base { color: red; }</style>' + expect(run(html)).toBe('<style>.sm-text-base { color: red; }</style>') + }) + + it('replaces \\/ with - in CSS selectors', () => { + const html = '<style>.w-1\\/2 { width: 50%; }</style>' + expect(run(html)).toBe('<style>.w-1-2 { width: 50%; }</style>') + }) + + it('replaces \\. with _ in CSS selectors', () => { + const html = '<style>.w-2\\.5 { width: 2.5rem; }</style>' + expect(run(html)).toBe('<style>.w-2_5 { width: 2.5rem; }</style>') + }) + + it('replaces \\[ and \\] in CSS selectors', () => { + const html = '<style>.text-\\[14px\\] { font-size: 14px; }</style>' + expect(run(html)).toBe('<style>.text-14px { font-size: 14px; }</style>') + }) + + it('replaces \\! with i- in CSS selectors', () => { + const html = '<style>.font-bold\\! { font-weight: 700 !important; }</style>' + expect(run(html)).toBe('<style>.font-bold-i { font-weight: 700 !important; }</style>') + }) + + it('handles multiple rules in one style tag', () => { + const html = '<style>.sm\\:flex { display: flex; } .md\\:block { display: block; }</style>' + expect(run(html)).toBe('<style>.sm-flex { display: flex; } .md-block { display: block; }</style>') + }) + + it('handles comma-separated selectors', () => { + const html = '<style>.sm\\:flex, .md\\:block { color: red; }</style>' + expect(run(html)).toBe('<style>.sm-flex, .md-block { color: red; }</style>') + }) + + it('handles \\2c (CSS unicode escape for comma) in selectors', () => { + // \2c is the CSS unicode escape for comma character + const html = '<style>.item\\2c list { color: red; }</style>' + expect(run(html)).toBe('<style>.item_list { color: red; }</style>') + }) + + it('does not modify property values inside rules', () => { + const html = '<style>.sm\\:text-base { font-family: "Arial:Black", sans-serif; }</style>' + const result = run(html) + expect(result).toContain('.sm-text-base') + expect(result).toContain('"Arial:Black"') + }) + + it('processes style and class attr together', () => { + const html = '<style>.sm\\:text-base { color: red; }</style><div class="sm:text-base">x</div>' + expect(run(html)).toBe('<style>.sm-text-base { color: red; }</style><div class="sm-text-base">x</div>') + }) +}) + +describe('safeClassNames — config: css.safe', () => { + it('is enabled by default', () => { + const html = '<div class="sm:text-base"></div>' + expect(serialize(safeClassNames(parse(html), {}))) + .toBe('<div class="sm-text-base"></div>') + }) + + it('is enabled when css.safe is true', () => { + expect(run('<div class="sm:text-base"></div>', true)) + .toBe('<div class="sm-text-base"></div>') + }) + + it('is disabled when css.safe is false', () => { + const html = '<div class="sm:text-base"></div>' + expect(run(html, false)).toBe(html) + }) + + it('also skips style tags when disabled', () => { + const html = '<style>.sm\\:text-base { color: red; }</style>' + expect(run(html, false)).toBe(html) + }) + + it('merges custom replacements on top of defaults', () => { + // Override : → _ instead of : → - + const result = run('<div class="sm:text-base"></div>', { ':': '_' }) + expect(result).toBe('<div class="sm_text-base"></div>') + }) + + it('custom replacements do not remove other defaults', () => { + // / still uses the default → - when only : is overridden + const result = run('<div class="sm:w-1/2"></div>', { ':': '_' }) + expect(result).toBe('<div class="sm_w-1-2"></div>') + }) + + it('custom replacements apply to style selectors too', () => { + const html = '<style>.sm\\:text-base { color: red; }</style>' + const result = run(html, { ':': '_' }) + expect(result).toBe('<style>.sm_text-base { color: red; }</style>') + }) + + it('supports empty replacement (removes chars)', () => { + // Default for [ is already '' — confirm removal + expect(run('<div class="text-[14px]"></div>')) + .toBe('<div class="text-14px"></div>') + }) +}) + +describe('safeClassNames — short-circuit behaviour', () => { + it('returns the original string when there are no class attrs or style tags', () => { + const html = '<div id="main"><p>Hello</p></div>' + const result = run(html) + // No modification was made — same string returned + expect(result).toBe(html) + }) + + it('returns the original string when no class contains special chars', () => { + const html = '<div class="font-bold text-red-500"></div>' + const result = run(html) + expect(result).toBe(html) + }) + + it('returns the original string when the style tag contains no rules', () => { + const html = '<style>/* empty */</style>' + const result = run(html) + expect(result).toBe(html) + }) +}) diff --git a/src/tests/transformers/shorthandCSS.test.ts b/src/tests/transformers/shorthandCSS.test.ts new file mode 100644 index 00000000..5f0144ee --- /dev/null +++ b/src/tests/transformers/shorthandCSS.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest' +import { shorthandCSS } from '../../transformers/shorthandCSS.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { CssConfig } from '../../types/config.ts' + +function run(html: string, shorthand?: boolean | { tags?: string[] }): string { + return serialize(shorthandCSS(parse(html), { shorthand } satisfies CssConfig)) +} + +describe('shorthandCSS', () => { + describe('config: css.shorthand', () => { + it('returns unchanged when shorthand is not set', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px;">Text</p>' + expect(serialize(shorthandCSS(parse(html), {}))).toBe(html) + }) + + it('returns unchanged when shorthand is false', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px;">Text</p>' + expect(run(html, false)).toBe(html) + }) + + it('converts longhand to shorthand when enabled', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">Text</p>' + const result = run(html, true) + expect(result).toContain('margin:') + expect(result).not.toContain('margin-top:') + expect(result).not.toContain('margin-bottom:') + expect(result).not.toContain('margin-left:') + expect(result).not.toContain('margin-right:') + }) + }) + + describe('margin shorthand', () => { + it('converts margin longhand to shorthand', () => { + const html = '<p style="margin-top: 10px; margin-right: 20px; margin-bottom: 10px; margin-left: 20px;">Text</p>' + const result = run(html, true) + expect(result).toContain('margin:') + expect(result).not.toContain('margin-top:') + }) + + it('handles equal margins on all sides', () => { + const html = '<p style="margin-top: 5px; margin-right: 5px; margin-bottom: 5px; margin-left: 5px;">Text</p>' + const result = run(html, true) + expect(result).toContain('margin:') + expect(result).not.toContain('margin-top:') + }) + + it('handles vertical/horizontal shorthand', () => { + const html = '<p style="margin-top: 10px; margin-bottom: 10px; margin-left: 5px; margin-right: 5px;">Text</p>' + const result = run(html, true) + expect(result).toContain('margin:') + expect(result).not.toContain('margin-top:') + }) + }) + + describe('padding shorthand', () => { + it('converts padding longhand to shorthand', () => { + const html = '<p style="padding-top: 10px; padding-right: 20px; padding-bottom: 10px; padding-left: 20px;">Text</p>' + const result = run(html, true) + expect(result).toContain('padding:') + expect(result).not.toContain('padding-top:') + }) + + it('handles equal padding on all sides', () => { + const html = '<p style="padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px;">Text</p>' + const result = run(html, true) + expect(result).toContain('padding:') + expect(result).not.toContain('padding-top:') + }) + }) + + describe('border shorthand', () => { + it('converts border longhand to shorthand', () => { + const html = '<p style="border-width: 1px; border-style: solid; border-color: #000;">Text</p>' + const result = run(html, true) + expect(result).toContain('border:') + expect(result).not.toContain('border-width:') + expect(result).not.toContain('border-style:') + expect(result).not.toContain('border-color:') + }) + }) + + describe('tag filtering', () => { + it('processes only specified tags when tags filter is set', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">A</p><div style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">B</div>' + const result = run(html, { tags: ['div'] }) + // p should remain unchanged, div should be converted + expect(result).toContain('margin-top:') + expect(result).toContain('margin:') + }) + + it('handles multiple allowed tags', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">A</p><div style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">B</div><span style="margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px;">C</span>' + const result = run(html, { tags: ['p', 'div'] }) + expect(result).toContain('<p style="margin:') + expect(result).toContain('<div style="margin:') + expect(result).toContain('<span style="margin-top:') + }) + }) + + describe('edge cases', () => { + it('handles elements without style attribute', () => { + const html = '<p>Text</p>' + const result = run(html, true) + expect(result).toBe('<p>Text</p>') + }) + + it('handles empty style attribute', () => { + const html = '<p style="">Text</p>' + const result = run(html, true) + expect(result).toBe('<p style>Text</p>') + }) + + it('handles invalid CSS gracefully', () => { + const html = '<p style="margin-top: invalid;">Text</p>' + const result = run(html, true) + // Should return original or handle gracefully + expect(result).toContain('<p') + }) + + it('preserves other CSS properties', () => { + const html = '<p style="color: red; margin-top: 4px; margin-bottom: 4px; margin-left: 2px; margin-right: 2px; font-size: 14px;">Text</p>' + const result = run(html, true) + expect(result).toContain('color:') + expect(result).toContain('font-size:') + expect(result).toContain('margin:') + expect(result).not.toContain('margin-top:') + }) + + it('does not modify when not all sides are specified', () => { + const html = '<p style="margin-top: 4px; margin-bottom: 4px;">Text</p>' + const result = run(html, true) + // postcss-merge-longhand only works when all sides are specified + expect(result).toBe('<p style="margin-top: 4px; margin-bottom: 4px;">Text</p>') + }) + + it('processes nested elements', () => { + const html = '<div style="padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px;"><p style="margin-top: 5px; margin-bottom: 5px; margin-left: 5px; margin-right: 5px;">Text</p></div>' + const result = run(html, true) + expect(result).toContain('padding:') + expect(result).toContain('margin:') + }) + }) +}) diff --git a/src/tests/transformers/tailwindcss.test.ts b/src/tests/transformers/tailwindcss.test.ts new file mode 100644 index 00000000..5637499e --- /dev/null +++ b/src/tests/transformers/tailwindcss.test.ts @@ -0,0 +1,275 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { tailwindcss } from '../../transformers/tailwindcss.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { MaizzleConfig } from '../../types/config.ts' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function run(html: string, filePath = path.resolve(__dirname, 'test.html'), config: MaizzleConfig = {}): Promise<string> { + return tailwindcss(parse(html), config, filePath).then(serialize) +} + +describe('tailwindcss', () => { + describe('Tailwind CSS compilation', () => { + it('compiles Tailwind utilities from @source inline', async () => { + const html = '<style>@import "tailwindcss" source(none); @source inline("text-red-500 font-bold mt-4 hidden");</style><div class="text-red-500 font-bold mt-4 hidden">Test</div>' + const result = await run(html, undefined, { postcss: { removeAtRules: [] } }) + + expect(result).toContain('color:') + expect(result).toContain('font-weight: 700') + expect(result).toContain('margin-top:') + expect(result).toContain('display: none') + // Tailwind directives should be compiled away + expect(result).not.toContain('@import "tailwindcss"') + expect(result).not.toContain('@source inline') + }) + + it('compiles @theme block with custom tokens', async () => { + const html = '<style>@import "tailwindcss" source(none); @source inline("text-primary"); @theme { --color-primary: #ff6600; }</style><div class="text-primary">Test</div>' + const result = await run(html, undefined, { postcss: { removeAtRules: [] } }) + + // Tailwind resolves the theme token directly into the utility + expect(result).toContain('.text-primary') + expect(result).toContain('color:') + expect(result).not.toContain('@import "tailwindcss"') + expect(result).not.toContain('@theme') + }) + }) + + describe('lightningcss syntax lowering', () => { + it('flattens CSS nesting to separate rules', async () => { + const html = '<style>.parent { color: red; .child { color: blue } }</style>' + const result = await run(html) + + expect(result).toContain('.parent {') + expect(result).toContain('.parent .child {') + // No nested selectors should remain + expect(result).not.toContain('& .child') + expect(result).not.toMatch(/\.parent\s*\{[^}]*\.child/) + }) + + it('lowers oklch() to a hex fallback', async () => { + const html = '<style>.foo { color: oklch(0.7 0.15 180) }</style>' + const result = await run(html) + + // lightningcss produces a hex fallback for oklch + expect(result).toContain('color: #00b8a1') + // original oklch() should be lowered + expect(result).not.toContain('oklch(') + }) + + it('resolves color-mix() to computed value', async () => { + const html = '<style>.foo { color: color-mix(in srgb, red 50%, blue) }</style>' + const result = await run(html) + + expect(result).toContain('color: purple') + expect(result).not.toContain('color-mix(') + }) + + it('expands logical properties to physical properties', async () => { + const html = '<style>.foo { margin-inline: 10px; padding-block: 5px }</style>' + const result = await run(html) + + expect(result).toContain('margin-left: 10px') + expect(result).toContain('margin-right: 10px') + expect(result).toContain('padding-top: 5px') + expect(result).toContain('padding-bottom: 5px') + expect(result).not.toContain('margin-inline') + expect(result).not.toContain('padding-block') + }) + }) + + describe('pruneVars', () => { + it('removes an unused custom property', async () => { + const html = '<style>:root { --unused: red } .foo { color: blue }</style>' + const result = await run(html) + + expect(result).not.toContain('--unused') + expect(result).toContain('.foo') + expect(result).toContain('color: #00f') + }) + + it('keeps a custom property that is referenced via var()', async () => { + const html = '<style>:root { --brand: #ff0000 } .foo { color: var(--brand) }</style>' + const result = await run(html) + + // postcss-custom-properties resolves the var() inline, so the + // declaration is consumed — pruneVars should not blow up + expect(result).toContain('.foo') + expect(result).toContain('color: red') + }) + + it('removes multiple unused custom properties', async () => { + const html = '<style>:root { --a: 1px; --b: 2px; --c: 3px } .foo { margin: var(--a) }</style>' + const result = await run(html) + + expect(result).not.toContain('--b') + expect(result).not.toContain('--c') + }) + + it('removes a custom property whose only reference is another unused var', async () => { + // --mid is only used by --top; --top is never consumed → both pruned + const html = '<style>:root { --mid: 2px; --top: var(--mid) } .foo { color: red }</style>' + const result = await run(html) + + expect(result).not.toContain('--mid') + expect(result).not.toContain('--top') + expect(result).toContain('color: red') + }) + + it('keeps a chain of custom properties that are ultimately consumed', async () => { + const html = '<style>:root { --base: #0000ff; --alias: var(--base) } .foo { color: var(--alias) }</style>' + const result = await run(html) + + // postcss-custom-properties resolves the whole chain, so the final + // rule should carry the concrete colour value (lightningcss normalises blue → #00f) + expect(result).toContain('.foo') + expect(result).toContain('color: #00f') + }) + + it('does not touch regular properties', async () => { + const html = '<style>.foo { color: red; font-size: 16px }</style>' + const result = await run(html) + + expect(result).toContain('color: red') + expect(result).toContain('font-size: 16px') + }) + }) + + describe('postcss-custom-properties', () => { + it('resolves var() references to computed values', async () => { + const html = '<style>:root { --my-color: #ff0000 } .foo { color: var(--my-color) }</style>' + const result = await run(html) + + expect(result).toContain('.foo') + expect(result).toContain('color: red') + // var() should be resolved + expect(result).not.toContain('var(--my-color)') + }) + }) + + describe('HTML entity decoding', () => { + it('decodes &quot; to double quotes in CSS selectors', async () => { + const html = '<style>.foo[data=&quot;bar&quot;] { background-image: url(&quot;test.jpg&quot;) }</style>' + const result = await run(html) + + expect(result).toContain('[data="bar"]') + expect(result).toContain('url("test.jpg")') + expect(result).not.toContain('&quot;') + }) + + it('decodes in CSS comments', async () => { + const html = '<style>/* a &amp; b */ .foo { color: red }</style>' + const result = await run(html) + + expect(result).not.toContain('&amp;') + }) + }) + + describe('skip marked style tags', () => { + it('skips style tags marked to be skipped', async () => { + const html = '<style raw>.foo { color: red }</style>' + const result = await run(html) + expect(result).toBe('<style>.foo { color: red }</style>') + }) + + it('processes unmarked style tags but skips marked ones', async () => { + const html = '<style>.process { margin-inline: 10px }</style><style raw>.keep { margin-inline: 10px }</style>' + const result = await run(html) + + // The first style tag should be processed (logical properties lowered) + expect(result).toContain('margin-left: 10px') + expect(result).toContain('margin-right: 10px') + // The raw style tag should be untouched + expect(result).toContain('<style>.keep { margin-inline: 10px }</style>') + }) + }) + + describe('error handling', () => { + it('falls back to decoded content when CSS processing fails', async () => { + // @import that cannot resolve falls back to decoded content + const html = '<style>@import &quot;./nonexistent.css&quot;;</style>' + const result = await run(html) + + // Entity should be decoded even on error + expect(result).toContain('@import "./nonexistent.css"') + expect(result).not.toContain('&quot;') + }) + }) + + describe('short-circuit', () => { + it('returns original HTML when there are no style tags', async () => { + const html = '<div class="text-red-500">Hello</div>' + const result = await run(html) + expect(result).toBe(html) + }) + + it('returns original HTML for empty input', async () => { + const result = await run('') + expect(result).toBe('') + }) + + it('returns original HTML when style tag is empty', async () => { + const html = '<style></style><div>Hello</div>' + const result = await run(html) + expect(result).toBe(html) + }) + + it('returns original HTML when style tag has only whitespace', async () => { + const html = '<style> </style>' + const result = await run(html) + expect(result).toBe(html) + }) + }) + + describe('multiple style tags', () => { + it('processes each style tag independently', async () => { + const html = '<style>.a { margin-inline: 5px }</style><div>mid</div><style>.b { padding-block: 8px }</style>' + const result = await run(html) + + // First style tag + expect(result).toContain('margin-left: 5px') + expect(result).toContain('margin-right: 5px') + // HTML between style tags preserved + expect(result).toContain('<div>mid</div>') + // Second style tag + expect(result).toContain('padding-top: 8px') + expect(result).toContain('padding-bottom: 8px') + }) + }) + + describe('edge cases', () => { + it('preserves HTML outside style tags', async () => { + const html = '<div class="test">Hello</div><style>.foo { color: red }</style><p>World</p>' + const result = await run(html) + + expect(result).toContain('<div class="test">Hello</div>') + expect(result).toContain('<p>World</p>') + }) + + it('handles style tag with type attribute', async () => { + const html = '<style type="text/css">.foo { margin-inline: 10px }</style>' + const result = await run(html) + + expect(result).toContain('margin-left: 10px') + }) + + it('handles media queries', async () => { + const html = '<style>@media (max-width: 600px) { .foo { color: red } }</style>' + const result = await run(html) + + expect(result).toContain('@media') + expect(result).toContain('color: red') + }) + + it('passes filePath to postcss for source mapping', async () => { + const html = '<style>.foo { color: red }</style>' + const result = await run(html, '/path/to/template.vue') + + expect(result).toContain('.foo') + expect(result).toContain('color: red') + }) + }) +}) diff --git a/src/tests/transformers/urlQuery.test.ts b/src/tests/transformers/urlQuery.test.ts new file mode 100644 index 00000000..120141ca --- /dev/null +++ b/src/tests/transformers/urlQuery.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest' +import { urlQuery } from '../../transformers/urlQuery.ts' +import { parse, serialize } from '../../utils/ast/index.ts' +import type { UrlConfig } from '../../types/config.ts' + +function run( + html: string, + params: Record<string, unknown> = { utm_source: 'maizzle' }, + options?: Record<string, unknown>, +): string { + const config: UrlConfig = { + query: options ? { ...params, _options: options } : params, + } + return serialize(urlQuery(parse(html), config)) +} + +// ─── Core behaviour ───────────────────────────────────────────────────────── + +describe('urlQuery — core behaviour', () => { + it('appends a single parameter to an href', () => { + expect(run('<a href="https://example.com">x</a>')) + .toBe('<a href="https://example.com?utm_source=maizzle">x</a>') + }) + + it('appends multiple parameters', () => { + const result = run('<a href="https://example.com">x</a>', { + utm_source: 'maizzle', + utm_campaign: 'launch', + }) + expect(result).toBe('<a href="https://example.com?utm_campaign=launch&utm_source=maizzle">x</a>') + }) + + it('merges with pre-existing query parameters', () => { + expect(run('<a href="https://example.com?foo=bar">x</a>')) + .toBe('<a href="https://example.com?foo=bar&utm_source=maizzle">x</a>') + }) + + it('does not encode special chars by default', () => { + const result = run('<a href="https://example.com">x</a>', { foo: '@Bar@' }) + expect(result).toBe('<a href="https://example.com?foo=@Bar@">x</a>') + }) +}) + +// ─── Strict mode ──────────────────────────────────────────────────────────── + +describe('urlQuery — strict mode', () => { + it('skips non-absolute URLs in strict mode (default)', () => { + const html = '<a href="example.com">x</a>' + expect(run(html)).toBe(html) + }) + + it('processes any string when strict is false', () => { + expect(run('<a href="example.com">x</a>', { foo: 'bar' }, { strict: false })) + .toBe('<a href="example.com?foo=bar">x</a>') + }) + + it('still processes absolute URLs when strict is false', () => { + expect(run('<a href="https://example.com">x</a>', { foo: 'bar' }, { strict: false })) + .toBe('<a href="https://example.com?foo=bar">x</a>') + }) +}) + +// ─── Tags option ───────────────────────────────────────────────────────────── + +describe('urlQuery — _options.tags', () => { + it('only processes <a> tags by default', () => { + const html = '<a href="https://example.com">x</a><img src="https://example.com/img.jpg">' + const result = run(html) + expect(result).toBe('<a href="https://example.com?utm_source=maizzle">x</a><img src="https://example.com/img.jpg">') + }) + + it('processes multiple tag types when specified', () => { + const html = '<a href="https://example.com">x</a><img src="https://example.com/img.jpg">' + const result = run(html, { foo: 'bar' }, { tags: ['a', 'img'] }) + expect(result).toBe( + '<a href="https://example.com?foo=bar">x</a>' + + '<img src="https://example.com/img.jpg?foo=bar">', + ) + }) + + it('supports CSS attribute selector — *= (contains)', () => { + const html = '<a href="https://example.com/path">yes</a><a href="https://other.com">no</a>' + const result = run(html, { foo: 'bar' }, { tags: ['a[href*="example.com"]'] }) + expect(result).toBe( + '<a href="https://example.com/path?foo=bar">yes</a>' + + '<a href="https://other.com">no</a>', + ) + }) + + it('supports CSS attribute selector — ^= (starts with)', () => { + const html = '<a href="https://example.com">yes</a><a href="http://other.com">no</a>' + const result = run(html, { foo: 'bar' }, { tags: ['a[href^="https://example"]'] }) + expect(result).toBe( + '<a href="https://example.com?foo=bar">yes</a>' + + '<a href="http://other.com">no</a>', + ) + }) + + it('supports CSS attribute selector — $= (ends with)', () => { + const html = '<a href="https://example.com/page.html">yes</a><a href="https://example.com/page.php">no</a>' + const result = run(html, { foo: 'bar' }, { tags: ['a[href$=".html"]'] }) + expect(result).toBe( + '<a href="https://example.com/page.html?foo=bar">yes</a>' + + '<a href="https://example.com/page.php">no</a>', + ) + }) + + it('supports CSS attribute selector — = (exact match)', () => { + const html = '<a href="https://example.com">yes</a><a href="https://example.com/page">no</a>' + const result = run(html, { foo: 'bar' }, { tags: ['a[href="https://example.com"]'] }) + expect(result).toBe( + '<a href="https://example.com?foo=bar">yes</a>' + + '<a href="https://example.com/page">no</a>', + ) + }) +}) + +// ─── Attributes option ─────────────────────────────────────────────────────── + +describe('urlQuery — _options.attributes', () => { + it('uses default attributes when not specified', () => { + // href is in defaults, data-url is not + const html = '<a href="https://example.com" data-url="https://example.com">x</a>' + const result = run(html, { foo: 'bar' }) + expect(result).toBe('<a href="https://example.com?foo=bar" data-url="https://example.com">x</a>') + }) + + it('processes src attribute on img when both tags and attributes are configured', () => { + const html = '<a href="https://foo.bar" data-href="https://example.com">x</a><img src="https://example.com">' + const result = run(html, { foo: 'bar' }, { + tags: ['a', 'img'], + attributes: ['data-href', 'src'], + }) + expect(result).toBe( + '<a href="https://foo.bar" data-href="https://example.com?foo=bar">x</a>' + + '<img src="https://example.com?foo=bar">', + ) + }) +}) + +// ─── qs option ──────────────────────────────────────────────────────────────── + +describe('urlQuery — _options.qs', () => { + it('encodes special chars', () => { + const result = run( + '<a href="https://example.com">x</a>', + { foo: '@Bar@' }, + { qs: { encode: true } }, + ) + expect(result).toBe('<a href="https://example.com?foo=%40Bar%40">x</a>') + }) +}) + +// ─── Config: enabled / disabled ────────────────────────────────────────────── + +describe('urlQuery — config', () => { + it('returns html unchanged when query is not set', () => { + const html = '<a href="https://example.com">x</a>' + expect(serialize(urlQuery(parse(html), {}))).toBe(html) + }) + + it('returns html unchanged when query is an empty object', () => { + const html = '<a href="https://example.com">x</a>' + expect(serialize(urlQuery(parse(html), { query: {} }))).toBe(html) + }) + + it('returns html unchanged when query only has _options (no params)', () => { + const html = '<a href="https://example.com">x</a>' + expect(serialize(urlQuery(parse(html), { query: { _options: { tags: ['a'] } } }))).toBe(html) + }) +}) + +// ─── Short-circuit ──────────────────────────────────────────────────────────── + +describe('urlQuery — short-circuit', () => { + it('returns the original string when no attributes are modified', () => { + // No <a> tags at all + const html = '<div><p>Hello</p></div>' + const result = run(html) + expect(result).toBe(html) + }) + + it('returns the original string when hrefs are all non-absolute (strict mode)', () => { + const html = '<a href="page.html">x</a>' + const result = run(html) + expect(result).toBe(html) + }) + + it('returns the original string when the tag is not in the tags list', () => { + const html = '<img src="https://example.com/img.jpg">' + const result = run(html) + expect(result).toBe(html) + }) +}) diff --git a/src/transformers/addAttributes.js b/src/transformers/addAttributes.js deleted file mode 100644 index a2e52578..00000000 --- a/src/transformers/addAttributes.js +++ /dev/null @@ -1,29 +0,0 @@ -import posthtml from 'posthtml' -import { defu as merge } from 'defu' -import addAttributesPlugin from 'posthtml-extra-attributes' - -export default function posthtmlPlugin(attributes = {}) { - const defaultAttributes = { - table: { - cellpadding: 0, - cellspacing: 0, - role: 'none' - }, - img: { - alt: true, - } - } - - // User-defined attributes take precedence - attributes = merge(attributes, defaultAttributes) - - return addAttributesPlugin({ attributes }) -} - -export async function addAttributes(html = '', attributes = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(attributes) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/addAttributes.ts b/src/transformers/addAttributes.ts new file mode 100644 index 00000000..36791c19 --- /dev/null +++ b/src/transformers/addAttributes.ts @@ -0,0 +1,157 @@ +import { defu as merge } from 'defu' +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import type { AttributesConfig } from '../types/config.ts' + +/** + * Default attributes to add to elements. + */ +const DEFAULT_ATTRIBUTES: Record<string, Record<string, string | boolean | number>> = { + table: { + cellpadding: 0, + cellspacing: 0, + role: 'none', + }, + img: { + alt: '', + }, +} + +/** + * Add attributes transformer. + * + * Automatically adds attributes to HTML elements based on CSS selectors. + * + * Default attributes (can be disabled by setting `attributes.add` to false): + * - table: cellpadding="0", cellspacing="0", role="none" + * - img: alt="" + * + * Supports tag, class, id, and attribute selectors. + * Multiple selectors can be specified by comma-separating them. + * + * Examples: + * ```js + * attributes: { + * add: { + * div: { role: 'article' }, + * '.test': { editable: true }, + * '#header': { 'data-id': 'main' }, + * 'div, p': { class: 'content' }, + * } + * } + * ``` + */ +export function addAttributes(dom: ChildNode[], config: AttributesConfig = {}): ChildNode[] { + const addConfig = config.add + + // Disabled when explicitly set to false + if (addConfig === false) { + return dom + } + + // Deep merge user attributes on top of defaults using defu + const userAttributes = typeof addConfig === 'object' ? addConfig : {} + const attributesToAdd = merge(userAttributes, DEFAULT_ATTRIBUTES) as Record<string, Record<string, string | boolean | number>> + + if (Object.keys(attributesToAdd).length === 0) { + return dom + } + + // Process each selector pattern + for (const [selectorPattern, attributes] of Object.entries(attributesToAdd)) { + // Split by comma for multiple selectors + const selectors = selectorPattern.split(',').map(s => s.trim()) + + walk(dom, (node) => { + const el = node as Element + if (!el.name) return + + // Check if element matches any selector in the pattern + const matches = selectors.some(selector => elementMatches(el, selector)) + + if (matches) { + // Initialize attribs if needed + if (!el.attribs) { + el.attribs = {} + } + + for (const [attrName, attrValue] of Object.entries(attributes)) { + // Special handling for class - merge instead of replace + if (attrName === 'class' && el.attribs.class) { + const existingClasses = el.attribs.class.split(/\s+/).filter(Boolean) + const newClasses = String(attrValue).split(/\s+/).filter(Boolean) + const mergedClasses = [...new Set([...existingClasses, ...newClasses])] + if (mergedClasses.join(' ') !== el.attribs.class) { + el.attribs.class = mergedClasses.join(' ') + } + } else { + // Only add attribute if not already present + if (!(attrName in el.attribs)) { + el.attribs[attrName] = String(attrValue) + } + } + } + } + }) + } + + return dom +} + +/** + * Check if an element matches a CSS selector. + * Supports: tag, .class, #id, [attribute], [attribute=value] + */ +function elementMatches(el: Element, selector: string): boolean { + // Remove whitespace + selector = selector.trim() + + // Check for attribute selector [attr] or [attr=value] + const attrMatch = selector.match(/^\[([^\]=]+)(?:=([^\]]*))?\]$/) + if (attrMatch) { + const [, attrName, attrValue] = attrMatch + if (attrValue === undefined) { + // Just checking if attribute exists + return attrName in (el.attribs || {}) + } else { + // Check if attribute has specific value + return el.attribs?.[attrName] === attrValue + } + } + + // Check for class selector .class + if (selector.startsWith('.')) { + const className = selector.slice(1) + const classes = el.attribs?.class?.split(/\s+/) || [] + return classes.includes(className) + } + + // Check for id selector #id + if (selector.startsWith('#')) { + const id = selector.slice(1) + return el.attribs?.id === id + } + + // Check for tag selector (possibly with attribute) + // Split tag from attribute if present, e.g., "div[role=alert]" + const tagAttrMatch = selector.match(/^([a-z][a-z0-9]*)\[([^\]]+)\]$/i) + if (tagAttrMatch) { + const [, tagName, attrPart] = tagAttrMatch + if (el.name !== tagName) return false + + // Parse attribute part: could be "attr" or "attr=value" + const attrEqMatch = attrPart.match(/^([^=]+)(?:=(.*))?$/) + if (attrEqMatch) { + const [, attrName, attrValue] = attrEqMatch + if (attrValue === undefined) { + return attrName in (el.attribs || {}) + } else { + return el.attribs?.[attrName] === attrValue + } + } + return false + } + + // Simple tag selector + return el.name === selector +} diff --git a/src/transformers/attributeToStyle.js b/src/transformers/attributeToStyle.js deleted file mode 100644 index e527ba47..00000000 --- a/src/transformers/attributeToStyle.js +++ /dev/null @@ -1,90 +0,0 @@ -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' -import keys from 'lodash-es/keys.js' -import forEach from 'lodash-es/forEach.js' -import parseAttrs from 'posthtml-attrs-parser' -import intersection from 'lodash-es/intersection.js' - -const posthtmlPlugin = (attributes = []) => tree => { - if (!Array.isArray(attributes)) { - return tree - } - - if (attributes.length === 0) { - return tree - } - - const process = node => { - if (!node.attrs) { - return node - } - - const nodeAttributes = parseAttrs(node.attrs) - const matches = intersection(keys(nodeAttributes), attributes) - const nodeStyle = get(node.attrs, 'style') - const cssToInline = [] - - forEach(matches, attribute => { - let value = get(node.attrs, attribute) - - switch (attribute) { - case 'bgcolor': - cssToInline.push(`background-color: ${value}`) - break - - case 'background': - cssToInline.push(`background-image: url('${value}')`) - break - - case 'width': - value = Number.parseInt(value, 10) + (value.match(/px|%/) || 'px') - cssToInline.push(`width: ${value}`) - break - - case 'height': - value = Number.parseInt(value, 10) + (value.match(/px|%/) || 'px') - cssToInline.push(`height: ${value}`) - break - - case 'align': - if (node.tag !== 'table') { - return cssToInline.push(`text-align: ${value}`) - } - - if (['left', 'right'].includes(value)) { - cssToInline.push(`float: ${value}`) - } - - if (value === 'center') { - cssToInline.push('margin-left: auto', 'margin-right: auto') - } - - break - - case 'valign': - cssToInline.push(`vertical-align: ${value}`) - break - - // No default - } - }) - - nodeAttributes.style = nodeStyle ? `${nodeStyle.split(';').join(';')} ${cssToInline.join('; ')}` : `${cssToInline.join('; ')}` - - node.attrs = nodeAttributes.compose() - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin - -export async function attributeToStyle(html = '', attributes = [], posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(attributes) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/attributeToStyle.ts b/src/transformers/attributeToStyle.ts new file mode 100644 index 00000000..41395f7a --- /dev/null +++ b/src/transformers/attributeToStyle.ts @@ -0,0 +1,126 @@ +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import type { CssConfig } from '../types/config.ts' + +/** + * Default list of attributes that can be converted to inline styles. + */ +const DEFAULT_ATTRIBUTES = ['width', 'height', 'bgcolor', 'background', 'align', 'valign'] + +/** + * Convert HTML attributes to inline CSS styles. + * + * Supported attributes: + * - width: converted to `width: ${value}${unit}` (supports px and %, defaults to px) + * - height: converted to `height: ${value}${unit}` (supports px and %, defaults to px) + * - bgcolor: converted to `background-color: ${value}` + * - background: converted to `background-image: url('${value}')` + * - align: on `<table>` elements, `left`/`right` become `float`, `center` becomes `margin: 0 auto`; + * on other elements, becomes `text-align: ${value}` + * - valign: converted to `vertical-align: ${value}` + * + * Enabled via `config.css.inline.attributeToStyle`: + * - `true`: process all default attributes + * - `false` or `undefined`: disabled (returns html unchanged) + * - `string[]`: process only the specified attributes + */ +export function attributeToStyle(dom: ChildNode[], config: CssConfig = {}): ChildNode[] { + const inline = config.inline + + // Disabled when inline is a boolean or undefined + if (typeof inline !== 'object' || inline === null) { + return dom + } + + const option = inline.attributeToStyle + + // Disabled when not set or explicitly false + if (!option) { + return dom + } + + // Determine which attributes to process + const attributesToProcess: string[] = + option === true + ? DEFAULT_ATTRIBUTES + : Array.isArray(option) + ? option + : [] + + if (attributesToProcess.length === 0) { + return dom + } + + walk(dom, (node) => { + const el = node as Element + + if (!('attribs' in el) || !el.attribs) { + return + } + + const styles: string[] = [] + + for (const attr of attributesToProcess) { + const value = el.attribs[attr] + if (!value) continue + + const styleValue = convertAttributeToStyle(el.name, attr, value) + if (styleValue) { + styles.push(styleValue) + } + } + + // Append new styles to existing style attribute + if (styles.length > 0) { + const existingStyle = el.attribs.style || '' + const separator = existingStyle ? '; ' : '' + el.attribs.style = existingStyle + separator + styles.join('; ') + } + }) + + return dom +} + +/** + * Convert a single HTML attribute value to a CSS style declaration. + */ +function convertAttributeToStyle( + tagName: string, + attr: string, + value: string, +): string | null { + switch (attr) { + case 'width': + case 'height': { + // Support px and % values, default to px if no unit + const normalizedValue = /^\d+$/.test(value) ? `${value}px` : value + return `${attr}: ${normalizedValue}` + } + + case 'bgcolor': + return `background-color: ${value}` + + case 'background': + return `background-image: url('${value}')` + + case 'align': { + // On table elements: left/right -> float, center -> margin auto + if (tagName === 'table') { + if (value === 'left' || value === 'right') { + return `float: ${value}` + } + if (value === 'center') { + return 'margin-left: auto; margin-right: auto' + } + } + // On other elements: text-align + return `text-align: ${value}` + } + + case 'valign': + return `vertical-align: ${value}` + + default: + return null + } +} diff --git a/src/transformers/base.ts b/src/transformers/base.ts new file mode 100644 index 00000000..ab33ecd1 --- /dev/null +++ b/src/transformers/base.ts @@ -0,0 +1,240 @@ +import postcss from 'postcss' +import safeParser from 'postcss-safe-parser' +import valueParser from 'postcss-value-parser' +import { walk, serialize, parse } from '../utils/ast/index.ts' +import { isAbsoluteUrl, defaultTags, processSrcset } from '../utils/url.ts' +import type { ChildNode, Element } from 'domhandler' +import type { UrlConfig } from '../types/config.ts' + +interface BaseUrlOptions { + url: string + tags?: string[] | Record<string, Record<string, string | boolean>> + attributes?: Record<string, string> + styleTag?: boolean + inlineCss?: boolean +} + +const sourceAttributes = ['src', 'href', 'srcset', 'poster', 'background', 'data'] + +/** + * Convert the shared `defaultTags` (tag → string[]) into the richer format + * the transformer needs (tag → Record<attr, true>). + */ +const defaultTagConfig: Record<string, Record<string, string | boolean>> = Object.fromEntries( + Object.entries(defaultTags).map(([tag, attrs]) => [ + tag, + Object.fromEntries(attrs.map(attr => [attr, true])), + ]), +) + +const postcssBaseUrl: postcss.PluginCreator<{ url: string }> = (opts) => { + return { + postcssPlugin: 'postcss-base-url', + Declaration(decl) { + if (!decl.value.includes('url(')) return + + const parsed = valueParser(decl.value) + let changed = false + + parsed.walk(node => { + if (node.type !== 'function' || node.value !== 'url') return + + const urlNode = node.nodes[0] + if (!urlNode) return + + if (isAbsoluteUrl(urlNode.value)) return + + urlNode.value = opts!.url + urlNode.value + changed = true + }) + + if (changed) { + decl.value = parsed.toString() + } + } + } +} +postcssBaseUrl.postcss = true + +function processCss(css: string, url: string): string { + const { css: result } = postcss([postcssBaseUrl({ url })]).process(css, { parser: safeParser, from: undefined }) + return result +} + +function processInlineStyle(style: string, url: string): string { + try { + const { css } = postcss([postcssBaseUrl({ url })]).process(`a{${style}}`, { parser: safeParser, from: undefined }) + const match = css.match(/a\s*\{\s*([\s\S]*?)\s*\}/) + return match?.[1]?.trim() ?? style + } catch { + return style + } +} + +function getBaseUrl(config: UrlConfig): string | BaseUrlOptions | undefined { + const baseUrlConfig = config.base + if (!baseUrlConfig || baseUrlConfig === '') { + return undefined + } + return baseUrlConfig as string | BaseUrlOptions | undefined +} + +function resolveOptions(baseUrlConfig: string | BaseUrlOptions | undefined): BaseUrlOptions | undefined { + if (!baseUrlConfig) return undefined + if (typeof baseUrlConfig === 'string') { + return { url: baseUrlConfig, styleTag: true, inlineCss: true } + } + if (typeof baseUrlConfig === 'object' && 'url' in baseUrlConfig) { + return { + url: baseUrlConfig.url ?? '', + tags: baseUrlConfig.tags, + attributes: baseUrlConfig.attributes, + styleTag: baseUrlConfig.styleTag ?? true, + inlineCss: baseUrlConfig.inlineCss ?? true, + } + } + return undefined +} + +function getTagConfig( + tagName: string, + options: BaseUrlOptions +): Record<string, string | boolean> | undefined { + const { tags } = options + + if (tags === undefined) { + return defaultTagConfig[tagName] + } + + if (Array.isArray(tags)) { + if (!tags.includes(tagName)) return undefined + return defaultTagConfig[tagName] + } + + if (typeof tags === 'object') { + return tags[tagName] + } + + return undefined +} + +export function base(dom: ChildNode[], config: UrlConfig = {}): ChildNode[] { + const baseUrlConfig = getBaseUrl(config) + const options = resolveOptions(baseUrlConfig) + + if (!options || !options.url) { + return dom + } + + const { url: baseUrl, styleTag = true, inlineCss = true, attributes = {} } = options + + walk(dom, (node) => { + const el = node as Element + if (!el.name) return + + // Process <style> tag content with PostCSS + if (el.name === 'style' && styleTag && el.children) { + for (const child of el.children) { + if (child.type === 'text') { + const textNode = child as unknown as { data: string } + const processed = processCss(textNode.data, baseUrl) + if (processed !== textNode.data) { + textNode.data = processed + } + } + } + return + } + + if (!el.attribs) return + + // Process tag-specific attributes (respects tags filter) + const tagConfig = getTagConfig(el.name, options) + + if (tagConfig || options.tags === undefined) { + for (const [attr, value] of Object.entries(el.attribs)) { + if (!value) continue + + const attrConfig = tagConfig?.[attr] + if (!attrConfig && attr !== 'style') continue + + if (attr === 'srcset' && (attrConfig === true || typeof attrConfig === 'string')) { + const newSrcset = processSrcset(value, typeof attrConfig === 'string' ? attrConfig : baseUrl) + if (newSrcset !== value) { + el.attribs.srcset = newSrcset + } + } else if (attr === 'style' && inlineCss && value.includes('url(')) { + const newStyle = processInlineStyle(value, baseUrl) + if (newStyle !== value) { + el.attribs.style = newStyle + } + } else if (attrConfig === true && !isAbsoluteUrl(value)) { + el.attribs[attr] = baseUrl + value + } else if (typeof attrConfig === 'string' && !isAbsoluteUrl(value)) { + el.attribs[attr] = attrConfig + value + } + } + } + + // Process custom attributes (not affected by tags filter) + for (const [attr, url] of Object.entries(attributes)) { + if (el.attribs[attr] && !isAbsoluteUrl(el.attribs[attr])) { + el.attribs[attr] = url + el.attribs[attr] + } + } + }) + + // VML and MSO comment rewrites require operating on serialized HTML + // (HTML comments are not represented as traversable DOM nodes) + const serialized = serialize(dom) + const rewritten = rewriteMsoComments(rewriteVMLs(serialized, baseUrl), baseUrl) + + // Only re-parse if the regex passes actually changed anything + if (rewritten !== serialized) { + return parse(rewritten) + } + + return dom +} + +function rewriteVMLs(html: string, url: string): string { + html = html.replace(/<v:image[^>]+src="?([^"\s]+)"/gi, (match, src) => { + if (isAbsoluteUrl(src)) return match + return match.replace(src, url + src) + }) + + html = html.replace(/<v:fill[^>]+src="?([^"\s]+)"/gi, (match, src) => { + if (isAbsoluteUrl(src)) return match + return match.replace(src, url + src) + }) + + return html +} + +function rewriteMsoComments(html: string, url: string): string { + return html.replace(/<!--\[if [^\]]+\]>[\s\S]*?<!\[endif\]-->/g, (msoBlock) => { + let result = msoBlock + + for (const attr of sourceAttributes) { + const attrRegex = new RegExp(`\\b${attr}="([^"]+)"`, 'gi') + result = result.replace(attrRegex, (match, value) => { + if (isAbsoluteUrl(value)) return match + + if (attr === 'srcset') { + return `srcset="${processSrcset(value, url)}"` + } + + return `${attr}="${url}${value}"` + }) + } + + // Use PostCSS for style attribute url() rewriting inside MSO comments + result = result.replace(/style="([^"]+)"/gi, (match, style) => { + if (!style.includes('url(')) return match + const processed = processInlineStyle(style, url) + return `style="${processed}"` + }) + + return result + }) +} diff --git a/src/transformers/baseUrl.js b/src/transformers/baseUrl.js deleted file mode 100644 index eb4f4d0b..00000000 --- a/src/transformers/baseUrl.js +++ /dev/null @@ -1,154 +0,0 @@ -import posthtml from 'posthtml' -import isUrl from 'is-url-superb' -import get from 'lodash-es/get.js' -import { render } from 'posthtml-render' -import isEmpty from 'lodash-es/isEmpty.js' -import isObject from 'lodash-es/isObject.js' -import { parser as parse } from 'posthtml-parser' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' -import baseUrl, { parseSrcset, stringifySrcset, defaultTags } from 'posthtml-base-url' - -const posthtmlOptions = getPosthtmlOptions() - -const posthtmlPlugin = url => tree => { - // Handle `baseURL` as a string - if (typeof url === 'string' && url.length > 0) { - const html = rewriteVMLs(render(tree), url) - - return baseUrl({ - url, - allTags: true, - styleTag: true, - inlineCss: true - })(parse(html, posthtmlOptions)) - } - - // Handle `baseURL` as an object - if (isObject(url) && !isEmpty(url)) { - const html = rewriteVMLs(render(tree), get(url, 'url', '')) - const { - styleTag = true, - inlineCss = true, - allTags, - tags, - url: baseURL, - } = url - - return baseUrl({ - styleTag, - inlineCss, - allTags, - tags, - url: baseURL, - })(parse(html, posthtmlOptions)) - } - - return tree -} - -export default posthtmlPlugin - -export async function addBaseUrl(html = '', options = {}, posthtmlOpts = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, getPosthtmlOptions(posthtmlOpts)) - .then(result => result.html) -} - -/** - * Handle VML - * - * VML backgrounds must be handled with regex because - * they're inside HTML comments. - * - * @param {string} html The HTML content - * @param {string} url The base URL to prepend - * @returns {string} The modified HTML - */ -const rewriteVMLs = (html, url) => { - // Handle <v:image> - const vImageMatches = html.match(/<v:image[^>]+src="?([^"\s]+)"/g) - - if (vImageMatches) { - vImageMatches.forEach(match => { - const vImage = match.match(/<v:image[^>]+src="?([^"\s]+)"/) - const vImageSrc = vImage[1] - - if (!isUrl(vImageSrc)) { - const vImageSrcUrl = url + vImageSrc - const vImageReplace = vImage[0].replace(vImageSrc, vImageSrcUrl) - html = html.replace(vImage[0], vImageReplace) - } - }) - } - - // Handle <v:fill> - const vFillMatches = html.match(/<v:fill[^>]+src="?([^"\s]+)"/g) - - if (vFillMatches) { - vFillMatches.forEach(match => { - const vFill = match.match(/<v:fill[^>]+src="?([^"\s]+)"/) - const vFillSrc = vFill[1] - - if (!isUrl(vFillSrc)) { - const vFillSrcUrl = url + vFillSrc - const vFillReplace = vFill[0].replace(vFillSrc, vFillSrcUrl) - html = html.replace(vFill[0], vFillReplace) - } - }) - } - - /** - * Handle other sources inside MSO comments - */ - - // Make a pipe-separated list of all the default tags and use it to create a regex - const uniqueSourceAttributes = [ - ...new Set(Object.values(defaultTags).flatMap(Object.keys)) - ].join('|') - - /** - * This regex uses a negative lookbehind to avoid matching VML elements - * like <v:image> and <v:fill>, which are already handled above. - */ - const sourceAttrRegex = new RegExp(`(?<!<v:image|fill[^>]*]*)\\b(${uniqueSourceAttributes})="([^"]+)"`, 'g') - - // Replace all the source attributes inside MSO comments - html = html.replace(/<!--\[if [^\]]+\]>[\s\S]*?<!\[endif\]-->/g, (msoBlock) => { - return msoBlock.replace(sourceAttrRegex, (match, attr, value) => { - if (isUrl(value)) { - return match - } - - const updatedValue = attr === 'srcset' - ? processSrcset(value, url) - : url + value - - return `${attr}="${updatedValue}"` - }) - }) - - return html -} - -/** - * Add the base URL to the srcset URLs - * - * @param {*} srcsetValue The value of the srcset attribute - * @param {*} url The base URL - * @returns {string} The updated srcset attribute value - */ -function processSrcset(srcsetValue, url) { - const parsed = parseSrcset(srcsetValue) - - parsed.map(p => { - if (!isUrl(p.url)) { - p.url = url + p.url - } - - return p - }) - - return stringifySrcset(parsed) -} diff --git a/src/transformers/core.js b/src/transformers/core.js deleted file mode 100644 index 19601189..00000000 --- a/src/transformers/core.js +++ /dev/null @@ -1,32 +0,0 @@ -const posthtmlPlugin = (config = {}) => tree => { - const process = node => { - /** - * Remove plaintext tags when developing locally - */ - if ( - config._dev - && node.tag === 'plaintext' - ) { - node.tag = false - node.content = [''] - } - - /** - * Custom attributes to prevent inlining CSS from <style> tags - */ - if ( - node.tag === 'style' - && (node.attrs?.['no-inline'] || node.attrs?.embed) - ) { - node.attrs['no-inline'] = false - node.attrs.embed = false - node.attrs['data-embed'] = true - } - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin diff --git a/src/transformers/entities.ts b/src/transformers/entities.ts new file mode 100644 index 00000000..7ca750e1 --- /dev/null +++ b/src/transformers/entities.ts @@ -0,0 +1,47 @@ +import { defu as merge } from 'defu' +import { walk } from '../utils/ast/index.ts' +import type { ChildNode } from 'domhandler' +import type { EntitiesConfig } from '../types/index.ts' + +const DEFAULT_ENTITIES: Record<string, string> = { + '\u200D': '&zwj;', + '\u200C': '&zwnj;', + '\u00A0': '&nbsp;', + '\u00AD': '&shy;', + '\u200B': '&#8203;', + '\u2007': '&#8199;', + '\u034F': '&#847;', + '\u2003': '&emsp;', + '\u2028': '&LineSeparator;', + '\u2029': '&ParagraphSeparator;', + '\u00B7': '&middot;', + '\u2013': '&ndash;', + '\u2014': '&mdash;', + '\u2018': '&lsquo;', + '\u2019': '&rsquo;', + '\u201C': '&ldquo;', + '\u201D': '&rdquo;', + '\u00AB': '&laquo;', + '\u00BB': '&raquo;', + '\u2022': '&bull;', + '\u2039': '&lsaquo;', + '\u203A': '&rsaquo;' +} + +export function entities(dom: ChildNode[], config: EntitiesConfig = true): ChildNode[] { + if (!config) return dom + + const map = typeof config === 'object' + ? merge(config as Record<string, string>, DEFAULT_ENTITIES) + : DEFAULT_ENTITIES + + walk(dom, (node) => { + if (node.type === 'text') { + for (const [char, entity] of Object.entries(map)) { + node.data = node.data.split(char).join(entity) + } + } + }) + + return dom +} diff --git a/src/transformers/filters/defaultFilters.js b/src/transformers/filters/defaultFilters.js deleted file mode 100644 index 528ae359..00000000 --- a/src/transformers/filters/defaultFilters.js +++ /dev/null @@ -1,146 +0,0 @@ -const append = (content, attribute) => content + attribute - -const capitalize = content => content.charAt(0).toUpperCase() + content.slice(1) - -const ceil = content => Math.ceil(Number.parseFloat(content)) - -const divide = (content, attribute) => Number.parseFloat(content) / Number.parseFloat(attribute) - -const escapeMap = { - '&': '&amp;', - '<': '&lt;', - '>': '&gt;', - '"': '&#34;', - '\'': '&#39;' -} -// biome-ignore lint: not confusing -const escape = content => content.replace(/["&'<>]/g, m => escapeMap[m]) - -const escapeOnce = content => escape(unescape(content)) - -const floor = content => Math.floor(Number.parseFloat(content)) - -const lowercase = content => content.toLowerCase() - -const lstrip = content => content.replace(/^\s+/, '') - -const minus = (content, attribute) => Number.parseFloat(content) - Number.parseFloat(attribute) - -const modulo = (content, attribute) => Number.parseFloat(content) % Number.parseFloat(attribute) - -const multiply = (content, attribute) => Number.parseFloat(content) * Number.parseFloat(attribute) - -const newlineToBr = content => content.replace(/\r?\n/g, '<br>') - -const plus = (content, attribute) => Number.parseFloat(content) + Number.parseFloat(attribute) - -const prepend = (content, attribute) => attribute + content - -const remove = (content, attribute) => { - const regex = new RegExp(attribute, 'g') - return content.replace(regex, '') -} - -const removeFirst = (content, attribute) => content.replace(attribute, '') - -const replace = (content, attribute) => { - const [search, replace] = attribute.split('|') - const regex = new RegExp(search, 'g') - return content.replace(regex, replace) -} - -const replaceFirst = (content, attribute) => { - const [search, replace] = attribute.split('|') - return content.replace(search, replace) -} - -const round = content => Math.round(Number.parseFloat(content)) - -const rstrip = content => content.replace(/\s+$/, '') - -const uppercase = content => content.toUpperCase() - -const size = content => content.length - -const slice = (content, attribute) => { - const [start, end] = attribute.split(',') - - if (!end && !start) { - return content - } - - if (!end) { - return content.slice(attribute) - } - - return content.slice(start, end) -} - -const stripNewlines = content => content.replace(/\r?\n/g, '') - -const trim = content => content.trim() - -const truncate = (content, attribute) => { - const [length, omission] = attribute.split(',') - - return content && content.length > Number.parseInt(length, 10) - ? content.slice(0, length) + (omission || '...') - : content // content is shorter than length required to truncate -} - -const truncateWords = (content, attribute) => { - const [length, omission] = attribute.split(',') - - return content.split(' ') - .slice(0, Number.parseInt(length, 10)) - .join(' ') + (omission || '...') -} - -const unescapeMap = { - '&amp;': '&', - '&lt;': '<', - '&gt;': '>', - '&#34;': '"', - '&#39;': '\'' -} -// biome-ignore lint: not confusing -const unescape = string => string.replace(/&(amp|lt|gt|#34|#39);/g, m => unescapeMap[m]) - -const urlDecode = content => content.split('+').map(decodeURIComponent).join(' ') - -const urlEncode = content => content.split(' ').map(encodeURIComponent).join('+') - -export const filters = { - append, - capitalize, - ceil, - 'divide-by': divide, - escape, - 'escape-once': escapeOnce, - floor, - lowercase, - lstrip, - minus, - modulo, - multiply, - 'newline-to-br': newlineToBr, - plus, - prepend, - remove, - 'remove-first': removeFirst, - replace, - 'replace-first': replaceFirst, - round, - rstrip, - uppercase, - size, - slice, - 'strip-newlines': stripNewlines, - times: multiply, - trim, - truncate, - 'truncate-words': truncateWords, - 'url-decode': urlDecode, - 'url-encode': urlEncode, - unescape -} diff --git a/src/transformers/filters/defaults.ts b/src/transformers/filters/defaults.ts new file mode 100644 index 00000000..133e3eb0 --- /dev/null +++ b/src/transformers/filters/defaults.ts @@ -0,0 +1,91 @@ +export type FilterFunction = (str: string, value: string) => string + +const escapeMap: Record<string, string> = { + '"': '&#34;', + '&': '&amp;', + "'": '&#39;', + '<': '&lt;', + '>': '&gt;', +} + +const escapeRegex = /["&'<>]/g + +function escapeHtml(str: string): string { + return str.replace(escapeRegex, ch => escapeMap[ch]) +} + +export const defaults: Record<string, FilterFunction> = { + append: (str, value) => str + value, + prepend: (str, value) => value + str, + uppercase: str => str.toUpperCase(), + lowercase: str => str.toLowerCase(), + capitalize: str => str.charAt(0).toUpperCase() + str.slice(1), + ceil: str => String(Math.ceil(Number.parseFloat(str))), + floor: str => String(Math.floor(Number.parseFloat(str))), + round: str => String(Math.round(Number.parseFloat(str))), + escape: str => escapeHtml(str), + 'escape-once': str => { + const decoded = str + .replace(/&amp;/g, '&') + .replace(/&lt;/g, '<') + .replace(/&gt;/g, '>') + .replace(/&#34;/g, '"') + .replace(/&quot;/g, '"') + .replace(/&#39;/g, "'") + .replace(/&apos;/g, "'") + + return escapeHtml(decoded) + }, + lstrip: str => str.trimStart(), + rstrip: str => str.trimEnd(), + trim: str => str.trim(), + minus: (str, value) => String(Number.parseFloat(str) - Number.parseFloat(value)), + plus: (str, value) => String(Number.parseFloat(str) + Number.parseFloat(value)), + multiply: (str, value) => String(Number.parseFloat(str) * Number.parseFloat(value)), + times: (str, value) => String(Number.parseFloat(str) * Number.parseFloat(value)), + 'divide-by': (str, value) => String(Number.parseFloat(str) / Number.parseFloat(value)), + divide: (str, value) => String(Number.parseFloat(str) / Number.parseFloat(value)), + modulo: (str, value) => String(Number.parseFloat(str) % Number.parseFloat(value)), + 'newline-to-br': str => str.replace(/\n/g, '<br>'), + 'strip-newlines': str => str.replace(/\n/g, ''), + remove: (str, value) => str.split(value).join(''), + 'remove-first': (str, value) => { + const i = str.indexOf(value) + return i === -1 ? str : str.slice(0, i) + str.slice(i + value.length) + }, + replace: (str, value) => { + const [search = '', replacement = ''] = value.split('|') + return str.split(search).join(replacement) + }, + 'replace-first': (str, value) => { + const [search = '', replacement = ''] = value.split('|') + const i = str.indexOf(search) + return i === -1 ? str : str.slice(0, i) + replacement + str.slice(i + search.length) + }, + size: str => String(str.length), + slice: (str, value) => { + const args = value.split(',').map(s => Number.parseInt(s.trim(), 10)) + return str.slice(args[0], args[1]) + }, + truncate: (str, value) => { + const commaIndex = value.indexOf(',') + const length = Number.parseInt(commaIndex === -1 ? value : value.slice(0, commaIndex), 10) + const ellipsis = commaIndex === -1 ? '...' : value.slice(commaIndex + 1) + + if (str.length <= length) return str + + return str.slice(0, length) + ellipsis + }, + 'truncate-words': (str, value) => { + const commaIndex = value.indexOf(',') + const count = Number.parseInt(commaIndex === -1 ? value : value.slice(0, commaIndex), 10) + const ellipsis = commaIndex === -1 ? '...' : value.slice(commaIndex + 1) + const words = str.split(/\s+/).filter(Boolean) + + if (words.length <= count) return str + + return words.slice(0, count).join(' ') + ellipsis + }, + 'url-decode': str => decodeURIComponent(str.replace(/\+/g, ' ')), + 'url-encode': str => encodeURIComponent(str), +} diff --git a/src/transformers/filters/index.js b/src/transformers/filters/index.js deleted file mode 100644 index 4ce1beb9..00000000 --- a/src/transformers/filters/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import posthtml from 'posthtml' -import { defu as merge } from 'defu' -import posthtmlContent from 'posthtml-content' -import { filters as defaultFilters } from './defaultFilters.js' - -export default function posthtmlPlugin(filters = {}) { - filters = merge(defaultFilters, filters) - - return posthtmlContent(filters) -} - -export async function filters(html = '', filters = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(filters) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/filters/index.ts b/src/transformers/filters/index.ts new file mode 100644 index 00000000..1d5e8fe1 --- /dev/null +++ b/src/transformers/filters/index.ts @@ -0,0 +1,87 @@ +import { Text } from 'domhandler' +import type { ChildNode, Element } from 'domhandler' +import { parse, serialize } from '../../utils/ast/index.ts' +import { defaults } from './defaults.ts' + +export type { FilterFunction } from './defaults.ts' +export type FiltersConfig = false | Record<string, (str: string, value: string) => string> + +/** + * Process children before parents so nested filter elements work correctly. + */ +function walkBottomUp(nodes: ChildNode[], callback: (node: ChildNode) => void): void { + for (const node of [...nodes]) { + if ('children' in node && node.children?.length) { + walkBottomUp(node.children as ChildNode[], callback) + } + + callback(node) + } +} + +/** + * Filters transformer. + * + * Applies transformation functions to the content of elements that + * have matching filter attributes. Multiple filters on the same element + * are executed in the order the attributes are defined. + * + * Default filters include string manipulation (uppercase, lowercase, trim, etc.), + * math operations (plus, minus, multiply, etc.), and more. + * + * Custom filters can be added via config, and will be merged with defaults. + * Set config to `false` to disable all filters. + */ +export function filters(dom: ChildNode[], config: FiltersConfig = {}): ChildNode[] { + if (config === false) return dom + + const allFilters = { ...defaults, ...config } + const filterNames = new Set(Object.keys(allFilters)) + + walkBottomUp(dom, (node) => { + const el = node as Element + + if (!el.attribs) return + + // Collect matching filter attributes in source order + const matched: Array<{ name: string; value: string }> = [] + + for (const attr of Object.keys(el.attribs)) { + if (filterNames.has(attr)) { + matched.push({ name: attr, value: el.attribs[attr] }) + } + } + + if (matched.length === 0) return + + // Serialize children to get innerHTML + let content = serialize(el.children as ChildNode[]) + + // Apply each filter in attribute order + for (const { name, value } of matched) { + content = allFilters[name](content, value) + delete el.attribs[name] + } + + // Replace children with the filtered content + if (content === '') { + el.children = [] + } else if (/<[a-z/!]/i.test(content)) { + // Result contains HTML elements — parse back to DOM + const newChildren = parse(content) + + for (const child of newChildren) { + child.parent = el as any + } + + el.children = newChildren as ChildNode[] + } else { + // Text-only result — create a text node directly to preserve entity strings + const textNode = new Text(content) + textNode.parent = el as any + el.children = [textNode] + } + }) + + return dom +} diff --git a/src/transformers/format.ts b/src/transformers/format.ts new file mode 100644 index 00000000..2ebcc921 --- /dev/null +++ b/src/transformers/format.ts @@ -0,0 +1,31 @@ +import { format as oxfmt } from 'oxfmt' +import { defu as merge } from 'defu' +import type { FormatOptions } from 'oxfmt' +import type { MaizzleConfig } from '../types/config.ts' + +const DEFAULT_OPTIONS: FormatOptions = { + printWidth: 320, + htmlWhitespaceSensitivity: 'ignore', + embeddedLanguageFormatting: 'off', +} + +/** + * Format transformer. + * + * Formats the HTML string using `oxfmt`. Accepts all oxfmt `FormatOptions`. + * + * Enable by setting `html.format: true` (or passing options). + * User options are merged on top of the defaults. + */ +export async function format(html: string, config: MaizzleConfig = {}): Promise<string> { + const option = config.html?.format + + if (!option) return html + + const userOptions: FormatOptions = typeof option === 'object' ? option : {} + const options = merge(userOptions, DEFAULT_OPTIONS) + + const result = await oxfmt('input.html', html, options) + + return result.code +} diff --git a/src/transformers/index.js b/src/transformers/index.js deleted file mode 100644 index c293cc12..00000000 --- a/src/transformers/index.js +++ /dev/null @@ -1,259 +0,0 @@ -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' -import { defu as merge } from 'defu' - -import core from './core.js' -import purge from './purge.js' -import sixHex from './sixHex.js' -import minify from './minify.js' -import baseUrl from './baseUrl.js' -import inlineCSS from './inline.js' -import prettify from './prettify.js' -import templateTag from './template.js' -import filters from './filters/index.js' -import markdown from 'posthtml-markdownit' -import posthtmlMso from './posthtmlMso.js' -import shorthandCss from './shorthandCss.js' -import preventWidows from './preventWidows.js' -import addAttributes from './addAttributes.js' -import urlParameters from './urlParameters.js' -import safeClassNames from './safeClassNames.js' -import replaceStrings from './replaceStrings.js' -import attributeToStyle from './attributeToStyle.js' -import removeAttributes from './removeAttributes.js' - -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -/** - * Use Maizzle Transformers on an HTML string. - * - * Only Transformers that are enabled in the passed - * `config` parameter will be used. - * - * @param {string} html The HTML content - * @param {object} config The Maizzle config object - * @returns {Promise<{ html: string }>} A Promise resolving to an object containing the modified HTML - */ -export async function run(html = '', config = {}) { - const posthtmlPlugins = [] - - const posthtmlConfig = getPosthtmlOptions(get(config, 'posthtml.options', {})) - - /** - * 1. Core transformers - * - * Transformers that are always enabled. - * - */ - posthtmlPlugins.push(core(config)) - - /** - * 2. Safe class names - * - * Rewrite Tailwind CSS class names to email-safe alternatives, - * unless explicitly disabled. - */ - if (get(config, 'css.safe') !== false) { - posthtmlPlugins.push( - safeClassNames(get(config, 'css.safe', {})) - ) - } - - /** - * 3. Filters - * - * Filters are always applied, unless explicitly disabled. - */ - if (get(config, 'filters') !== false) { - posthtmlPlugins.push( - filters(get(config, 'filters', {})) - ) - } - - /** - * 4. Markdown - * - * Convert Markdown to HTML with markdown-it, unless explicitly disabled. - */ - if (get(config, 'markdown') !== false) { - posthtmlPlugins.push( - markdown(get(config, 'markdown', {})) - ) - } - - /** - * 5. Prevent widow words - * - * Enabled by default, will prevent widow words in elements - * wrapped with a `prevent-widows` attribute. - */ - if (get(config, 'widowWords') !== false) { - posthtmlPlugins.push( - preventWidows(get(config, 'widowWords', {})) - ) - } - - /** - * 6. Attribute to `style` - * - * Duplicate HTML attributes to inline CSS. - */ - if (get(config, 'css.inline.attributeToStyle')) { - posthtmlPlugins.push( - attributeToStyle(get(config, 'css.inline.attributeToStyle', [])) - ) - } - - /** - * 7. Inline CSS - * - * Inline CSS into HTML. - */ - if (get(config, 'css.inline')) { - posthtmlPlugins.push(inlineCSS( - merge( - get(config, 'css.inline', {}), - { - removeInlinedSelectors: true, - } - ) - )) - } - - /** - * 8. Remove attributes - * - * Remove attributes from HTML tags - * If `undefined`, removes empty `style` and `class` attributes - */ - if (get(config, 'attributes.remove') !== false) { - posthtmlPlugins.push( - removeAttributes( - get(config, 'attributes.remove', []), - posthtmlConfig - ) - ) - } - - /** - * 9. Shorthand CSS - * - * Convert longhand CSS properties to shorthand in `style` attributes. - */ - if (get(config, 'css.shorthand')) { - posthtmlPlugins.push( - shorthandCss(get(config, 'css.shorthand', {})) - ) - } - - /** - * 10. Add attributes - * - * Add attributes to HTML tags. - */ - if (get(config, 'attributes.add') !== false) { - posthtmlPlugins.push( - addAttributes(get(config, 'attributes.add', {})) - ) - } - - /** - * 11. Base URL - * - * Add a base URL to relative paths. - */ - const baseConfig = get(config, 'baseURL', get(config, 'baseUrl')) - if (baseConfig) { - posthtmlPlugins.push( - baseUrl(baseConfig) - ) - } - - /** - * 12. URL parameters - * - * Add parameters to URLs. - */ - if (get(config, 'urlParameters')) { - posthtmlPlugins.push( - urlParameters(get(config, 'urlParameters', {})) - ) - } - - /** - * 13. Six-digit HEX - * - * Enabled by default, converts three-digit HEX colors to six-digit. - */ - if (get(config, 'css.sixHex') !== false) { - posthtmlPlugins.push( - sixHex() - ) - } - - /** - * 14. PostHTML MSO - * - * Enabled by default, simplifies writing MSO conditionals for Outlook. - */ - if (get(config, 'outlook') !== false) { - posthtmlPlugins.push( - posthtmlMso(get(config, 'outlook', {})) - ) - } - - /** - * 15. Purge CSS - * - * Remove unused CSS, uglify classes etc. - */ - if (get(config, 'css.purge')) { - posthtmlPlugins.push(purge(config.css.purge)) - } - - /** - * 16. <template> tags - * - * Replace <template> tags with their content. - */ - posthtmlPlugins.push(templateTag()) - - /** - * 17. Replace strings - * - * Replace strings through regular expressions. - */ - if (get(config, 'replaceStrings')) { - posthtmlPlugins.push( - replaceStrings(get(config, 'replaceStrings', {})) - ) - } - - /** - * 18. Prettify - * - * Pretty-print HTML using js-beautify. - */ - if (get(config, 'prettify')) { - posthtmlPlugins.push( - prettify(get(config, 'prettify', {})) - ) - } - - /** - * 19. Minify - * - * Minify HTML using html-crush. - */ - if (get(config, 'minify')) { - posthtmlPlugins.push( - minify(get(config, 'minify', {})) - ) - } - - return posthtml(posthtmlPlugins) - .process(html, posthtmlConfig) - .then(result => ({ - html: result.html, - })) -} diff --git a/src/transformers/index.ts b/src/transformers/index.ts new file mode 100644 index 00000000..c078bcdc --- /dev/null +++ b/src/transformers/index.ts @@ -0,0 +1,125 @@ +import { parse, serialize } from '../utils/ast/index.ts' +import { inlineLink } from './inlineLink.ts' +import { tailwindcss } from './tailwindcss.ts' +import { safeClassNames } from './safeClassNames.ts' +import { attributeToStyle } from './attributeToStyle.ts' +import { inlineCSS } from './inlineCSS.ts' +import { removeAttributes } from './removeAttributes.ts' +import { shorthandCSS } from './shorthandCSS.ts' +import { addAttributes } from './addAttributes.ts' +import { filters } from './filters/index.ts' +import { base } from './base.ts' +import { entities } from './entities.ts' +import { urlQuery } from './urlQuery.ts' +import { purgeCSS } from './purgeCSS.ts' +import { replaceStrings } from './replaceStrings.ts' +import { format } from './format.ts' +import { minify } from './minify.ts' +import type { MaizzleConfig } from '../types/config.ts' + +/** + * Run all Maizzle transformers on the rendered HTML. + * + * The HTML is parsed into a DOM once at the start and passed through all + * DOM-based transformers as a shared `ChildNode[]`. After all DOM transformers + * complete, the DOM is serialized back to a string exactly once. + * + * String-only transformers (those that rely on external tools that require a + * raw HTML string) then run on the serialized output. + * + * Transformers run in a specific order: + * 0. Inline link stylesheets — replace `<link rel="stylesheet">` with `<style>` tags + * 1. Tailwind CSS — compile CSS, lower syntax, optimize (cleanup + merge media queries) + * 2. Safe class names + * 3. Attribute to style + * 4. CSS inliner + * 5. Remove attributes + * 6. Shorthand CSS + * 7. Add attributes + * 8. Filters + * 9. Base URL + * 10. URL query + * 11. Purge CSS (serializes/parses internally around email-comb) + * 12. Entities + * + Vue-generated comments stripped here (on serialized string) + * 13. Replace strings + * 14. Prettify + * 15. Minify + */ +export async function runTransformers( + html: string, + config: MaizzleConfig, + filePath?: string, + doctype?: string, +): Promise<string> { + // Parse once — all DOM transformers share this array + let dom = parse(html) + + // 0. Inline <link> stylesheets + dom = await inlineLink(dom, filePath) + + // 1. Tailwind CSS — always runs first + dom = await tailwindcss(dom, config, filePath) + + // 2. Safe class names + dom = safeClassNames(dom, config.css) + + // 3. Attribute to style + dom = attributeToStyle(dom, config.css) + + // 4. CSS inliner (serializes/parses internally around juice) + dom = inlineCSS(dom, config.css) + + // 5. Remove attributes + dom = removeAttributes(dom, config.html?.attributes) + + // 6. Shorthand CSS + dom = shorthandCSS(dom, config.css) + + // 7. Add attributes + dom = addAttributes(dom, config.html?.attributes) + + // 8. Filters + dom = filters(dom, config.filters) + + // 9. Base URL (serializes/parses internally for VML/MSO regex passes) + dom = base(dom, config.url) + + // 10. URL query + dom = urlQuery(dom, config.url) + + // 11. Purge CSS (serializes/parses internally around email-comb) + dom = purgeCSS(dom, config.css) + + // 12. Entities + dom = entities(dom, config.html?.decodeEntities) + + // Serialize once — remaining transformers operate on the HTML string + const isXhtml = doctype ? /xhtml/i.test(doctype) : false + let result = serialize(dom, { selfClosingTags: isXhtml }) + + // Remove Vue-generated comments after serializing + result = result + .replaceAll('<!--[-->', '') + .replaceAll('<!--]-->', '') + .replaceAll('<!--teleport start anchor-->', '') + .replaceAll('<!--teleport anchor-->', '') + .replaceAll('<!--teleport start-->', '') + .replaceAll('<!--teleport end-->', '') + + // 13. Replace strings + result = replaceStrings(result, config) + + // 14. Format + result = await format(result, config) + + // 15. Minify + result = minify(result, config) + + // Strip self-closing slashes for HTML5 doctypes + if (!isXhtml) { + result = result.replace(/ \/>/g, '>') + } + + return result +} diff --git a/src/transformers/inline.js b/src/transformers/inline.js deleted file mode 100644 index 72666c6d..00000000 --- a/src/transformers/inline.js +++ /dev/null @@ -1,167 +0,0 @@ -import juice from 'juice' -import postcss from 'postcss' -import get from 'lodash-es/get.js' -import * as cheerio from 'cheerio/slim' -import { render } from 'posthtml-render' -import isEmpty from 'lodash-es/isEmpty.js' -import safeParser from 'postcss-safe-parser' -import isObject from 'lodash-es/isObject.js' -import { parser as parse } from 'posthtml-parser' -import { useAttributeSizes } from './useAttributeSizes.js' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -const posthtmlPlugin = (options = {}) => tree => { - return inline(render(tree), options).then(html => parse(html, getPosthtmlOptions())) -} - -export default posthtmlPlugin - -export async function inline(html = '', options = {}) { - // Exit early if no HTML is passed - if (typeof html !== 'string' || html === '') { - return html - } - - const removeStyleTags = get(options, 'removeStyleTags', false) - const css = get(options, 'customCSS', false) - - options.removeInlinedSelectors = get(options, 'removeInlinedSelectors', true) - options.preferUnitlessValues = get(options, 'preferUnitlessValues', true) - options.preservedSelectors = get(options, 'safelist', []) - - juice.styleToAttribute = get(options, 'styleToAttribute', {}) - juice.applyWidthAttributes = get(options, 'applyWidthAttributes', true) - juice.applyHeightAttributes = get(options, 'applyHeightAttributes', true) - juice.excludedProperties.push(...get(options, 'excludedProperties', [])) - juice.widthElements = get(options, 'widthElements', ['img', 'video']).map(i => i.toUpperCase()) - juice.heightElements = get(options, 'heightElements', ['img', 'video']).map(i => i.toUpperCase()) - - if (isObject(options.codeBlocks) && !isEmpty(options.codeBlocks)) { - Object.entries(options.codeBlocks).forEach(([k, v]) => { - juice.codeBlocks[k] = v - }) - } - - const $ = cheerio.load(html, { - xml: { - decodeEntities: false, - xmlMode: false, - } - }) - - // Add a `data-embed` attribute to style tags that have the embed attribute - $('style[embed]:not([data-embed])').each((_i, el) => { - $(el).attr('data-embed', '') - }) - $('style[data-embed]:not([embed])').each((_i, el) => { - $(el).attr('embed', '') - }) - - /** - * Inline the CSS - * - * If customCSS is passed, inline that CSS specifically - * Otherwise, use Juice's default inlining - */ - $.root().html( - css - ? juice($.html(), { extraCss: css, removeStyleTags, ...options }) - : juice($.html(), { removeStyleTags, ...options }) - ) - - /** - * Prefer attribute sizes - * - * Prefer HTML `width` and `height` attributes over inline CSS. - * Useful for retina images in MSO Outlook, where CSS sizes - * aren't respected and will render the image in its - * natural size. - */ - if (options.useAttributeSizes) { - $.root().html( - await useAttributeSizes(html, { - width: juice.widthElements, - height: juice.heightElements, - }) - ) - } - - /** - * Remove inlined selectors from the HTML - */ - $('style:not([embed])').each((_i, el) => { - const { root } = postcss() - .process( - $(el).html(), - { - from: undefined, - parser: safeParser - } - ) - - const selectors = new Set() - - root.walkRules(rule => { - const { selector } = rule - - // Add the selector to the set as long as it's not a pseudo selector - if (!/.+[^\\\s]::?\w+/.test(selector)) { - selectors.add({ - name: selector, - prop: get(rule.nodes[0], 'prop') - }) - } - }) - - /** - * `preferUnitlessValues` - replace unit values with `0` where possible - */ - selectors.forEach(({ name, prop }) => { - const elements = $(name).get() - - // If the property is excluded from inlining, skip - if (!juice.excludedProperties.includes(prop)) { - // Find the selector in the HTML - elements.forEach((el) => { - // Get a `property|value` list from the inline style attribute - const styleAttr = $(el).attr('style') - const inlineStyles = {} - - if (styleAttr) { - try { - const root = postcss.parse(`* { ${styleAttr} }`) - - root.first.each((decl) => { - const property = decl.prop - let value = decl.value - - if (value && options.preferUnitlessValues) { - value = value.replace( - /\b0(px|rem|em|%|vh|vw|vmin|vmax|in|cm|mm|pt|pc|ex|ch)\b/g, - '0' - ) - } - - if (property) { - inlineStyles[property] = value - } - }) - - // Update the element's style attribute with the new value - $(el).attr( - 'style', - Object.entries(inlineStyles).map(([property, value]) => `${property}: ${value}`).join('; ') - ) - } catch {} - } - }) - } - }) - }) - - $('style[embed]').each((_i, el) => { - $(el).removeAttr('embed') - }) - - return $.html() -} diff --git a/src/transformers/inlineCSS.ts b/src/transformers/inlineCSS.ts new file mode 100644 index 00000000..77d0ac12 --- /dev/null +++ b/src/transformers/inlineCSS.ts @@ -0,0 +1,129 @@ +import juice from 'juice' +import { walk, parse, serialize } from '../utils/ast/index.ts' +import type { ChildNode, Element } from 'domhandler' +import type { Options as JuiceOptions } from 'juice' +import type { CssConfig } from '../types/config.ts' + +interface InlineCssOptions { + removeStyleTags?: boolean + removeInlinedSelectors?: boolean + preferUnitlessValues?: boolean + safelist?: string[] + styleToAttribute?: Record<string, string> + applyWidthAttributes?: boolean + applyHeightAttributes?: boolean + widthElements?: string[] + heightElements?: string[] + excludedProperties?: string[] + codeBlocks?: Record<string, { start: string; end: string }> + customCSS?: string +} + +/** + * Inline CSS transformer. + * + * Inlines CSS from `<style>` tags into inline style attributes on HTML elements. + * This is important for email client compatibility (especially Outlook on Windows). + * + * Enabled when `css.inline` is set to `true` or an object with options. + * + * Options: + * - removeStyleTags: Remove style tags after inlining (default: false) + * - removeInlinedSelectors: Remove classes after they've been inlined (default: true) + * - preferUnitlessValues: Convert 0px, 0em, etc. to 0 (default: true) + * - safelist: Selectors that should not be removed after inlining + * - styleToAttribute: Map CSS properties to HTML attributes (e.g., background-color -> bgcolor) + * - applyWidthAttributes: Add width attributes based on inline CSS (default: true) + * - applyHeightAttributes: Add height attributes based on inline CSS (default: true) + * - widthElements: Elements that can receive width attributes (default: ['img', 'video']) + * - heightElements: Elements that can receive height attributes (default: ['img', 'video']) + * - excludedProperties: CSS properties to exclude from inlining + * - codeBlocks: Fenced code blocks to ignore (default: { EJS: { start: '<%', end: '%>' }, HBS: { start: '{{', end: '}}' } }) + * - customCSS: Additional CSS to inline + */ +export function inlineCSS(dom: ChildNode[], config: CssConfig = {}): ChildNode[] { + const inline = config.inline + + // Disabled when inline is falsy or not an object/truthy + if (!inline) { + return dom + } + + // Build options from config + const options: InlineCssOptions = typeof inline === 'object' ? inline : {} + + const removeStyleTags = options.removeStyleTags ?? false + const customCSS = options.customCSS ?? '' + + // Configure Juice static properties + juice.styleToAttribute = options.styleToAttribute ?? {} + juice.excludedProperties = ['--tw-shadow', ...(options.excludedProperties ?? [])] + juice.widthElements = (options.widthElements ?? ['img', 'video']).map(i => i.toUpperCase()) as unknown as HTMLElement[] + juice.heightElements = (options.heightElements ?? ['img', 'video']).map(i => i.toUpperCase()) as unknown as HTMLElement[] + + // Add custom code blocks + if (options.codeBlocks && typeof options.codeBlocks === 'object') { + Object.entries(options.codeBlocks).forEach(([key, value]) => { + if (value.start && value.end) { + juice.codeBlocks[key] = value + } + }) + } + + // Handle style tags with embed attributes + walk(dom, (node) => { + const el = node as Element + if (el.name === 'style' && el.attribs) { + // Add data-embed to style tags with embed attribute + if (el.attribs.embed && !('data-embed' in el.attribs)) { + el.attribs['data-embed'] = '' + } + // Add embed to style tags with data-embed attribute + if (el.attribs['data-embed'] && !('embed' in el.attribs)) { + el.attribs.embed = '' + } + } + }) + + // Serialize for juice (juice requires a string) + const serialized = serialize(dom) + + let inlinedHtml: string + + try { + const juiceOptions: JuiceOptions = { + removeStyleTags, + removeInlinedSelectors: options.removeInlinedSelectors ?? true, + preservedSelectors: options.safelist ?? [], + applyWidthAttributes: options.applyWidthAttributes ?? true, + applyHeightAttributes: options.applyHeightAttributes ?? true, + } + + if (customCSS) { + inlinedHtml = juice(serialized, { ...juiceOptions, extraCss: customCSS }) + } else { + inlinedHtml = juice(serialized, juiceOptions) + } + } catch { + // If Juice fails, return the dom unchanged + return dom + } + + // Post-process for preferUnitlessValues + const preferUnitlessValues = options.preferUnitlessValues ?? true + const result = parse(inlinedHtml) + + if (preferUnitlessValues) { + walk(result, (node) => { + const el = node as Element + if (el.attribs?.style) { + el.attribs.style = el.attribs.style.replace( + /\b0(px|rem|em|%|vh|vw|vmin|vmax|in|cm|mm|pt|pc|ex|ch)\b/g, + '0' + ) + } + }) + } + + return result +} diff --git a/src/transformers/inlineLink.ts b/src/transformers/inlineLink.ts new file mode 100644 index 00000000..086a74bf --- /dev/null +++ b/src/transformers/inlineLink.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' + +/** + * Inline `<link rel="stylesheet">` tags as `<style>` tags. + * + * - Local file paths are always inlined (resolved relative to the template) + * - Remote URLs (http/https) are only inlined if the `inline` attribute is present + * - Runs before the tailwindcss transformer so CSS is compiled normally + */ +export async function inlineLink(dom: ChildNode[], filePath?: string): Promise<ChildNode[]> { + const links: { node: Element; parent: ChildNode; index: number }[] = [] + + walk(dom, (node) => { + if ((node as Element).name !== 'link') return + + const el = node as Element + const attrs = el.attribs || {} + + if (attrs.rel !== 'stylesheet' || !attrs.href) return + + const parent = el.parent as ChildNode + + if (parent && 'children' in parent) { + const index = (parent.children as ChildNode[]).indexOf(el) + if (index !== -1) { + links.push({ node: el, parent, index }) + } + } else { + // Top-level node + const index = dom.indexOf(el) + if (index !== -1) { + links.push({ node: el, parent: null as any, index }) + } + } + }) + + for (const { node, parent, index } of links) { + const href = node.attribs.href + const isRemote = href.startsWith('http://') || href.startsWith('https://') + + let css: string | undefined + + if (isRemote) { + if (!('inline' in node.attribs)) continue + + try { + const response = await fetch(href) + css = await response.text() + } catch { + continue + } + } else { + if (!filePath) continue + + try { + const absolutePath = resolve(dirname(filePath), href) + css = readFileSync(absolutePath, 'utf8') + } catch { + continue + } + } + + const styleNode = { + type: 'tag', + name: 'style', + attribs: {}, + children: [{ + type: 'text', + data: css, + parent: null as any, + }], + parent: parent || null, + } as any + + // Set parent reference on the text child + styleNode.children[0].parent = styleNode + + const siblings = parent && 'children' in parent + ? parent.children as ChildNode[] + : dom + + siblings.splice(index, 1, styleNode) + } + + return dom +} diff --git a/src/transformers/markdown.js b/src/transformers/markdown.js deleted file mode 100644 index 0ed0c093..00000000 --- a/src/transformers/markdown.js +++ /dev/null @@ -1,26 +0,0 @@ -import posthtml from 'posthtml' -import md from 'posthtml-markdownit' - -export async function markdown(input = '', options = {}, posthtmlOptions = {}) { - /** - * If no input is provided, return an empty string. - */ - if (!input) { - return '' - } - - /** - * Automatically wrap in <md> tag, unless manual mode is enabled. - * - * With manual mode, user must wrap the input in a <md> tag. - * - * https://github.com/posthtml/posthtml-markdownit#usage - */ - input = options.manual ? input : `<md>${input}</md>` - - return posthtml([ - md(options) - ]) - .process(input, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/minify.js b/src/transformers/minify.js deleted file mode 100644 index 3d1e369a..00000000 --- a/src/transformers/minify.js +++ /dev/null @@ -1,27 +0,0 @@ -import posthtml from 'posthtml' -import { crush } from 'html-crush' -import { defu as merge } from 'defu' -import { render } from 'posthtml-render' -import { parser as parse } from 'posthtml-parser' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -const posthtmlPlugin = (options = {}) => tree => { - options = merge(options, { - removeLineBreaks: true, - }) - - const posthtmlConfig = getPosthtmlOptions() - const { result: html } = crush(render(tree), options) - - return parse(html, posthtmlConfig) -} - -export default posthtmlPlugin - -export async function minify(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/minify.ts b/src/transformers/minify.ts new file mode 100644 index 00000000..c3cb5989 --- /dev/null +++ b/src/transformers/minify.ts @@ -0,0 +1,29 @@ +import { crush } from 'html-crush' +import { defu as merge } from 'defu' +import type { Opts } from 'html-crush' +import type { MaizzleConfig } from '../types/config.ts' + +const DEFAULT_OPTIONS = { + removeLineBreaks: true, +} + +/** + * Minify transformer. + * + * Minifies HTML using the `html-crush` package. + * Enabled by setting `minify: true` (or passing options object). + * User options are merged on top of the defaults. + * + * The only Maizzle default that differs from html-crush's own defaults + * is `removeLineBreaks: true`. + */ +export function minify(html: string, config: MaizzleConfig = {}): string { + const option = config.html?.minify + + if (!option) return html + + const userOptions = typeof option === 'object' ? option : {} + const options = merge(userOptions, DEFAULT_OPTIONS) as Partial<Opts> + + return crush(html, options).result +} diff --git a/src/transformers/posthtmlMso.js b/src/transformers/posthtmlMso.js deleted file mode 100644 index bbc49829..00000000 --- a/src/transformers/posthtmlMso.js +++ /dev/null @@ -1,14 +0,0 @@ -import posthtml from 'posthtml' -import posthtmlMso from 'posthtml-mso' - -export default function posthtmlPlugin(options = {}) { - return posthtmlMso(options) -} - -export async function useMso(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/prettify.js b/src/transformers/prettify.js deleted file mode 100644 index 3f5e0a51..00000000 --- a/src/transformers/prettify.js +++ /dev/null @@ -1,29 +0,0 @@ -import pretty from 'pretty' -import posthtml from 'posthtml' -import { defu as merge } from 'defu' -import { render } from 'posthtml-render' -import { parser as parse } from 'posthtml-parser' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -const posthtmlPlugin = (options = {}) => tree => { - const defaultConfig = { - space_around_combinator: true, // Preserve space around CSS selector combinators - newline_between_rules: false, // Remove empty lines between CSS rules - indent_inner_html: false, // Helps reduce file size - extra_liners: [] // Don't add extra new line before any tag - } - - const config = merge(options, defaultConfig) - - return parse(pretty(render(tree), config), getPosthtmlOptions()) -} - -export default posthtmlPlugin - -export async function prettify(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/preventWidows.js b/src/transformers/preventWidows.js deleted file mode 100644 index 38d0a93b..00000000 --- a/src/transformers/preventWidows.js +++ /dev/null @@ -1,37 +0,0 @@ -import posthtml from 'posthtml' -import posthtmlWidows from 'posthtml-widows' -import { defu as merge } from 'defu' - -export default function posthtmlPlugin(options = {}) { - options = merge(options, { - minWords: 3 - }) - - // Custom ignores - const mappings = [ - // MSO comments - { - start: '<!--[', - end: ']>' - }, - // <![endif]--> - { - start: '<![', - end: ']--><' - } - ] - - if (Array.isArray(options.ignore)) { - options.ignore = options.ignore.concat(mappings) - } - - return posthtmlWidows(options) -} - -export async function preventWidows(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/purge.js b/src/transformers/purge.js deleted file mode 100644 index d8c9a864..00000000 --- a/src/transformers/purge.js +++ /dev/null @@ -1,52 +0,0 @@ -import posthtml from 'posthtml' -import { comb } from 'email-comb' -import get from 'lodash-es/get.js' -import { defu as merge } from 'defu' -import { render } from 'posthtml-render' -import { parser as parse } from 'posthtml-parser' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -const posthtmlPlugin = options => tree => { - const defaultSafelist = [ - '*body*', // Gmail - '.gmail*', // Gmail - '.apple*', // Apple Mail - '.ios*', // Mail on iOS - '.ox-*', // Open-Xchange - '.outlook*', // Outlook.com - '[data-ogs*', // Outlook.com - '.bloop_container', // Airmail - '.Singleton', // Apple Mail 10 - '.unused', // Notes 8 - '.moz-text-html', // Thunderbird - '.mail-detail-content', // Comcast, Libero webmail - '*edo*', // Edison (all) - '#*', // Freenet uses #msgBody - '.lang*' // Fenced code blocks - ] - - const defaultOptions = { - backend: [ - { heads: '{{', tails: '}}' }, - { heads: '{%', tails: '%}' }, - ], - whitelist: [...defaultSafelist, ...get(options, 'safelist', [])] - } - - options = merge(options, defaultOptions) - - const posthtmlConfig = getPosthtmlOptions() - const { result: html } = comb(render(tree), options) - - return parse(html, posthtmlConfig) -} - -export default posthtmlPlugin - -export async function purge(html = '', pluginOptions = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(pluginOptions) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/purgeCSS.ts b/src/transformers/purgeCSS.ts new file mode 100644 index 00000000..64d6081e --- /dev/null +++ b/src/transformers/purgeCSS.ts @@ -0,0 +1,73 @@ +import { comb } from 'email-comb' +import { defu as merge } from 'defu' +import type { ChildNode } from 'domhandler' +import { parse, serialize } from '../utils/ast/index.ts' +import type { CssConfig } from '../types/config.ts' + +const DEFAULT_SAFELIST: string[] = [ + '*body*', // Gmail + '.gmail*', // Gmail + '.apple*', // Apple Mail + '.ios*', // Mail on iOS + '.ox-*', // Open-Xchange + '.outlook*', // Outlook.com + '[data-ogs*', // Outlook.com + '.bloop_container', // Airmail + '.Singleton', // Apple Mail 10 + '.unused', // Notes 8 + '.moz-text-html', // Thunderbird + '.mail-detail-content', // Comcast, Libero webmail + '*edo*', // Edison (all) + '#*', // Freenet uses #msgBody + '.lang*', // Fenced code blocks +] + +const DEFAULT_OPTIONS = { + backend: [ + { heads: '{{', tails: '}}' }, + { heads: '{%', tails: '%}' }, + ], + whitelist: [...DEFAULT_SAFELIST], +} + +/** + * Remove unused CSS transformer. + * + * Uses `email-comb` to strip CSS selectors and corresponding class/id + * references that are not matched anywhere in the HTML body. + * + * Enable by setting `css.purge: true` (or passing options). + * The user-supplied options are merged on top of the defaults, so + * `safelist` values are **appended** to the built-in safelist rather + * than replacing it. + * + * Accepts `ChildNode[]` as input, serializes internally before passing + * to email-comb (which requires a raw HTML string), then parses the + * result back to `ChildNode[]` so it fits in the DOM pipeline. + */ +export function purgeCSS(dom: ChildNode[], config: CssConfig = {}): ChildNode[] { + const option = config.purge + + if (!option) return dom + + const userOptions = typeof option === 'object' ? option : {} + + // Merge user options on top of defaults. + // defu merges objects deeply; for arrays it appends user values. + // We want the user safelist appended to the default safelist, + // so we build whitelist manually. + const userSafelist = Array.isArray((userOptions as any).safelist) + ? (userOptions as any).safelist as string[] + : [] + + const { safelist: _discard, ...restUserOptions } = userOptions as any + + const options = merge( + { ...restUserOptions, whitelist: [...DEFAULT_SAFELIST, ...userSafelist] }, + DEFAULT_OPTIONS, + ) + + const { result } = comb(serialize(dom), options) + + return parse(result) +} diff --git a/src/transformers/removeAttributes.js b/src/transformers/removeAttributes.js deleted file mode 100644 index e3e0585b..00000000 --- a/src/transformers/removeAttributes.js +++ /dev/null @@ -1,55 +0,0 @@ -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -/** - * Remove empty attributes with PostHTML - * - * Condition 1: - * `boolean` is for attributes without ="" (respects `recognizeNoValueAttribute` in PostHTML) - * `''` if the attribute included ="", i.e. style="" - * - * Condition 2: attribute value is a string and matches the one on the node - * - * Condition 3: same as 2, but for regular expressions - */ -const posthtmlPlugin = (attributes = [], posthtmlOptions = {}) => tree => { - attributes.push('style', 'class') - - const process = node => { - const normalizedAttrs = attributes.map(attribute => { - return { - name: get(attribute, 'name', typeof attribute === 'string' ? attribute : false), - value: get(attribute, 'value', get(posthtmlOptions, 'recognizeNoValueAttributes', true)) - } - }) - - if (node.attrs) { - normalizedAttrs.forEach(attr => { - const targetAttrValue = get(node.attrs, attr.name) - - if ( - typeof targetAttrValue === 'boolean' || targetAttrValue === '' || - (typeof attr.value === 'string' && node.attrs[attr.name] === attr.value) || - (attr.value instanceof RegExp && attr.value.test(node.attrs[attr.name])) - ) { - node.attrs[attr.name] = false - } - }) - } - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin - -export async function removeAttributes(html = '', attributes = [], posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(attributes, getPosthtmlOptions(posthtmlOptions)) - ]) - .process(html, getPosthtmlOptions()) - .then(result => result.html) -} diff --git a/src/transformers/removeAttributes.ts b/src/transformers/removeAttributes.ts new file mode 100644 index 00000000..0a7e6fb9 --- /dev/null +++ b/src/transformers/removeAttributes.ts @@ -0,0 +1,98 @@ +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import type { AttributesConfig } from '../types/config.ts' + +interface RemoveAttributeConfig { + name: string + value?: string | RegExp | boolean +} + +type RemoveAttributeOption = string | RemoveAttributeConfig + +/** + * Remove attributes transformer. + * + * Removes specified HTML attributes from elements. + * + * By default, removes empty `style` and `class` attributes. + * + * Supports: + * - String: removes attribute when empty (boolean or empty string) + * - Object with name and value: removes when attribute matches exactly + * - Object with name and RegExp value: removes when attribute value matches regex + * + * Configured via `remove` array: + * ```js + * { + * remove: [ + * 'data-src', // Remove empty data-src attributes + * { name: 'id', value: 'test' }, // Remove id="test" exactly + * { name: 'data-id', value: /\d/ } // Remove data-id when value contains digits + * ] + * } + * ``` + */ +export function removeAttributes(dom: ChildNode[], config: AttributesConfig = {}): ChildNode[] { + const removeOptions = config.remove + + // Always remove empty style and class attributes by default + const alwaysRemove: RemoveAttributeOption[] = ['style', 'class'] + + // Parse user options + let userOptions: RemoveAttributeOption[] = [] + if (Array.isArray(removeOptions)) { + userOptions = removeOptions as RemoveAttributeOption[] + } + + // Combine default and user options + const attributesToRemove: RemoveAttributeOption[] = [...alwaysRemove, ...userOptions] + + if (attributesToRemove.length === 0) { + return dom + } + + walk(dom, (node) => { + const el = node as Element + if (!el.attribs) return + + for (const attr of attributesToRemove) { + let attrName: string + let attrValue: string | RegExp | boolean | undefined + + if (typeof attr === 'string') { + attrName = attr + attrValue = true // Remove when value is empty (boolean true or empty string) + } else { + attrName = attr.name + attrValue = attr.value + } + + const currentValue = el.attribs[attrName] + + // Skip if attribute doesn't exist + if (currentValue === undefined) continue + + let shouldRemove = false + + if (typeof attrValue === 'boolean') { + // Remove if value is empty (boolean true is treated as no-value attribute) + shouldRemove = currentValue === '' || (currentValue as unknown) === true + } else if (typeof attrValue === 'string') { + // Remove if value matches exactly + shouldRemove = currentValue === attrValue + } else if (attrValue instanceof RegExp) { + // Remove if value matches regex + shouldRemove = attrValue.test(currentValue) + } else { + // Default: remove if empty + shouldRemove = currentValue === '' + } + + if (shouldRemove) { + delete el.attribs[attrName] + } + } + }) + + return dom +} diff --git a/src/transformers/replaceStrings.js b/src/transformers/replaceStrings.js deleted file mode 100644 index d79c62ac..00000000 --- a/src/transformers/replaceStrings.js +++ /dev/null @@ -1,37 +0,0 @@ -import posthtml from 'posthtml' -import { render } from 'posthtml-render' -import isEmpty from 'lodash-es/isEmpty.js' -import { parser as parse } from 'posthtml-parser' -import { getPosthtmlOptions } from '../posthtml/defaultConfig.js' - -const posthtmlPlugin = (replacements = {}) => tree => { - if (!isEmpty(replacements)) { - const regexes = Object.entries(replacements).map(([k, v]) => [new RegExp(k, 'gi'), v]) - const patterns = new RegExp(Object.keys(replacements).join('|'), 'gi') - - return parse( - render(tree).replace(patterns, matched => { - for (const [regex, replacement] of regexes) { - if (regex.test(matched)) { - return matched.replace(regex, replacement) - } - } - - return matched - }), - getPosthtmlOptions() - ) - } - - return tree -} - -export default posthtmlPlugin - -export async function replaceStrings(html = '', replacements = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(replacements) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/replaceStrings.ts b/src/transformers/replaceStrings.ts new file mode 100644 index 00000000..247d4ad4 --- /dev/null +++ b/src/transformers/replaceStrings.ts @@ -0,0 +1,21 @@ +import type { MaizzleConfig } from '../types/config.ts' + +/** + * Replace strings transformer. + * + * Replaces strings in the HTML using the key-value pairs defined in + * `config.replaceStrings`. Each key is treated as a regular expression + * pattern (case-insensitive, global), and the value is the replacement. + * + * Character classes must be escaped in keys, e.g. `\\s` for `\s`. + */ +export function replaceStrings(html: string, config: MaizzleConfig = {}): string { + const replacements = config.replaceStrings + + if (!replacements || Object.keys(replacements).length === 0) return html + + return Object.entries(replacements).reduce( + (result, [pattern, replacement]) => result.replace(new RegExp(pattern, 'gi'), replacement), + html, + ) +} diff --git a/src/transformers/safeClassNames.js b/src/transformers/safeClassNames.js deleted file mode 100644 index 2d3bb605..00000000 --- a/src/transformers/safeClassNames.js +++ /dev/null @@ -1,28 +0,0 @@ -import posthtml from 'posthtml' -import { defu as merge } from 'defu' -import posthtmlSafeClassNames from 'posthtml-safe-class-names' - -export default function posthtmlPlugin(options = {}) { - // If options is boolean, convert to object - if (typeof options === 'boolean') { - options = {} - } - - // Default options - options = merge({ - replacements: { - '{': '{', - '}': '}' - } - }, options) - - return posthtmlSafeClassNames(options) -} - -export async function safeClassNames(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/safeClassNames.ts b/src/transformers/safeClassNames.ts new file mode 100644 index 00000000..12b69eda --- /dev/null +++ b/src/transformers/safeClassNames.ts @@ -0,0 +1,131 @@ +import postcss from 'postcss' +import safeParser from 'postcss-safe-parser' +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import type { CssConfig } from '../types/config.ts' + +const DEFAULT_REPLACEMENTS: Record<string, string> = { + ':': '-', + '/': '-', + '%': 'pc', + '.': '_', + ',': '_', + '#': '_', + '[': '', + ']': '', + '(': '', + ')': '', + '{': '', + '}': '', + '!': '-i', + '&': 'and-', + '<': 'lt-', + '=': 'eq-', + '>': 'gt-', + '|': 'or-', + '@': 'at-', + '?': 'q-', + '\\': '-', + '"': '-', + "'": '-', + '*': '-', + '+': '-', + ';': '-', + '^': '-', + '`': '-', + '~': '-', + '$': '-', +} + +function escapeForRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Replace escaped special characters in CSS selectors. + * + * Tailwind generates selectors like `.sm\:text-base`. This function + * replaces the `\:` with `-` (or whatever the configured replacement is) + * so the selector becomes `.sm-text-base`, which is safe for email clients. + */ +function processCssSelectors(css: string, replacements: Record<string, string>): string { + // Matches \<char> in CSS selectors — e.g. \: \/ \. \[ etc. + const selectorRegex = new RegExp( + `\\\\(${Object.keys(replacements).map(escapeForRegex).join('|')})`, + 'g', + ) + + return postcss([ + (root: postcss.Root) => { + root.walkRules((rule: postcss.Rule) => { + rule.selector = rule.selector + .replace(selectorRegex, (_matched, char) => replacements[char] ?? _matched) + // Handle CSS unicode escape for comma (\2c → _) + .replaceAll('\\2c ', '_') + }) + }, + ]).process(css, { parser: safeParser }).css +} + +/** + * Replace unsafe special characters in a class attribute value. + * + * Splits on whitespace and replaces each char from the replacements map + * in each class token individually. + */ +function processClassAttr(classStr: string, replacements: Record<string, string>): string { + return classStr + .split(/\s+/) + .filter(Boolean) + .map((cls) => { + for (const [from, to] of Object.entries(replacements)) { + cls = cls.split(from).join(to) + } + return cls + }) + .join(' ') +} + +/** + * Safe class names transformer. + * + * Replaces unsafe characters (`:`, `/`, `[`, `]`, etc.) in: + * - CSS selectors inside `<style>` tags + * - HTML `class` attributes + * + * This makes Tailwind utility classes like `sm:text-base` safe for + * email clients that cannot handle escaped characters in class names. + * + * Enabled by default. Disable by setting `css.safe` to `false`. + * Customize replacements by passing a `Record<string, string>` — user + * values are merged on top of the defaults. + */ +export function safeClassNames(dom: ChildNode[], config: CssConfig = {}): ChildNode[] { + const option = config.safe ?? true + + if (!option) return dom + + const replacements: Record<string, string> = + option && typeof option === 'object' + ? { ...DEFAULT_REPLACEMENTS, ...option } + : DEFAULT_REPLACEMENTS + + walk(dom, (node) => { + const el = node as Element + + // Process CSS selectors inside <style> tags + if (el.name === 'style' && el.children?.length) { + const text = el.children.find((c) => c.type === 'text') as any + if (text?.data?.trim()) { + text.data = processCssSelectors(text.data, replacements) + } + } + + // Replace special chars in class attributes + if ('attribs' in el && el.attribs?.class) { + el.attribs.class = processClassAttr(el.attribs.class, replacements) + } + }) + + return dom +} diff --git a/src/transformers/shorthandCSS.ts b/src/transformers/shorthandCSS.ts new file mode 100644 index 00000000..292b90f2 --- /dev/null +++ b/src/transformers/shorthandCSS.ts @@ -0,0 +1,77 @@ +import postcss from 'postcss' +import safeParser from 'postcss-safe-parser' +import mergeLonghand from 'postcss-merge-longhand' +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import type { CssConfig } from '../types/config.ts' + +interface ShorthandCssOptions { + tags?: string[] +} + +/** + * Shorthand CSS transformer. + * + * Rewrites longhand CSS inside `style` attributes with shorthand syntax. + * Works with margin, padding, and border when all sides are specified. + * + * For example: + * `margin-left: 2px; margin-right: 2px; margin-top: 4px; margin-bottom: 4px` + * becomes: + * `margin: 4px 2px` + * + * Enabled via `css.shorthand`: + * - `true`: enable for all tags + * - `{ tags: ['td', 'div'] }`: enable only for specified tags + * - `false` or omitted: disabled + */ +export function shorthandCSS(dom: ChildNode[], config: CssConfig = {}): ChildNode[] { + const option = config.shorthand + + // Disabled by default + if (!option) { + return dom + } + + // Parse options + const options: ShorthandCssOptions = typeof option === 'object' ? option : {} + const allowedTags = options.tags ?? [] + const hasTagFilter = allowedTags.length > 0 + + walk(dom, (node) => { + const el = node as Element + + // Skip if no attribs or no style + if (!el.attribs?.style) { + return + } + + // Skip if tag filter is active and this tag is not allowed + if (hasTagFilter && !allowedTags.includes(el.name)) { + return + } + + const styleValue = el.attribs.style + + try { + // Process the style with postcss-merge-longhand + // Wrap in a dummy selector since postcss needs a rule + const { css } = postcss() + .use(mergeLonghand) + .process(`div { ${styleValue} }`, { parser: safeParser }) + + // Extract the content between the braces + const match = css.match(/div\s*\{\s*([^}]+)\s*\}/) + if (match && match[1]) { + const newStyle = match[1].trim() + if (newStyle !== styleValue) { + el.attribs.style = newStyle + } + } + } catch { + // If processing fails, keep the original style + } + }) + + return dom +} diff --git a/src/transformers/shorthandCss.js b/src/transformers/shorthandCss.js deleted file mode 100644 index ca1dc95f..00000000 --- a/src/transformers/shorthandCss.js +++ /dev/null @@ -1,20 +0,0 @@ -import posthtml from 'posthtml' -import posthtmlMergeLonghand from 'posthtml-postcss-merge-longhand' - -export default function posthtmlPlugin(options = {}) { - if (Array.isArray(options.tags)) { - return posthtmlMergeLonghand({ - tags: options.tags, - }) - } - - return posthtmlMergeLonghand() -} - -export async function shorthandCSS(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/sixHex.js b/src/transformers/sixHex.js deleted file mode 100644 index a64149d3..00000000 --- a/src/transformers/sixHex.js +++ /dev/null @@ -1,30 +0,0 @@ -import posthtml from 'posthtml' -import { conv } from 'color-shorthand-hex-to-six-digit' - -const posthtmlPlugin = () => tree => { - const targets = new Set(['bgcolor', 'color']) - - const process = node => { - if (node.attrs) { - Object.entries(node.attrs).forEach(([name, value]) => { - if (targets.has(name) && node.attrs[name]) { - node.attrs[name] = conv(value) - } - }) - } - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin - -export async function sixHEX(html = '', posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin() - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/tailwindcss.ts b/src/transformers/tailwindcss.ts new file mode 100644 index 00000000..4e1bc317 --- /dev/null +++ b/src/transformers/tailwindcss.ts @@ -0,0 +1,232 @@ +import postcss from 'postcss' +import tailwindcssPostcss from '@tailwindcss/postcss' +import customProperties from 'postcss-custom-properties' +import postcssCalc from 'postcss-calc' +import pruneVars from '../plugins/postcss/pruneVars.ts' +import safeParser from 'postcss-safe-parser' +import { transform } from 'lightningcss' +import { resolve, dirname, relative } from 'node:path' +import type { ChildNode, Element } from 'domhandler' +import { walk } from '../utils/ast/index.ts' +import { tailwindCleanup } from '../plugins/postcss/tailwindCleanup.ts' +import { mergeMediaQueries } from '../plugins/postcss/mergeMediaQueries.ts' +import type { MaizzleConfig } from '../types/config.ts' + +function createProcessor(config: MaizzleConfig) { + return postcss([ + tailwindcssPostcss({ + base: config.css?.base, + transformAssetUrls: false, + optimize: false, // we run Lightning CSS manually + }), + customProperties({ + preserve: false, + }), + postcssCalc({}), + pruneVars(), + ]) +} + +/** + * Decode HTML entities that Vue SSR encodes inside <style> tags. + * + * Vue's renderToString HTML-encodes quotes and other characters + * inside <style> tags within templates, breaking CSS like + * `@import "@maizzle/tailwindcss"` → `@import &quot;...&quot;` + */ +function decodeEntities(str: string): string { + return str + .replace(/&quot;/g, '"') + .replace(/&amp;/g, '&') + .replace(/&lt;/g, '<') + .replace(/&gt;/g, '>') + .replace(/&#39;/g, "'") + .replace(/&apos;/g, "'") +} + +/** + * Check if CSS content uses Tailwind features that require source scanning. + * + * Only CSS that imports Tailwind (or @maizzle/tailwindcss) needs @source + * directives. Plain CSS without Tailwind imports doesn't need scanning + * and would pass through @source directives unconsumed. + */ +function usesTailwind(css: string): boolean { + return /(@import\s+["'](tailwindcss|@maizzle\/tailwindcss)|@tailwind\s)/.test(css) +} + +/** + * Lower modern CSS syntax using lightningcss. + * + * Targets IE 1 to maximize syntax lowering — converts modern features + * like nesting, oklch(), color-mix(), @property, etc. into simple CSS + * that email clients can understand. + */ +function lowerSyntax(css: string): string { + const result = transform({ + filename: 'email.css', + code: Buffer.from(css), + minify: false, + targets: { + ie: 4 << 5, + }, + }) + + return result.code.toString() +} + +/** + * Run cleanup and media query merging on the compiled CSS. + * + * Removes unwanted selectors (:host, :lang) and at-rules (@layer, @property), + * then sorts and merges media queries. + */ +async function optimizeCss(css: string, config: MaizzleConfig): Promise<string> { + const plugins: postcss.Plugin[] = [...tailwindCleanup(config)] + + const mediaPlugin = mergeMediaQueries(config) + if (mediaPlugin) plugins.push(mediaPlugin) + + const result = await postcss(plugins).process(css, { from: undefined }) + + return result.css +} + +/** + * Build @source directives for Tailwind CSS scanning. + * + * Configures two types of sources: + * 1. Exclusions for output dir and user-configured paths + * 2. Inline source with all class attribute values from the rendered DOM, + * capturing classes from all components (built-in + user), dynamic + * expressions, and the template itself — Tailwind's scanner handles + * the actual class extraction from these raw values + */ +function buildSourceDirectives(dom: ChildNode[], config: MaizzleConfig, fromDir: string): string { + const directives: string[] = [] + + // Exclude output dir and user-configured paths + const excludePaths = [ + resolve(config.output?.path ?? 'dist'), + ...(config.css?.exclude ?? []).map(p => resolve(p)), + ] + + for (const p of excludePaths) { + directives.push(`@source not "${relative(fromDir, resolve(p))}";`) + } + + // Inline source: collect all class attribute values from the rendered DOM. + // After Vue SSR, the DOM contains every class from every component + // (built-in framework components, user components, dynamic bindings). + // We pass these raw values to Tailwind's scanner via @source inline(). + const classes: string[] = [] + walk(dom, (n) => { + const cls = (n as Element).attribs?.class + if (cls) classes.push(cls) + }) + + if (classes.length) { + directives.push(`@source inline("${classes.join(' ')}");`) + } + + return directives.join('\n') +} + +/** + * Tailwind CSS transformer. + * + * Compiles CSS inside <style> tags in the DOM using + * @tailwindcss/postcss, then lowers modern CSS syntax with lightningcss. + * + * Configures Tailwind sources to scan: + * - Rendered class attributes (via `@source inline`) for all classes from all components + * - User project files (via Tailwind's auto-detection from base/from path) + * + * User `@source` and `@source not directives` in style tags are preserved. + * Source directives are only added to style tags that import Tailwind. + * + * Runs as the first transformer in the pipeline so that subsequent + * transformers (inliner, purge, etc.) work with fully compiled CSS. + */ +export async function tailwindcss(dom: ChildNode[], config: MaizzleConfig, filePath?: string): Promise<ChildNode[]> { + const styleTags: { node: Element; cssContent: string }[] = [] + + walk(dom, (node) => { + if ((node as Element).name !== 'style') return + + const el = node as Element + const attrs = el.attribs || {} + + // Skip marked style tags, but remove the marker attribute first + const markerAttr = ['raw', 'embed', 'data-embed'].find(attr => attr in attrs) + if (markerAttr) { + delete el.attribs[markerAttr] + return + } + + // Get text content from children and decode HTML entities + const rawContent = el.children + .filter(child => child.type === 'text') + .map(child => (child as any).data) + .join('') + + if (!rawContent.trim()) return + + styleTags.push({ node: el, cssContent: decodeEntities(rawContent) }) + }) + + if (!styleTags.length) return dom + + const fromPath = filePath ?? resolve(process.cwd(), 'template.vue') + const fromDir = dirname(fromPath) + + // Only compute source directives if at least one style tag uses Tailwind + const hasTailwindStyles = styleTags.some(({ cssContent }) => usesTailwind(cssContent)) + const sourceDirectives = hasTailwindStyles + ? buildSourceDirectives(dom, config, fromDir) + : '' + + // Create processor once — reused for all style tags in this template + const processor = createProcessor(config) + + for (let i = 0; i < styleTags.length; i++) { + const { node, cssContent } = styleTags[i] + + // Only add source directives to style tags that import Tailwind — + // plain CSS doesn't need them and @tailwindcss/postcss would leave + // the directives unconsumed in the output + const fullCss = usesTailwind(cssContent) + ? `${cssContent}\n${sourceDirectives}` + : cssContent + + try { + const result = await processor.process( + fullCss, + { + from: `${fromPath}?style=${i}`, + parser: safeParser, + } + ) + + const lowered = lowerSyntax(result.css) + const optimized = await optimizeCss(lowered, config) + + // Replace the style tag's children with the compiled CSS + node.children = [{ + type: 'text', + data: optimized, + parent: node, + } as any] + } catch { + // If CSS processing fails, still replace with decoded content + // so HTML entities don't break the CSS + node.children = [{ + type: 'text', + data: cssContent, + parent: node, + } as any] + } + } + + return dom +} diff --git a/src/transformers/template.js b/src/transformers/template.js deleted file mode 100644 index 65b174f3..00000000 --- a/src/transformers/template.js +++ /dev/null @@ -1,26 +0,0 @@ -const posthtmlPlugin = () => tree => { - const process = node => { - // Return the original node if it doesn't have a tag - if (!node.tag) { - return node - } - - if (node.tag === 'template') { - // Preserve <template> tags marked as such - if ('attrs' in node && 'preserve' in node.attrs) { - node.attrs.preserve = false - - return node - } - - // Remove the <template> tag - node.tag = false - } - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin diff --git a/src/transformers/urlParameters.js b/src/transformers/urlParameters.js deleted file mode 100644 index 3b0e8181..00000000 --- a/src/transformers/urlParameters.js +++ /dev/null @@ -1,20 +0,0 @@ -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' -import urlParameters from 'posthtml-url-parameters' - -export default function posthtmlPlugin(options = {}) { - const { _options, ...parameters } = options - const tags = get(_options, 'tags', ['a']) - const strict = get(_options, 'strict', true) - const qs = get(_options, 'qs', { encode: false }) - - return urlParameters({ parameters, tags, qs, strict }) -} - -export async function addURLParams(html = '', options = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(options) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/transformers/urlQuery.ts b/src/transformers/urlQuery.ts new file mode 100644 index 00000000..f0c65d01 --- /dev/null +++ b/src/transformers/urlQuery.ts @@ -0,0 +1,75 @@ +import queryString from 'query-string' +import { selectAll } from 'css-select' +import type { ChildNode, Element } from 'domhandler' +import { isAbsoluteUrl } from '../utils/url.ts' +import type { UrlConfig, UrlQueryOptions } from '../types/config.ts' + +const DEFAULT_ATTRIBUTES = ['src', 'href', 'poster', 'srcset', 'background'] +const DEFAULT_TAGS = ['a'] + +/** + * Append query parameters to a URL string using query-string. + */ +function appendParams( + url: string, + params: Record<string, unknown>, + qsOptions: queryString.StringifyOptions, + strict: boolean, +): string { + if (strict && !isAbsoluteUrl(url)) return url + + return queryString.stringifyUrl( + { url, query: params as queryString.StringifiableRecord }, + qsOptions, + ) +} + +/** + * URL query transformer. + * + * Appends query parameters to URLs found in specified attributes of + * specified HTML tags. + * + * Reads config from the `config.url` object in `MaizzleConfig` (pass + * `config.url` directly when calling as a standalone transformer). + * The `_options` key inside `query` controls behaviour: + * - `tags` — CSS selectors for elements to process. Default: `['a']` + * - `attributes` — attribute names to process. Default: `['src', 'href', 'poster', 'srcset', 'background']` + * - `strict` — only append to absolute URLs. Default: `true` + * - `qs` — options forwarded to query-string. Default: `{ encode: false }` + * + * All non-`_options` keys inside `query` are treated as URL parameters to append. + */ +export function urlQuery(dom: ChildNode[], config: UrlConfig = {}): ChildNode[] { + const queryConfig = config.query + + if (!queryConfig || Object.keys(queryConfig).length === 0) return dom + + const { _options, ...params } = queryConfig as Record<string, unknown> + const options = (_options ?? {}) as UrlQueryOptions + + if (Object.keys(params).length === 0) return dom + + const tags = options.tags ?? DEFAULT_TAGS + const attributes = options.attributes ?? DEFAULT_ATTRIBUTES + const strict = options.strict ?? true + const qsOptions: queryString.StringifyOptions = { encode: false, ...((options.qs ?? {}) as queryString.StringifyOptions) } + + // Use css-select to find all elements matching any of the tag selectors + const selector = tags.join(', ') + const elements = selectAll(selector, dom) as Element[] + + for (const el of elements) { + for (const attr of attributes) { + const value = el.attribs[attr] + if (!value) continue + + const updated = appendParams(value, params, qsOptions, strict) + if (updated !== value) { + el.attribs[attr] = updated + } + } + } + + return dom +} diff --git a/src/transformers/useAttributeSizes.js b/src/transformers/useAttributeSizes.js deleted file mode 100644 index 779d4baf..00000000 --- a/src/transformers/useAttributeSizes.js +++ /dev/null @@ -1,63 +0,0 @@ -import postcss from 'postcss' -import posthtml from 'posthtml' -import get from 'lodash-es/get.js' - -const posthtmlPlugin = (mappings = {}) => tree => { - if (!Object.keys(mappings).length) { - return tree - } - - // Normalize tags in mappings by lowercasing them - for (const key in mappings) { - if (Array.isArray(mappings[key])) { - mappings[key] = mappings[key].map(value => value.toLowerCase()) - } - } - - const process = node => { - // Check if the node is defined in mappings - if ( - get(mappings, 'width', []).includes(node.tag) - || get(mappings, 'height', []).includes(node.tag) - ) { - // Check if the node has an inline CSS property equal to one of the keys in mappings - if (node.attrs.style) { - const { root } = postcss().process(`${node.attrs.style}`, { from: undefined }) - - root.walkDecls(decl => { - if (mappings.width.includes(node.tag) && decl.prop === 'width') { - // Set its value as an attribute on the node; the attribute name is the key in mappings - node.attrs.width = decl.value.replace('px', '') - // Remove the inline CSS property from the declaration - decl.remove() - } - - if (mappings.height.includes(node.tag) && decl.prop === 'height') { - // Set its value as an attribute on the node; the attribute name is the key in mappings - node.attrs.height = decl.value.replace('px', '') - // Remove the inline CSS property from the declaration - decl.remove() - } - }) - - // Set the remaining inline CSS as the `style` attribute on the node - // If there are no remaining declarations, remove the `style` attribute - node.attrs.style = root.toString().trim() || false - } - } - - return node - } - - return tree.walk(process) -} - -export default posthtmlPlugin - -export async function useAttributeSizes(html = '', mappings = {}, posthtmlOptions = {}) { - return posthtml([ - posthtmlPlugin(mappings) - ]) - .process(html, posthtmlOptions) - .then(result => result.html) -} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 00000000..2a4111e7 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,495 @@ +export interface UrlQueryOptions { + /** + * CSS selectors for elements to process. + * + * @default ['a'] + */ + tags?: string[] + /** + * HTML attributes containing URLs to append query params to. + * + * @default ['src', 'href', 'poster', 'srcset', 'background'] + */ + attributes?: string[] + /** + * When `true`, only appends query params to absolute URLs. + * + * @default true + */ + strict?: boolean + /** + * Options forwarded to the `query-string` library for controlling serialization. + * + * @default { encode: false } + */ + qs?: Record<string, unknown> +} + +export type UrlQuery = Record<string, unknown> & { + _options?: UrlQueryOptions +} + +export interface UrlConfig { + /** + * Append query parameters to URLs in your HTML. + * + * @example + * url: { + * query: { + * utm_source: 'maizzle', + * utm_medium: 'email', + * } + * } + */ + query?: UrlQuery + /** + * Prepend a base URL to relative paths. + * + * Pass a string to prepend to all tags, or an object for fine-grained control. + * + * @example + * url: { + * base: 'https://cdn.example.com/emails/', + * } + */ + base?: string | { + /** The base URL to prepend. */ + url?: string + /** Tags or tag-attribute map to process. */ + tags?: string[] | Record<string, Record<string, string | boolean>> + /** Attributes to process. */ + attributes?: Record<string, string> + /** Also apply to URLs in `<style>` tags. */ + styleTag?: boolean + /** Also apply to URLs in inline `style` attributes. */ + inlineCss?: boolean + } +} + +export interface CssConfig { + /** + * Base directory for Tailwind CSS `@source` resolution. + * + * Automatically set to `root` when `root` is configured. + */ + base?: string + /** + * Remove unused CSS. + * + * Set to `true` to enable with defaults, or pass an options object. + * + * @default false + */ + purge?: boolean | Record<string, unknown> + /** + * Inline CSS from `<style>` tags into matching HTML elements. + * + * Set to `true` to enable with defaults, or pass an options object for fine-grained control. + * + * @example + * css: { + * inline: { + * removeStyleTags: true, + * applyWidthAttributes: true, + * } + * } + */ + inline?: boolean | { + /** + * Convert HTML attributes like `width`, `height`, `bgcolor`, and `valign` + * to inline CSS styles. Set to `true` for all, or pass an array of attribute names. + * + * @default false + */ + attributeToStyle?: boolean | string[] + /** + * Remove `<style>` tags after inlining. + * + * @default false + */ + removeStyleTags?: boolean + /** + * Remove selectors from `<style>` tags after they have been inlined. + * + * @default true + */ + removeInlinedSelectors?: boolean + /** + * Convert `0px`, `0em` etc. to `0` in inline styles. + * + * @default true + */ + preferUnitlessValues?: boolean + /** + * CSS selectors to preserve in `<style>` tags, even after inlining. + * + * @default [] + */ + safelist?: string[] + /** + * Duplicate CSS properties to HTML attributes. + * + * @default {} + * + * @example + * styleToAttribute: { + * 'background-color': 'bgcolor', + * } + */ + styleToAttribute?: Record<string, string> + /** + * Add `width` HTML attributes based on inline CSS width values. + * + * @default true + */ + applyWidthAttributes?: boolean + /** + * Add `height` HTML attributes based on inline CSS height values. + * + * @default true + */ + applyHeightAttributes?: boolean + /** + * Elements that can receive `width` HTML attributes. + * + * @default ['img', 'video'] + */ + widthElements?: string[] + /** + * Elements that can receive `height` HTML attributes. + * + * @default ['img', 'video'] + */ + heightElements?: string[] + /** + * CSS properties to exclude from inlining. + * + * @default [] + */ + excludedProperties?: string[] + /** + * Template language code blocks to preserve during inlining. + * + * @default { EJS: { start: '<%', end: '%>' }, HBS: { start: '\{\{', end: '}}' } } + */ + codeBlocks?: Record<string, { start: string; end: string }> + /** + * Additional CSS string to inline alongside `<style>` tag contents. + */ + customCSS?: string + } + /** + * Merge duplicate `@media` queries and sort them. + * + * Enabled by default. Set to `false` to disable, or pass an object to control sort order. + * + * @default true + * + * @example + * css: { + * media: { sort: 'desktop-first' }, + * } + */ + media?: boolean | { + /** + * Sort order for media queries. + * + * @default 'mobile-first' + */ + sort?: 'mobile-first' | 'desktop-first' | ((a: string, b: string) => number) + } + /** + * Convert unitless CSS values to their unitless equivalents. + * + * For example, `line-height: 24px` with a `16px` font becomes `line-height: 1.5`. + * + * @default true + */ + preferUnitless?: boolean + /** + * Resolve CSS `calc()` expressions to static values where possible. + * + * @default true + */ + resolveCalc?: boolean + /** + * Resolve CSS custom properties (`var()`) to their computed values. + * + * @default true + */ + resolveProps?: boolean + /** + * Replace unsafe CSS class names with email-safe equivalents. + * + * @default true + */ + safe?: boolean | Record<string, string> + /** + * Rewrite longhand CSS to shorthand where possible. + * + * For example, `padding: 10px 20px 10px 20px` becomes `padding: 10px 20px`. + * + * @default false + */ + shorthand?: boolean | { tags?: string[] } + /** + * Remove specific CSS declarations by selector. + * + * @example + * css: { + * removeDeclarations: { + * ':root': '*', + * } + * } + */ + removeDeclarations?: Record<string, import('../plugins/postcss/removeDeclarations.ts').RemoveValue> + /** + * File paths to exclude from CSS processing. + * + * @example + * css: { + * exclude: ['emails/amp/**'], + * } + */ + exclude?: string[] +} + +export interface AttributesConfig { + /** + * Add attributes to HTML elements. + * + * @example + * html: { + * attributes: { + * add: { + * table: { cellpadding: 0, cellspacing: 0, role: 'none' }, + * img: { alt: '' }, + * } + * } + * } + */ + add?: false | Record<string, Record<string, string | boolean | number>> + /** + * Remove attributes from HTML elements by name or name-value pair. + * + * @example + * html: { + * attributes: { + * remove: ['data-test', { name: 'class', value: /^js-/ }], + * } + * } + */ + remove?: Array<string | { name: string; value?: string | RegExp }> +} + +export type EntitiesConfig = boolean | Record<string, string> + +export interface PostcssConfig { + /** + * Selector prefixes to strip from compiled CSS. + * + * @default [':host', ':lang'] + * + * @example + * postcss: { + * removeSelectors: [':host', ':lang', ':root'], + * } + */ + removeSelectors?: string[] + /** + * At-rule names to strip from compiled CSS. + * + * @default ['layer', 'property'] + * + * @example + * postcss: { + * removeAtRules: ['layer', 'property', 'charset'], + * } + */ + removeAtRules?: string[] +} + +export interface HtmlConfig { + /** Configure HTML attribute transformations. */ + attributes?: AttributesConfig + /** + * Decode HTML entities. + * + * Set to `true` to decode all, or pass a map of entities to decode. + * + * @default true + */ + decodeEntities?: EntitiesConfig + /** + * Pretty-print the HTML output. + * + * Set to `true` to enable with defaults, or pass options. + */ + format?: boolean | import('oxfmt').FormatOptions + /** + * Minify the HTML output. + * + * Set to `true` to enable with defaults, or pass options. + */ + minify?: boolean | Record<string, unknown> +} + +export type FilterFunction = (str: string, value: string) => string +export type FiltersConfig = false | Record<string, FilterFunction> + +export interface MaizzleConfig { + /** + * Root directory for the Maizzle email project. + * + * When set, relative paths for `content`, `static.source`, + * and `css.base` are all resolved relative to this directory. + * + * Defaults to `process.cwd()`. + * + * @example + * maizzle({ + * root: 'resources/js/emails', + * content: ['./**\/*.vue'], + * }) + */ + root?: string + /** Options passed to `unplugin-vue-markdown` for Markdown template support. */ + markdown?: import('unplugin-vue-markdown/types').Options + /** + * Glob patterns for email template files to process. + * + * Resolved relative to `root`. + * + * @default ['emails/**\/*.{vue,md}'] + */ + content?: string[] + /** Output configuration for built email templates. */ + output?: { + /** + * Directory to write compiled HTML files to. + * + * @default 'dist' + */ + path?: string + /** + * File extension for compiled templates. + * + * @default 'html' + * + * @example + * output: { + * extension: 'blade.php', + * } + */ + extension?: string + } + /** Static file copying configuration. */ + static?: { + /** + * Glob patterns for static files to copy to the output directory. + * + * @default ['public/**\/*.*'] + */ + source?: string[] + /** + * Subdirectory in the output folder where static files are placed. + * + * @default 'public' + */ + destination?: string + } + /** Component auto-import configuration. */ + components?: { + /** + * Additional directories to scan for auto-imported Vue components. + * + * Resolved relative to `cwd` (not `root`), so paths outside the + * email root directory work as expected. + * + * @example + * components: { + * source: ['resources/js/components/email'], + * } + */ + source?: string | string[] + } + /** Dev server configuration. */ + server?: { + /** + * Port for the dev server. + * + * @default 3000 + */ + port?: number + /** + * Additional file paths to watch for changes. + * + * @default [] + * + * @example + * server: { + * watch: ['./tailwind.config.ts'], + * } + */ + watch?: string[] + } + /** Tailwind CSS and email CSS optimization settings. */ + css?: CssConfig + /** + * Generate a plaintext version of the email. + * + * Set to `true` to enable, or pass a string path or options object. + * + * @default false + */ + plaintext?: boolean | string | Record<string, unknown> + /** PostCSS processing options. */ + postcss?: PostcssConfig + /** + * Enable the transformer pipeline (CSS inlining, purging, shorthand, etc). + * + * @default true + */ + useTransformers?: boolean + /** + * Replace strings in the final HTML output. + * + * @example + * replaceStrings: { + * '{{ year }}': new Date().getFullYear().toString(), + * } + */ + replaceStrings?: Record<string, string> + /** + * Content filters that transform text inside HTML elements using custom attributes. + * + * Set to `false` to disable all filters. Pass an object to add custom filters + * (merged with built-in defaults). + * + * @example + * filters: { + * uppercase: str => str.toUpperCase(), + * } + */ + filters?: FiltersConfig + /** URL transformation settings (base URL, query string appending). */ + url?: UrlConfig + /** HTML post-processing settings (attributes, formatting, minification). */ + html?: HtmlConfig + + // Events + + /** Called before any templates are processed. */ + beforeCreate?: (params: { config: MaizzleConfig }) => void | Promise<void> + /** Called before each template is rendered. Return a string to replace the template source. */ + beforeRender?: (params: { config: MaizzleConfig; template: string }) => string | void | Promise<string | void> + /** Called after each template is rendered but before transformers run. Return a string to replace the output HTML. */ + afterRender?: (params: { config: MaizzleConfig; template: string; html: string }) => string | void | Promise<string | void> + /** Called after transformers have run on each template. Return a string to replace the output HTML. */ + afterTransform?: (params: { config: MaizzleConfig; template: string; html: string }) => string | void | Promise<string | void> + /** Called after all templates have been built. */ + afterBuild?: (params: { files: string[]; config: MaizzleConfig }) => void | Promise<void> + + // Allow arbitrary user data + [key: string]: any +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..0a6d0e5d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export type { MaizzleConfig, HtmlConfig, UrlConfig, UrlQuery, UrlQueryOptions, CssConfig, AttributesConfig, EntitiesConfig, PostcssConfig, FilterFunction, FiltersConfig } from './config.ts' diff --git a/src/types/modules.d.ts b/src/types/modules.d.ts new file mode 100644 index 00000000..0cfdcf9a --- /dev/null +++ b/src/types/modules.d.ts @@ -0,0 +1,5 @@ +declare module 'postcss-sort-media-queries' { + import type postcss from 'postcss' + function sortMediaQueries(options?: { sort?: 'mobile-first' | 'desktop-first' | ((a: string, b: string) => number) }): postcss.Plugin + export = sortMediaQueries +} diff --git a/src/utils/ast/index.ts b/src/utils/ast/index.ts new file mode 100644 index 00000000..bf580510 --- /dev/null +++ b/src/utils/ast/index.ts @@ -0,0 +1,3 @@ +export { parse } from './parser.ts' +export { walk } from './walker.ts' +export { serialize } from './serializer.ts' diff --git a/src/utils/ast/parser.ts b/src/utils/ast/parser.ts new file mode 100644 index 00000000..2f2c68f4 --- /dev/null +++ b/src/utils/ast/parser.ts @@ -0,0 +1,11 @@ +import { Parser } from 'htmlparser2' +import { DomHandler } from 'domhandler' +import type { ChildNode, DomHandlerOptions } from 'domhandler' + +export function parse(html: string, options: DomHandlerOptions = {}): ChildNode[] { + const handler = new DomHandler() + const parser = new Parser(handler, options) + parser.write(html) + parser.end() + return handler.dom +} diff --git a/src/utils/ast/serializer.ts b/src/utils/ast/serializer.ts new file mode 100644 index 00000000..7f1dfb6b --- /dev/null +++ b/src/utils/ast/serializer.ts @@ -0,0 +1,33 @@ +import render from 'dom-serializer' +import type { ChildNode } from 'domhandler' +import type { DomSerializerOptions } from 'dom-serializer' +import { walk } from './walker.ts' + +/** + * Re-encode < and > as entities in text nodes inside <code> elements. + * + * The DOM parser decodes entities like &#x3C; into raw < in text nodes. + * With encodeEntities: false the serializer outputs them as-is, which + * creates broken HTML (e.g. </a> inside a code block closes the real tag). + * + * We selectively fix this for <code> contents only, so the rest of the + * document (where encodeEntities: false is needed) is unaffected. + */ +function encodeCodeTextNodes(dom: ChildNode[]): void { + walk(dom, (node) => { + const el = node as import('domhandler').Element + if (el.name !== 'code') return + + walk(el.children ?? [], (child) => { + if (child.type === 'text') { + const text = child as import('domhandler').Text + text.data = text.data.replace(/</g, '&lt;').replace(/>/g, '&gt;') + } + }) + }) +} + +export function serialize(dom: ChildNode[], options?: DomSerializerOptions): string { + encodeCodeTextNodes(dom) + return render(dom, { encodeEntities: false, ...options }) +} diff --git a/src/utils/ast/walker.ts b/src/utils/ast/walker.ts new file mode 100644 index 00000000..8f3b311e --- /dev/null +++ b/src/utils/ast/walker.ts @@ -0,0 +1,17 @@ +import type { ChildNode } from 'domhandler' + +export function walk(ast: ChildNode[], callback: (node: ChildNode) => void): void { + function traverse(node: ChildNode) { + callback(node) + + if ('children' in node && node.children && node.children.length > 0) { + for (const child of node.children) { + traverse(child) + } + } + } + + for (const node of ast) { + traverse(node) + } +} diff --git a/src/utils/detect.ts b/src/utils/detect.ts new file mode 100644 index 00000000..db27777a --- /dev/null +++ b/src/utils/detect.ts @@ -0,0 +1,6 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +export function isLaravel(cwd: string = process.cwd()): boolean { + return existsSync(resolve(cwd, 'artisan')) +} diff --git a/src/utils/getConfigByFilePath.js b/src/utils/getConfigByFilePath.js deleted file mode 100644 index ff929790..00000000 --- a/src/utils/getConfigByFilePath.js +++ /dev/null @@ -1,143 +0,0 @@ -import { resolve } from 'pathe' -import get from 'lodash/get.js' -import { defu as merge } from 'defu' -import { lstat } from 'node:fs/promises' -import isEmpty from 'lodash-es/isEmpty.js' - -/** - * Compute the Maizzle config object. - * - * If a config file path is provided, that file will be read - * instead of trying to merge the base and environment configs. - * - * If an environment is provided, the config object will be - * computed based on a base config and the resolved - * environment config. - * - * @param {string} env - The environment name to use. - * @param {string} path - The path to the config file to use. - * @returns {Promise<object>} The computed config object. - */ -export async function readFileConfig(config) { - try { - /** - * If `config` is string, try to read and return - * the config object from it. - */ - if (typeof config === 'string' && config) { - const { default: resolvedConfig } = await import(`file://${resolve(config)}?d=${Date.now()}`) - .catch(() => { throw new Error('Could not read config file') }) - - return merge(resolvedConfig, { env: config }) - } - - /** - * Otherwise, default to the Environment config approach, - * where we check for config files that follow a - * specific naming convention. - * - * First, we check for a base config file, in this order: - */ - const baseConfigFileNames = [ - './maizzle.config.js', - './maizzle.config.local.js', - './config.js', - './config.local.js', - './maizzle.config.cjs', - './maizzle.config.local.cjs', - './config.cjs', - './config.local.cjs', - ] - - const env = get(config, 'env', 'local') - let baseConfig = merge({ env }, config) - let envConfig = merge({ env }, config) - - const cwd = env === 'maizzle-ci' ? './test/stubs/config' : process.cwd() - - // We load the first base config found - for (const module of baseConfigFileNames) { - // Check if the file exists, go to next one if not - const configFileExists = await lstat(resolve(cwd, module)).catch(() => false) - - if (!configFileExists) { - continue - } - - // Load the config file - try { - const { default: baseConfigFile } = await import(`file://${resolve(cwd, module)}?d=${Date.now()}`) - - // Use the first base config found - if (!isEmpty(baseConfigFile)) { - baseConfig = merge(baseConfigFile, baseConfig) - break - } - } catch (_error) { - break - } - - } - - // Then, we load and compute the first Environment config found - if (env !== 'local') { - let loaded = false - const modulesToTry = [ - `./maizzle.config.${env}.js`, - `./config.${env}.js`, - `./maizzle.config.${env}.cjs`, - `./config.${env}.cjs`, - ] - - for (const module of modulesToTry) { - // Check if the file exists, go to next one if not - const configFileExists = await lstat(resolve(cwd, module)).catch(() => false) - - if (!configFileExists) { - continue - } - - // Load the config file - try { - const { default: envConfigFile } = await import(`file://${resolve(cwd, module)}?d=${Date.now()}`) - - // If it's not an empty object, merge it with the base config - if (!isEmpty(envConfigFile)) { - envConfig = merge(envConfigFile, envConfig) - loaded = true - break - } - } catch (_error) { - break - } - } - - if (!loaded) { - throw new Error(`Failed to load the \`${env}\` environment config, do you have one of these files in your project root?\n\n${modulesToTry.join('\n')}`) - } - } - - /** - * Override the `build.content` key in `baseConfig` with the one in `envConfig` - * if present. We do this so that each build uses its own `content` paths, - * in order to avoid compiling unnecessary files. - */ - if (envConfig.build && Array.isArray(envConfig.build.content)) { - baseConfig.build = baseConfig.build || {} - baseConfig.build.content = envConfig.build.content - // Remove build.content from envConfig to prevent merging duplicates - envConfig = { ...envConfig, build: { ...envConfig.build, content: undefined } } - } - - // Merge envConfig and baseConfig, but ensure build.content is not duplicated - const merged = merge(envConfig, baseConfig) - - if (baseConfig.build && Array.isArray(baseConfig.build.content)) { - merged.build.content = baseConfig.build.content - } - - return merged - } catch (_error) { - throw new Error('Could not compute config') - } -} diff --git a/src/utils/node.js b/src/utils/node.js deleted file mode 100644 index f93f28b4..00000000 --- a/src/utils/node.js +++ /dev/null @@ -1,68 +0,0 @@ -import os from 'node:os' -import gm from 'gray-matter' -import pico from 'picocolors' -import { humanFileSize } from './string.js' - -// Return a local IP address -export function getLocalIP() { - const interfaces = os.networkInterfaces() - - for (const iface in interfaces) { - const ifaceInfo = interfaces[iface] - - for (const alias of ifaceInfo) { - if (alias.family === 'IPv4' && !alias.internal) { - return alias.address - } - } - } - - return '127.0.0.1' // Default to localhost if no suitable IP is found -} - -/** - * Return the file size of a string - * - * @param {string} string The HTML string to calculate the file size of - * @returns {number} The file size in bytes, a floating-point number - * */ -export function getFileSize(string) { - const blob = new Blob([string], { type: 'text/html' }) - - return blob.size.toFixed(2) -} - -/** - * Color-code a formatted file size string depending on the size - * - * 0-49 KB: green - * 50-102 KB: yellow - * >102 KB: red - * - * @param {string} string The HTML string to calculate the file size of - * @returns {string} The formatted, color-coded file size - * */ -export function getColorizedFileSize(string) { - const size = getFileSize(string) - const formattedSize = humanFileSize(size) - - if (size / 1024 < 50) { - return formattedSize - } - - if (size / 1024 < 102) { - return pico.yellow(formattedSize) - } - - return pico.red(formattedSize) -} - -export function parseFrontMatter(html) { - /** - * Need to pass empty options object to gray-matter - * in order to disable caching. - * https://github.com/jonschlinkert/gray-matter/issues/43 - */ - const { content, data, matter, stringify } = gm(html, {}) - return { content, data, matter, stringify } -} diff --git a/src/utils/string.js b/src/utils/string.js deleted file mode 100644 index 03e3fe39..00000000 --- a/src/utils/string.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Inject a script into HTML by "prepending" it to the first available closing - * tag from a list of candidates. - * - * @param {string} html The HTML content - * @param {string} script The script to inject - * @returns {string} The modified HTML - */ -export function injectScript(html = '', script = '') { - if (html.includes('</head>')) { - return html.replace('</head>', `${script}</head>`) - } - - if (html.includes('</title>')) { - return html.replace('</title>', `</title>${script}`) - } - - if (html.includes('</body>')) { - return html.replace('</body>', `${script}</body>`) - } - - if (html.includes('</html>')) { - return html.replace('</html>', `${script}</html>`) - } - - if (html.includes('<!doctype html>')) { - return html.replace('<!doctype html>', `<!doctype html>${script}`) - } - - return script + html -} - -/** - * Find the common prefix among an array of strings. - * - * @param {string[]} strings Array of strings - * @returns {string} Common prefix - * @throws {TypeError} If the input is not an array - */ -export function findCommonPrefix(strings) { - // Must be an array - if (!Array.isArray(strings)) { - throw new TypeError('findCommonPrefix expects an array') - } - - const sortedStrings = strings.slice().sort() - const first = sortedStrings[0] - const last = sortedStrings[sortedStrings.length - 1] - let i = 0 - - while (i < first.length && first.charAt(i) === last.charAt(i)) { - i++ - } - - return first.substring(0, i) -} - -export function formatMs(milliseconds) { - const date = new Date(milliseconds); - - const hours = date.getUTCHours(); - const minutes = date.getUTCMinutes(); - const seconds = date.getUTCSeconds(); - const formattedTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - - return formattedTime; -} - -/** - * Format milliseconds as human-readable text. - * - * @param {number} ms Number of milliseconds. - * @returns {string} Formatted string. - */ -export function formatTime(ms) { - if (ms < 1000) { - return `${ms} ms` - } - - if (ms < 60000) { // Less than 1 minute - const seconds = ms / 1000 - return `${seconds.toFixed(2)} s` - } - - const minutes = ms / 60000 - return `${minutes.toFixed(2)} min` -} - -/** - * Format bytes as human-readable text. - * - * @param {number} bytes Number of bytes. - * @param {boolean} si True to use metric (SI) units, aka powers of 1000. False to use - * binary (IEC), aka powers of 1024. - * @param {number} dp Number of decimal places to display. - * - * @return {string} Formatted string. - */ -export function humanFileSize(bytes, si=false, dp=2) { - const threshold = si ? 1000 : 1024 - - if (Math.abs(bytes) < threshold) { - return bytes + ' B' - } - - const units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - let u = -1 - const r = 10**dp - - do { - bytes /= threshold - ++u - } while (Math.round(Math.abs(bytes) * r) / r >= threshold && u < units.length - 1) - - - return bytes.toFixed(dp) + ' ' + units[u] -} - -/** - * Get the root directories from a list of glob patterns. - * - * @param {array} patterns List of glob patterns. - * @returns {array} List of root directories. - */ -export function getRootDirectories(patterns = []) { - if (!Array.isArray(patterns)) { - return [] - } - - if (patterns.length === 0) { - return [] - } - - return [...new Set( - patterns - .filter(pattern => !pattern.startsWith('!')) - .map(pattern => { - // If the pattern doesn't include wildcards, use it as is - if (!pattern.includes('*')) { - return pattern.replace(/\/$/, '') // Remove trailing slash if present - } - // For patterns with wildcards, get the part before the first wildcard - const parts = pattern.split(/[*{]/)[0].split('/') - return parts.slice(0, -1).join('/') - }) - .filter(Boolean) - )] -} - -/** - * Get the file extensions from a glob pattern. - * @param {*} pattern - * @returns - */ -export function getFileExtensionsFromPattern(pattern) { - // biome-ignore lint: needs to be escaped - const starExtPattern = /\.([^\*\{\}]+)$/ // Matches .ext but not .* or .{ext} - const bracePattern = /\.{([^}]+)}$/ // Matches .{ext} or .{ext,ext} - const wildcardPattern = /\.\*$/ // Matches .* - - if (wildcardPattern.test(pattern)) { - return ['html'] // We default to 'html' if the pattern is a wildcard - } - - const braceMatch = pattern.match(bracePattern); - if (braceMatch) { - return braceMatch[1].split(',') // Split and return extensions inside braces - } - - const starExtMatch = pattern.match(starExtPattern) - if (starExtMatch) { - return [starExtMatch[1]] // Return single extension - } - - return ['html'] // No recognizable extension pattern, default to 'html' -} - -/** - * Normalize a string by removing extra whitespace. - * - * @param {String} str The string to clean - * @returns {String} The cleaned string - */ -export function cleanString(str) { - return str.replace(/\s+/g, ' ').trim() -} diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 00000000..8b4ccb55 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,35 @@ +import isUrl from 'is-url-superb' + +export const defaultTags: Record<string, string[]> = { + a: ['href'], + img: ['src', 'srcset'], + video: ['src', 'poster'], + source: ['src', 'srcset'], + link: ['href'], + script: ['src'], + object: ['data'], + embed: ['src'], + iframe: ['src'], + 'v:image': ['src'], + 'v:fill': ['src'], +} + +export const urlAttributes = [...new Set(Object.values(defaultTags).flat())] + +export function isAbsoluteUrl(url: string): boolean { + if (!url) return true + + return url.startsWith('//') || url.startsWith('#') || url.startsWith('?') || isUrl(url) +} + +export function processSrcset(srcset: string, baseUrl: string): string { + return srcset.split(',').map(entry => { + const parts = entry.trim().split(/\s+/) + + if (parts[0] && !isAbsoluteUrl(parts[0])) { + parts[0] = baseUrl + parts[0] + } + + return parts.join(' ') + }).join(', ') +} diff --git a/test/build.test.js b/test/build.test.js deleted file mode 100644 index 796abda7..00000000 --- a/test/build.test.js +++ /dev/null @@ -1,234 +0,0 @@ -import path from 'pathe' -import build from '../src/commands/build.js' -import { rm, readFile } from 'node:fs/promises' -import { describe, expect, test, beforeEach, afterEach, afterAll, vi } from 'vitest' - -describe.concurrent('Build', () => { - beforeEach(async context => { - context.folder = '_temp_' + Math.random().toString(36).slice(2, 9) - }) - - afterEach(async context => { - if (context.folder) { - await rm(context.folder, { recursive: true }).catch(() => {}) - context.folder = undefined - } - }) - - test('Throws if no config found', async () => { - await expect(() => build({})).rejects.toThrow() - }) - - test('Throws if no templates found', async () => { - await expect(() => build({ build: { content: ['test/fixtures/**/*.php'] } })).rejects.toThrow('No templates found in') - }) - - test('Throws if build.files is invalid', async () => { - await expect(() => build({ - build: { - content: true - } - })).rejects.toThrow() - }) - - test('Runs `beforeCreate` event', async ctx => { - const { config } = await build( - { - build: { - content: ['test/fixtures/build/**/*.html'], - output: { - path: ctx.folder - } - }, - async beforeCreate({ config }) { - config.foo = '`beforeCreate` with build()' - expect(config).toBeInstanceOf(Object) - } - } - ) - - expect(config.foo).toBe('`beforeCreate` with build()') - }) - - test('Runs `afterBuild` event', async ctx => { - await build( - { - build: { - content: ['test/fixtures/build/beforeCreate.html'], - output: { - path: ctx.folder - } - }, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - }, - async afterBuild({ files, config }) { - ctx.afterBuild = files - expect(config).toBeInstanceOf(Object) - expect(files).toBeInstanceOf(Array) - } - } - ) - - expect(ctx.afterBuild).toContain(`${ctx.folder}/test/fixtures/build/beforeCreate.html`) - }) - - test('Outputs files', async ctx => { - ctx.arrayGlobFiles = await build( - { - build: { - content: [ - 'test/fixtures/**/*.html', - '!test/fixtures/filters.html', - '!test/fixtures/build/expandLinkTag.html' - ], - output: { - path: ctx.folder, - extension: 'php' - }, - spinner: false, - }, - css: { - resolveCalc: false, - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - } - } - ).then(({ files }) => files) - - ctx.stringGlobFiles = await build( - { - build: { - content: ['test/fixtures/**/*.html'], - output: { - path: path.join(ctx.folder, 'str'), - from: 'test/fixtures', - } - }, - components: { - fileExtension: 'html', - test: true, - }, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - } - } - ).then(({ files }) => files) - - expect(ctx.arrayGlobFiles.length).toBe(4) - expect(ctx.arrayGlobFiles).toContain(`${ctx.folder}/test/fixtures/build/beforeCreate.php`) - - expect(ctx.stringGlobFiles.length).toBe(6) - expect(ctx.stringGlobFiles).toContain(`${ctx.folder}/str/filters.html`) - }) - - test('Logs build report', async ctx => { - const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined) - - afterAll(() => { - consoleMock.mockReset() - }) - - await build( - { - build: { - content: ['test/fixtures/build/**/*.html'], - output: { - path: ctx.folder - }, - summary: true - }, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - } - } - ) - - expect(consoleMock).toHaveBeenCalled() - expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('Build time')) - }) - - test('Copies static files to output directory', async ctx => { - ctx.files = await build( - { - build: { - content: ['test/fixtures/build/**/*.html'], - output: { - path: ctx.folder - }, - static: [ - { - source: ['test/stubs/static/**/*'], - }, - { - source: ['test/stubs/components/**/*'], - destination: 'build/components', - }, - ], - }, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - }, - } - ).then(({ files }) => files) - - expect(ctx.files.length).toBe(6) - expect(ctx.files).toContain(`${ctx.folder}/test/fixtures/build/image.png`) - expect(ctx.files).toContain(`${ctx.folder}/plain.txt`) - expect(ctx.files).toContain(`${ctx.folder}/build/components/list.html`) - }) - - test('Generates plaintext file', async ctx => { - const { files } = await build( - { - build: { - content: ['test/fixtures/build/beforeCreate.html'], - output: { - path: ctx.folder - }, - }, - plaintext: true, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - }, - } - ) - - expect(files).toContain(`${ctx.folder}/test/fixtures/build/beforeCreate.txt`) - }) - - test('Expands <link> tags', async ctx => { - const { files } = await build( - { - build: { - content: ['test/fixtures/build/**/*.html'], - output: { - path: ctx.folder - }, - }, - css: { - tailwind: { - content: ['test/fixtures/build/**/*.html'] - } - }, - } - ) - - const fileContents = await readFile(files.filter(f => f.includes('expandLinkTag'))[0], 'utf8') - - expect(fileContents).toContain('display: none') - expect(fileContents).not.toContain('expand') - expect(fileContents).toContain('link') - }) -}) diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index 6f2d577b..00000000 --- a/test/config.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' -import { readFileConfig } from '../src/utils/getConfigByFilePath' - -vi.mock('module', () => ({ - async import(path) { - // Simulate file not found or error condition - if (path.includes('config.invalid.js')) { - throw new Error('File not found') - } - - // Simulate a successful import for other paths - return { default: { foo: 'bar' } } - }, -})) - -describe.concurrent('Config', () => { - test('Throws if it cannot load the config', async () => { - await expect(readFileConfig('config.invalid.js')) - .rejects.toThrow('Could not compute config') - - await expect(readFileConfig({ env: 'invalid' })) - .rejects.toThrow('Could not compute config') - }) - - test('Returns the resolved config', async () => { - const config = await readFileConfig('test/stubs/config/config.js') - - expect(config).toHaveProperty('foo', 'bar') - }) - - test('Uses the correct environment config', async () => { - const config = await readFileConfig({ env: 'maizzle-ci' }) - - expect(config) - .toHaveProperty('env', 'maizzle-ci') - .toHaveProperty('local', true) - .toHaveProperty('foo', 'bar') - }) - - test('Overrides `content` in base config', async () => { - const config = await readFileConfig({ build: { content: ['maizzle-ci'] } }) - - expect(config) - .toHaveProperty('env', 'local') - .toHaveProperty('build.content', ['maizzle-ci']) - }) -}) diff --git a/test/expected/base-url.html b/test/expected/base-url.html deleted file mode 100644 index d0b64287..00000000 --- a/test/expected/base-url.html +++ /dev/null @@ -1,112 +0,0 @@ -<html> - <head> - <style>.test { - background-image: url('https://example.com/image.jpg'); - background: url('https://example.com/image.jpg'); - background-image: url('https://preserve.me/image.jpg'); - background: url('https://preserve.me/image.jpg'); - } - - .test-2 { - background-image: url("https://example.com/image.jpg"); - background: url("https://example.com/image.jpg"); - background-image: url("https://preserve.me/image.jpg"); - background: url("https://preserve.me/image.jpg"); - } - - .test-3 { - background-image: url(https://example.com/image.jpg); - background: url(https://example.com/image.jpg); - background-image: url(https://preserve.me/image.jpg); - background: url(https://preserve.me/image.jpg); - }</style> - </head> - - <body> - <img src="https://example.com/test.jpg"> - <img src="https://example.com/test.jpg"> - - <img src="https://example.com/image1.jpg" srcset="https://example.com/image1-HD.jpg 2x, https://example.com/image1-phone.jpg 100w"> - - <img src="https://example.com/image2.jpg" srcset="https://example.com/image2-HD.jpg 2x, https://example.com/image2-phone.jpg 100w"> - - <picture> - <source media="(max-width: 799px)" srcset="https://example.com/elva-480w-close-portrait.jpg"> - <source media="(min-width: 800px)" srcset="https://example.com/elva-800w.jpg"> - <img src="https://example.com/elva-800w.jpg" alt="..."> - </picture> - - <video width="250" poster="https://example.com/flower.jpg"> - <source src="https://example.com/media/flower.webm" type="video/webm"> - <source src="https://example.tv/media/flower.mp4" type="video/mp4"> - <track default kind="captions" srclang="en" src="https://example.com/media/tracks/friday.vtt"> - </video> - - <audio src="https://example.com/media/sample.mp3"> - Fallback content - </audio> - - <embed type="video/webm" src="https://example.com/media/flower.mp4" width="250" height="200"> - - <iframe width="300" height="200" src="https://example.com/embed.html"></iframe> - - <input type="image" src="https://example.com/image.jpg" alt=""> - - <script src="https://example.com/javascript.js"></script> - - <div> - <!--[if mso]> - <v:image xmlns:v="urn:schemas-microsoft-com:vml" src="https://example.com/image.jpg" style="width:600px;height:400px;" /> - <v:rect fill="false" stroke="false" style="position:absolute;width:600px;height:400px;"> - <v:textbox inset="0,0,0,0"><div><![endif]--> - <div>test</div> - <!--[if mso]></div></v:textbox></v:rect><![endif]--> - </div> - - <!--[if mso]> - <v:image src="https://example.com/image-2.jpg" xmlns:v="urn:schemas-microsoft-com:vml" style="width:600px;height:400px;" /> - <![endif]--> - - <!--[if mso]> - <v:image xmlns:v="urn:schemas-microsoft-com:vml" src="https://example.com/image-3.jpg" style="width:600px;height:400px;" /> - <![endif]--> - - <table> - <tr> - <td background="https://example.com/image.png" bgcolor="#7bceeb" width="120" height="92" valign="top"> - <!--[if gte mso 9]> - <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:120px;height:92px;"> - <v:fill type="tile" src="https://example.com/image.png" color="#7bceeb" /> - <v:textbox inset="0,0,0,0"> - <![endif]--> - <div>test</div> - <!--[if gte mso 9]> - </v:textbox> - </v:rect> - <![endif]--> - </td> - </tr> - </table> - - <!--[if mso]> - <v:fill type="tile" src="https://example.com/image.png" color="#7bceeb" /> - <![endif]--> - - <!--[if mso]> - <v:fill type="tile" src="https://example.com/image.png" color="#7bceeb" /> - <![endif]--> - - <!--[if mso]> - <v:image src="https://example.com/image-2.jpg" xmlns:v="urn:schemas-microsoft-com:vml" style="width:600px;height:400px;" /> - <v:rect xmlns:v="urn:schemas-microsoft-com:vml" stroked="f" filled="f" style="mso-width-percent: 1000; position: absolute; top: 256px; left: 128x;"> - <v:fill type="tile" src="https://example.com/image.png" color="#7bceeb" /> - <v:textbox style="mso-fit-shape-to-text:true;" inset="0,0,0,0"> - <video src="https://example.com/video.mp4" poster="https://example.com/example.png"></video> - <div> - <img src="https://example.com/example.png" srcset="https://example.com/image1.png 1x, https://example.com/image2.png 2x"> - </div> - </v:textbox> - </v:rect> - <![endif]--> - </body> -</html> diff --git a/test/expected/build/beforeCreate.html b/test/expected/build/beforeCreate.html deleted file mode 100644 index d3844795..00000000 --- a/test/expected/build/beforeCreate.html +++ /dev/null @@ -1 +0,0 @@ -`beforeCreate` with build() diff --git a/test/expected/filters.html b/test/expected/filters.html deleted file mode 100644 index 6f01d220..00000000 --- a/test/expected/filters.html +++ /dev/null @@ -1,91 +0,0 @@ -<!-- Append --> -<div>testing append</div> -<!-- Prepend --> -<div>testing prepend</div> - -<!-- Uppercase --> -<div>TEST</div> -<!-- Lowercase --> -<div>test</div> -<!-- Capitalize --> -<div>Test</div> - -<!-- Ceil --> -<div>2</div> -<!-- Floor --> -<div>1</div> -<!-- Round --> -<div>1235</div> - -<!-- Escape --> -<div>&#34;&amp;&#39;&lt;&gt;</div> -<!-- Escape Once --> -<div>1 &lt; 2 &amp; 3</div> - -<!-- lstrip --> -<div>test </div> -<!-- rstrip --> -<div> test</div> -<!-- trim --> -<div>test</div> - -<!-- Minus --> -<div>1.02</div> -<!-- Plus --> -<div>5.02</div> -<!-- Times --> -<div>12.08</div> -<!-- Divide --> -<div>6.04</div> -<!-- Modulo --> -<div>1</div> - -<!-- Newline to br --> -<div><br> test<br> test<br></div> -<!-- Strip newlines --> -<div> test test</div> - -<!-- Remove --> -<div>I sted to see the t through the </div> -<!-- Remove First --> -<div>I sted to see the train through the rain</div> - -<!-- Replace --> -<div>testestest</div> -<!-- Replace First --> -<div>testest</div> - -<!-- Size --> -<div>33</div> - -<!-- Slice --> -<div>est</div> -<!-- Slice with endIndex --> -<div>tes</div> -<!-- Slice with invalid attribute --> -<div>test</div> - -<!-- Truncate --> -<div>Ground control to...</div> -<!-- Truncate (do nothing) --> -<div>Ground control to</div> -<!-- Truncate with custom ellipsis --> -<div>Ground control to no one</div> -<!-- Truncate error --> -<div>undefined</div> - -<!-- Truncate words --> -<div>Ground control...</div> -<!-- Truncate words with custom ellipsis --> -<div>Ground control over and out</div> - -<!-- Custom: underscore-case --> -<div>t_e_s_t</div> - -<!-- Unescape --> -<div>"&'<></div> - -<!-- URL decode --> -<div>'Stop!' said Fred</div> -<!-- URL encode --> -<div>user%40example.com</div> diff --git a/test/fixtures/base-url.html b/test/fixtures/base-url.html deleted file mode 100644 index 4ad0cb10..00000000 --- a/test/fixtures/base-url.html +++ /dev/null @@ -1,114 +0,0 @@ -<html> - <head> - <style> - .test { - background-image: url('image.jpg'); - background: url('image.jpg'); - background-image: url('https://preserve.me/image.jpg'); - background: url('https://preserve.me/image.jpg'); - } - - .test-2 { - background-image: url("image.jpg"); - background: url("image.jpg"); - background-image: url("https://preserve.me/image.jpg"); - background: url("https://preserve.me/image.jpg"); - } - - .test-3 { - background-image: url(image.jpg); - background: url(image.jpg); - background-image: url(https://preserve.me/image.jpg); - background: url(https://preserve.me/image.jpg); - } - </style> - </head> - - <body> - <img src="test.jpg"> - <img src="https://example.com/test.jpg"> - - <img src="image1.jpg" srcset="image1-HD.jpg 2x,image1-phone.jpg 100w"> - - <img src="https://example.com/image2.jpg" srcset="https://example.com/image2-HD.jpg 2x, https://example.com/image2-phone.jpg 100w"> - - <picture> - <source media="(max-width: 799px)" srcset="elva-480w-close-portrait.jpg"> - <source media="(min-width: 800px)" srcset="elva-800w.jpg"> - <img src="elva-800w.jpg" alt="..."> - </picture> - - <video width="250" poster="flower.jpg"> - <source src="media/flower.webm" type="video/webm"> - <source src="https://example.tv/media/flower.mp4" type="video/mp4"> - <track default kind="captions" srclang="en" src="media/tracks/friday.vtt"> - </video> - - <audio src="media/sample.mp3"> - Fallback content - </audio> - - <embed type="video/webm" src="media/flower.mp4" width="250" height="200"> - - <iframe width="300" height="200" src="embed.html"></iframe> - - <input type="image" src="image.jpg" alt=""> - - <script src="javascript.js"></script> - - <div> - <!--[if mso]> - <v:image xmlns:v="urn:schemas-microsoft-com:vml" src="image.jpg" style="width:600px;height:400px;" /> - <v:rect fill="false" stroke="false" style="position:absolute;width:600px;height:400px;"> - <v:textbox inset="0,0,0,0"><div><![endif]--> - <div>test</div> - <!--[if mso]></div></v:textbox></v:rect><![endif]--> - </div> - - <!--[if mso]> - <v:image src="image-2.jpg" xmlns:v="urn:schemas-microsoft-com:vml" style="width:600px;height:400px;" /> - <![endif]--> - - <!--[if mso]> - <v:image xmlns:v="urn:schemas-microsoft-com:vml" src="https://example.com/image-3.jpg" style="width:600px;height:400px;" /> - <![endif]--> - - <table> - <tr> - <td background="image.png" bgcolor="#7bceeb" width="120" height="92" valign="top"> - <!--[if gte mso 9]> - <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:120px;height:92px;"> - <v:fill type="tile" src="image.png" color="#7bceeb" /> - <v:textbox inset="0,0,0,0"> - <![endif]--> - <div>test</div> - <!--[if gte mso 9]> - </v:textbox> - </v:rect> - <![endif]--> - </td> - </tr> - </table> - - <!--[if mso]> - <v:fill type="tile" src="image.png" color="#7bceeb" /> - <![endif]--> - - <!--[if mso]> - <v:fill type="tile" src="https://example.com/image.png" color="#7bceeb" /> - <![endif]--> - - <!--[if mso]> - <v:image src="image-2.jpg" xmlns:v="urn:schemas-microsoft-com:vml" style="width:600px;height:400px;" /> - <v:rect xmlns:v="urn:schemas-microsoft-com:vml" stroked="f" filled="f" style="mso-width-percent: 1000; position: absolute; top: 256px; left: 128x;"> - <v:fill type="tile" src="image.png" color="#7bceeb" /> - <v:textbox style="mso-fit-shape-to-text:true;" inset="0,0,0,0"> - <video src="video.mp4" poster="example.png"></video> - <div> - <img src="example.png" srcset="image1.png 1x, image2.png 2x"> - </div> - </v:textbox> - </v:rect> - <![endif]--> - </body> -</html> diff --git a/test/fixtures/build/beforeCreate.html b/test/fixtures/build/beforeCreate.html deleted file mode 100644 index e4b8fddc..00000000 --- a/test/fixtures/build/beforeCreate.html +++ /dev/null @@ -1 +0,0 @@ -{{ page.foo }} diff --git a/test/fixtures/build/expandLinkTag.html b/test/fixtures/build/expandLinkTag.html deleted file mode 100644 index cd21736d..00000000 --- a/test/fixtures/build/expandLinkTag.html +++ /dev/null @@ -1,4 +0,0 @@ -<link rel="stylesheet" href="test/stubs/foo.css"> -<link rel="stylesheet" href="test/stubs/style.css" expand> - -<div class="hidden">test</div> diff --git a/test/fixtures/build/image.png b/test/fixtures/build/image.png deleted file mode 100644 index e69de29b..00000000 diff --git a/test/fixtures/build/with spaces.html b/test/fixtures/build/with spaces.html deleted file mode 100644 index 153d1940..00000000 --- a/test/fixtures/build/with spaces.html +++ /dev/null @@ -1 +0,0 @@ -works diff --git a/test/fixtures/filters.html b/test/fixtures/filters.html deleted file mode 100644 index c46b8300..00000000 --- a/test/fixtures/filters.html +++ /dev/null @@ -1,97 +0,0 @@ -<!-- Append --> -<div append="ing append">test</div> -<!-- Prepend --> -<div prepend="test">ing prepend</div> - -<!-- Uppercase --> -<div uppercase>test</div> -<!-- Lowercase --> -<div lowercase>TEST</div> -<!-- Capitalize --> -<div capitalize>test</div> - -<!-- Ceil --> -<div ceil>1.2</div> -<!-- Floor --> -<div floor>1.2</div> -<!-- Round --> -<div round>1234.567</div> - -<!-- Escape --> -<div escape>"&'<></div> -<!-- Escape Once --> -<div escape-once>1 &lt; 2 &amp; 3</div> - -<!-- lstrip --> -<div lstrip> test </div> -<!-- rstrip --> -<div rstrip> test </div> -<!-- trim --> -<div trim> test </div> - -<!-- Minus --> -<div minus="2">3.02</div> -<!-- Plus --> -<div plus="2">3.02</div> -<!-- Times --> -<div times="2">6.04</div> -<!-- Divide --> -<div divide-by="2">12.08</div> -<!-- Modulo --> -<div modulo="2">3</div> - -<!-- Newline to br --> -<div newline-to-br> - test - test -</div> -<!-- Strip newlines --> -<div strip-newlines> - test - test -</div> - -<!-- Remove --> -<div remove="rain">I strained to see the train through the rain</div> -<!-- Remove First --> -<div remove-first="rain">I strained to see the train through the rain</div> - -<!-- Replace --> -<div replace="t|test">test</div> -<!-- Replace First --> -<div replace-first="t|test">test</div> - -<!-- Size --> -<div size>This string is 33 characters long</div> - -<!-- Slice --> -<div slice="1">test</div> -<!-- Slice with endIndex --> -<div slice="0,-1">test</div> -<!-- Slice with invalid attribute --> -<div slice="">test</div> - -<!-- Truncate --> -<div truncate="17">Ground control to Major Tom.</div> -<!-- Truncate (do nothing) --> -<div truncate="17">Ground control to</div> -<!-- Truncate with custom ellipsis --> -<div truncate="17, no one">Ground control to Major Tom.</div> -<!-- Truncate error --> -<div truncate=""></div> - -<!-- Truncate words --> -<div truncate-words="2">Ground control to Major Tom.</div> -<!-- Truncate words with custom ellipsis --> -<div truncate-words="2, over and out">Ground control to Major Tom.</div> - -<!-- Custom: underscore-case --> -<div underscore-case>test</div> - -<!-- Unescape --> -<div unescape>&#34;&amp;&#39;&lt;&gt;</div> - -<!-- URL decode --> -<div url-decode>%27Stop%21%27+said+Fred</div> -<!-- URL encode --> -<div url-encode>user@example.com</div> diff --git a/test/plaintext.test.js b/test/plaintext.test.js deleted file mode 100644 index af85122e..00000000 --- a/test/plaintext.test.js +++ /dev/null @@ -1,157 +0,0 @@ -import { - beforeEach, - afterEach, - describe, - expect, - test } from 'vitest' -import { - generatePlaintext, - handlePlaintextTags, - writePlaintextFile -} from '../src/generators/plaintext.js' -import { rm, readdir, readFile } from 'node:fs/promises' - -const minify = html => html.replaceAll(/[\s\n]+/g, '') - -describe.concurrent('Plaintext', () => { - beforeEach(async context => { - context.folder = '_temp_' + Math.random().toString(36).slice(2, 9) - }) - - afterEach(async context => { - if (context.folder) { - await rm(context.folder, { recursive: true }).catch(() => {}) - context.folder = undefined - } - }) - - test('Throws is plaintext content is not provided', async () => { - await expect(writePlaintextFile(undefined)).rejects.toThrow('Missing plaintext content.') - }) - - test('Throws is plaintext content is not a string', async () => { - await expect(writePlaintextFile(true)).rejects.toThrow('Plaintext content must be a string.') - }) - - test('Does not generate plaintext if no HTML is provided', async () => { - expect(await generatePlaintext()).toBe('') - }) - - test('Generates plaintext', async () => { - const html = ` - <div>Show in HTML</div> - <plaintext>Show in plaintext</plaintext> - <not-plaintext> - <p>Do not show <a href="url">this</a> in plaintext.</p> - </not-plaintext> - ` - - const expected = 'Show in HTML\nShow in plaintext' - - const result = await generatePlaintext(html) - - expect(result).toBe(expected) - }) - - test('Generates plaintext (with options)', async () => { - const html = ` - <div>Show in HTML &amp; plaintext &check;</div> - <plaintext>Show <a href="https://example.com">this</a> in plaintext</plaintext> - <not-plaintext> - <p>Do not show this in plaintext.</p> - </not-plaintext> - ` - - const expected = 'Show in HTML & plaintext ✓\nShow this [https://example.com] in plaintext' - - const result = await generatePlaintext( - html, - { - dumpLinkHrefsNearby: { - wrapHeads: '[', - wrapTails: ']' - }, - posthtml: { - decodeEntities: true - } - }, - ) - - expect(result).toBe(expected) - }) - - test('Outputs plaintext files', async ctx => { - /** - * `plaintext` as a file path - */ - const withOptions = await writePlaintextFile('test', { - plaintext: `${ctx.folder}/plaintext.txt` - }) - - const withOptionsFiles = await readdir(ctx.folder) - const withOptionsFileContents = await readFile(`${ctx.folder}/plaintext.txt`, 'utf8') - - // Successful file write fulfills the promise with `undefined` - expect(withOptions).toBe(undefined) - expect(withOptionsFiles).toContain('plaintext.txt') - expect(withOptionsFileContents).toBe('test') - - /** - * `plaintext` as a directory path - */ - const withDirPath = await writePlaintextFile('test', { - build: { - current: { - path: { - name: 'plaintext' - } - } - }, - plaintext: `${ctx.folder}/txt` - }) - - const withDirPathFiles = await readdir(`${ctx.folder}/txt`) - const withDirPathFileContents = await readFile(`${ctx.folder}/txt/plaintext.txt`, 'utf8') - - // Successful file write fulfills the promise with `undefined` - expect(withDirPath).toBe(undefined) - expect(withDirPathFiles).toContain('plaintext.txt') - expect(withDirPathFileContents).toBe('test') - - const withPermalink = await writePlaintextFile('with permalink', { - plaintext: true , - permalink: `${ctx.folder}/plaintext2.html` - }) - - const withPermalinkFiles = await readdir(ctx.folder) - const withPermalinkFileContents = await readFile(`${ctx.folder}/plaintext2.txt`, 'utf8') - - // Successful file write fulfills the promise with `undefined` - expect(withPermalink).toBe(undefined) - expect(withPermalinkFiles).toContain('plaintext2.txt') - expect(withPermalinkFileContents).toBe('with permalink') - }) - - test('Handles custom <plaintext> tags', async () => { - const html = ` - <p>This should exist in the returned HTML.</p> - <plaintext>This should be removed from the returned HTML</plaintext> - <not-plaintext> - <p>This should also exist in the returned HTML.</p> - </not-plaintext> - ` - - const expected = ` - <p>This should exist in the returned HTML.</p> - <p>This should also exist in the returned HTML.</p> - ` - - const result = await handlePlaintextTags(html) - - expect(minify(result)).toBe(minify(expected)) - }) - - test('Returns original input if it is empty', async () => { - expect(await handlePlaintextTags('')).toBe('') - }) -}) diff --git a/test/postcss.test.js b/test/postcss.test.js deleted file mode 100644 index 2ac243ae..00000000 --- a/test/postcss.test.js +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { process as posthtml } from '../src/posthtml/index.js' - -const cleanString = (str) => str.replace(/\s+/g, ' ').trim() - -describe.concurrent('PostCSS', () => { - test('resolveProps', async () => { - // Default: resolves CSS variables - posthtml(` - <style> - :root { - --color: red; - } - .foo { - color: var(--color); - } - </style> - <p class="foo">test</p> - `).then(({ html }) => { - expect(cleanString(html)).toBe(`<style> .foo { color: red; } </style> <p class="foo">test</p>`) - }) - - // Passing options - posthtml(` - <style> - .foo { - font-weight: var(--font-weight); - } - </style> - <p class="foo">test</p> - `, { - css: { - resolveProps: { - variables: { - '--font-weight': 'bold', - } - }, - } - }).then(({ html }) => { - expect(cleanString(html)).toBe(`<style>.foo { font-weight: bold; } </style> <p class="foo">test</p>`) - }) - - // Disabling `resolveProps` - posthtml(` - <style> - :root { - --color: red; - } - .foo { - color: var(--color); - } - </style> - <p class="foo">test</p> - `, { - css: { - resolveProps: false, - } - }).then(({ html }) => { - expect(cleanString(html)).toBe(`<style> :root { --color: red; } .foo { color: var(--color); } </style> <p class="foo">test</p>`) - }) - }) - - test('resolveCalc', async () => { - const html = ` - <style> - .foo { - width: calc(16px * 1.5569); - } - </style> - ` - - posthtml(html) - .then(({ html }) => { - expect(cleanString(html)).toBe('<style> .foo { width: 24.91px; } </style>') - }) - - posthtml(html, { - css: { - resolveCalc: { - precision: 1, - }, - } - }).then(({ html }) => { - expect(cleanString(html)).toBe('<style> .foo { width: 24.9px; } </style>') - }) - }) - - test('functional color notation', async () => { - const html = ` - <style> - .bg-black/80 { - background-color: rgb(0 0 1 / 0.8); - } - .text-white/20 { - color: rgb(255 255 254 / 0.2); - } - </style> - ` - - posthtml(html) - .then(({ html }) => { - expect(cleanString(html)) - .toBe( - cleanString(` - <style> - .bg-black/80 { background-color: rgba(0, 0, 1, 0.8); } - .text-white/20 { color: rgba(255, 255, 254, 0.2); } - </style>` - ) - ) - }) - }) - - test('css.media', async () => { - const html = ` - <style> - @tailwind components; - @tailwind utilities; - - .custom { - @apply sm:w-[100px]; - } - </style> - <div class="custom sm:flex hover:invisible"></div> - ` - - /** - * When using `@apply` and the source content has pseudos like `hover:`, - * the utilities generated with `@apply` will be separated in their own - * media query blocks. - * - * This does not happen if the source content does not use things like `hover:` 🤷‍♂️ - */ - posthtml(html, { - css: { - tailwind: { - content: [{ raw: html }], - theme: { - screens: { - sm: { max: '600px' }, - xs: { max: '430px' }, - }, - }, - } - } - }) - .then(({ html }) => { - expect(cleanString(html)) - .toBe( - cleanString(` - <style> - @media (max-width: 600px) { - .custom { - width: 100px - } - } - .hover\\:invisible:hover { - visibility: hidden - } - @media (max-width: 600px) { - .sm\\:flex { - display: flex - } - .sm\\:w-\\[100px\\] { - width: 100px - } - } - </style> - <div class="custom sm:flex hover:invisible"></div>` - ) - ) - }) - - // plugin enabled - posthtml(html, { - css: { - media: { - merge: true, - }, - tailwind: { - content: [{ raw: html }], - theme: { - screens: { - sm: { max: '600px' }, - xs: { max: '430px' }, - }, - }, - } - } - }) - .then(({ html }) => { - expect(cleanString(html)) - .toBe( - cleanString(` - <style> - .hover\\:invisible:hover { - visibility: hidden - } - @media (max-width: 600px) { - .custom { - width: 100px - } - .sm\\:flex { - display: flex - } - .sm\\:w-\\[100px\\] { - width: 100px - } - } - </style> - <div class="custom sm:flex hover:invisible"></div>` - ) - ) - }) - }) -}) diff --git a/test/posthtml.test.js b/test/posthtml.test.js deleted file mode 100644 index 4ab43e76..00000000 --- a/test/posthtml.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { process as posthtml } from '../src/posthtml/index.js' - -const cleanString = (str) => str.replace(/\s+/g, ' ').trim() - -describe.concurrent('PostHTML', () => { - test('Throws on PostHTML processing error', async () => { - await expect(posthtml('test', { - posthtml: { - plugins: [ - () => { throw new Error('TestError') } - ] - } - })).rejects.toThrow('TestError') - }) - - test('Works with default PostHTML options', async () => { - const html = ` - <p> - <?php echo $foo; ?> - </p> - ` - - const { html: result } = await posthtml(html) - - expect(cleanString(result)).toBe('<p> <?php echo $foo; ?> </p>') - }) -}) diff --git a/test/render.test.js b/test/render.test.js deleted file mode 100644 index 0f9fa858..00000000 --- a/test/render.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { render } from '../src/generators/render.js' - -const cleanString = (str) => str.replace(/\s+/g, ' ').trim() - -describe.concurrent('Render', () => { - test('Throws if first argument is not a string', async () => { - const html = true - - await expect(render(html)).rejects.toThrow('first argument must be a string') - }) - - test('Throws if first argument is an empty string', async () => { - const html = '' - - await expect(render(html)).rejects.toThrow('received empty string') - }) - - test('Uses data from config if available', async () => { - const source = '<div class="inline">{{ page.mail }}</div>' - - const { html } = await render(source, { - mail: 'puzzle' - }) - - expect(html).toBe('<div class="inline">puzzle</div>') - }) - - test('Runs the `beforeRender` event', async () => { - const { html } = await render('<div class="inline">{{ page.foo }}</div>', { - beforeRender({ config, matter }) { - config.foo = 'bar' - - expect(config).toBeInstanceOf(Object) - expect(matter).toBeInstanceOf(Object) - } - }) - - expect(html).toBe('<div class="inline">bar</div>') - }) - - test('Runs the `afterRender` event', async () => { - const { html } = await render('<div class="inline">foo</div>', { - afterRender({ config, matter }) { - config.replaceStrings = { - foo: 'bar' - } - - expect(config).toBeInstanceOf(Object) - expect(matter).toBeInstanceOf(Object) - } - }) - - expect(html).toBe('<div class="inline">bar</div>') - }) - - test('Runs the `afterTransformers` event', async () => { - const { html: withHtmlReturned } = await render('<div class="inline">foo</div>', { - replaceStrings: { - foo: 'bar' - }, - afterTransformers({ html, matter, config }) { - expect(config).toBeInstanceOf(Object) - expect(matter).toBeInstanceOf(Object) - - return html.replace('bar', 'baz') - } - }) - - const { html: nothingReturned } = await render('<div class="inline">foo</div>', { - replaceStrings: { - foo: 'bar' - }, - afterTransformers({ html }) { - html.replace('bar', 'baz') - } - }) - - expect(withHtmlReturned).toBe('<div class="inline">baz</div>') - expect(nothingReturned).toBe('<div class="inline">bar</div>') - }) - - test('Uses env-based attributes', async () => { - const source = '<div title="local" title-production="{{ page.env }}"></div>' - - const { html: inDev } = await render(source) - - const { html: inProduction } = await render(source, { - env: 'production' - }) - - expect(inDev).toBe('<div title="local" title-production="{{ page.env }}"></div>') - expect(inProduction).toBe('<div title="production"></div>') - }) - - test('uses expressions options', async () => { - const { html } = await render(` - <script locals> - module.exports = { name: 'John' } - </script> - <h1>Hello {{ name }}</h1> - `, - { - expressions: { - removeScriptLocals: true, - } - } - ) - - expect(cleanString(html)).toBe('<h1>Hello John</h1>') - }) -}) diff --git a/test/server.test.js b/test/server.test.js deleted file mode 100644 index dcd027a1..00000000 --- a/test/server.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import request from 'supertest' -import serve from '../src/commands/serve.js' -import { describe, expect, test, beforeAll } from 'vitest' - -const init = async () => { - await serve({ - build: { - content: ['test/fixtures/build/**/*.html'], - static: { - source: ['test/fixtures/build/**/*.png'], - } - }, - components: { - folders: ['test/stubs/templates'] - }, - beforeCreate(config) { - config.hello = 'world' - }, - server: { - maxRetries: 1 - } - }) -} - -beforeAll(async () => { - await init() -}) - -describe.concurrent('Server', () => { - test('Starts server', async () => { - const res = await request('http://localhost:3000').get('/') - - expect(res.status).toBe(200) - expect(res.text).toContain('<title>Maizzle | Templates</title>') - expect(res.text).toContain('insignia') // checks for insignia SVG on index page - - // Test if the server serves static files from `build.static.source` - const imageRes = await request('http://localhost:3000').get('/image.png') - expect(imageRes.status).toBe(200) - }) - - test('Retries for a different port', async () => { - await init() - - const res = await request('http://localhost:3001').get('/') - - expect(res.status).toBe(200) - }) - - test('Throws if it cannot render a template', async () => { - const res = await request('http://localhost:3000').get('/error.html') - - expect(res.status).toBe(500) - }) - - test('Serves scripts', async () => { - const hmr = await request('http://localhost:3000').get('/hmr.js') - - expect(hmr.status).toBe(200) - }) - - test('Lists grouped templates', async () => { - const res = await request('http://localhost:3000').get('/') - - expect(res.status).toBe(200) - expect(res.text).toContain('<strong>test/fixtures/build</strong>') - }) - - test('Renders template', async () => { - const res = await request('http://localhost:3000').get('/expandLinkTag.html') - const res2 = await request('http://localhost:3000').get('/with spaces.html') - - expect(res.status).toBe(200) - expect(res.text).toContain('<style>.hidden {') - - expect(res2.status).toBe(200) - expect(res2.text).toContain('works') - }) - - test('404 page', async () => { - const res = await request('http://localhost:3000').get('/error.php') - - expect(res.status).toBe(404) - expect(res.text).toContain('Template Not Found') - }) -}) diff --git a/test/stubs/components/list.html b/test/stubs/components/list.html deleted file mode 100644 index 0c0edbdb..00000000 --- a/test/stubs/components/list.html +++ /dev/null @@ -1,3 +0,0 @@ -<h1>Results</h1> - -<yield /> diff --git a/test/stubs/config/config.js b/test/stubs/config/config.js deleted file mode 100644 index 92b33d29..00000000 --- a/test/stubs/config/config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - foo: 'bar' -} diff --git a/test/stubs/config/maizzle.config.local.js b/test/stubs/config/maizzle.config.local.js deleted file mode 100644 index 220b0523..00000000 --- a/test/stubs/config/maizzle.config.local.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - local: true, - build: { - content: ['local'], - } -} diff --git a/test/stubs/config/maizzle.config.maizzle-ci.js b/test/stubs/config/maizzle.config.maizzle-ci.js deleted file mode 100644 index 6f4fe6d4..00000000 --- a/test/stubs/config/maizzle.config.maizzle-ci.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - foo: 'bar', - build: { - content: ['maizzle-ci'], - } -} diff --git a/test/stubs/data.json b/test/stubs/data.json deleted file mode 100644 index 882f568c..00000000 --- a/test/stubs/data.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "id": 1, - "name": "Leanne Graham" - }, - { - "id": 2, - "name": "Ervin Howell" - } -] diff --git a/test/stubs/static/plain.txt b/test/stubs/static/plain.txt deleted file mode 100644 index 8e27be7d..00000000 --- a/test/stubs/static/plain.txt +++ /dev/null @@ -1 +0,0 @@ -text diff --git a/test/stubs/style.css b/test/stubs/style.css deleted file mode 100644 index f4d336fd..00000000 --- a/test/stubs/style.css +++ /dev/null @@ -1,3 +0,0 @@ -.hidden { - display: none; -} diff --git a/test/tags.test.js b/test/tags.test.js deleted file mode 100644 index 56855fa5..00000000 --- a/test/tags.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { render } from '../src/generators/render.js' -import { run as useTransformers } from '../src/transformers/index.js' - -const cleanString = (str) => str.replace(/\s+/g, ' ').trim() - -describe.concurrent('Tags', () => { - test('fetch component', async () => { - const { html } = await render(` - <x-list> - <fetch url="test/stubs/data.json"> - {{ undefinedVariable }} - <each loop="user in response">{{ user.name + (loop.last ? '' : ', ') }}</each> - @{{ ignored }} - </fetch> - </x-list> - `, { - components: { - folders: ['test/stubs/components'], - } - }) - - expect(cleanString(html)).toBe('<h1>Results</h1> {{ undefinedVariable }} Leanne Graham, Ervin Howell {{ ignored }}') - }) - - test('<template> tags', async () => { - const { html } = await useTransformers(` - <template uppercase>test</template> - <template preserve>test</template> - `) - - expect(cleanString(html)).toBe('TEST <template>test</template>') - }) - - test('<env> tags', async () => { - const source = ` - <env:local>{{ page.env }}</env:local> - <env:production>{{ page.env }}</env:production> - <fake:production>ignore</fake:production> - <env:>test</env:> - ` - - const { html: inDev } = await render(source) - - const { html: inProduction } = await render(source, { - env: 'production' - }) - - // we don't pass `env` to the page object so it remains as-is - expect(cleanString(inDev)).toBe('{{ page.env }} <fake:production>ignore</fake:production>') - expect(inProduction.trim()).toBe('production\n <fake:production>ignore</fake:production>') - }) - - test('<not-env> tags', async () => { - const source = ` - <not-env:local>{{ page.env }}</not-env:local> - <not-env:production>{{ page.env }}</not-env:production> - <fake:production>ignore</fake:production> - <not-env:>test</not-env:> - ` - - const { html: inDev } = await render(source) - - const { html: inProduction } = await render(source, { - env: 'production' - }) - - // we don't pass `env` to the page object so it remains as-is - expect(cleanString(inDev)).toBe('{{ page.env }} <fake:production>ignore</fake:production>') - expect(cleanString(inProduction)).toBe('production <fake:production>ignore</fake:production>') - }) -}) diff --git a/test/transformers/addAttributes.test.js b/test/transformers/addAttributes.test.js deleted file mode 100644 index 716123b8..00000000 --- a/test/transformers/addAttributes.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from 'vitest' -import { addAttributes } from '../../src/index.js' - -test('Add attributes', async () => { - const result = await addAttributes('<div></div>', { - div: { - role: 'article' - } - }) - - expect(result).toBe('<div role="article"></div>') -}) diff --git a/test/transformers/attributeToStyle.test.js b/test/transformers/attributeToStyle.test.js deleted file mode 100644 index 0f2c094c..00000000 --- a/test/transformers/attributeToStyle.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { attributeToStyle } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Attribute to style', () => { - test('Expands attributes to inline CSS', async () => { - const html = `<table align="left" width="100%" height="600" bgcolor="#FFFFFF" background="https://example.com/image.jpg"> - <tr> - <td align="center" valign="top"></td> - </tr> - </table>` - - expect( - await attributeToStyle(html, ['width', 'height', 'bgcolor', 'background', 'align', 'valign']) - ).toBe(`<table align="left" width="100%" height="600" bgcolor="#FFFFFF" background="https://example.com/image.jpg" style="float: left; width: 100%; height: 600px; background-color: #FFFFFF; background-image: url('https://example.com/image.jpg')"> - <tr> - <td align="center" valign="top" style="text-align: center; vertical-align: top"></td> - </tr> - </table>`) - - expect( - await useTransformers(html, { - attributes: { add: false }, - css: { inline: { attributeToStyle: ['width', 'height'] } }, - }).then(({ html }) => html) - ).toBe(`<table align="left" width="100%" height="600" bgcolor="#ffffff" background="https://example.com/image.jpg" style="width: 100%; height: 600px"> - <tr> - <td align="center" valign="top"></td> - </tr> - </table>`) - }) - - test('Expands align="center" to style="margin-left: auto; margin-right: auto"', async () => { - const html = `<table align="center"> - <tr> - <td></td> - </tr> - </table>` - - expect(await attributeToStyle(html, ['align'])).toBe(`<table align="center" style="margin-left: auto; margin-right: auto"> - <tr> - <td></td> - </tr> - </table>`) - - // Does not expand anything if options are empty or false - expect(await attributeToStyle(html, [])).toBe(html) - expect(await attributeToStyle(html, false)).toBe(html) - }) - - test('Defaults to px for width values without units', async () => { - expect( - await attributeToStyle('<td width="100" style="color: #000;"></td>', ['width']) - ).toBe('<td width="100" style="color: #000; width: 100px"></td>') - }) -}) diff --git a/test/transformers/baseURL.test.js b/test/transformers/baseURL.test.js deleted file mode 100644 index d3151199..00000000 --- a/test/transformers/baseURL.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { readFile } from 'node:fs/promises' -import { addBaseUrl } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -const fixture = await readFile(new URL('../fixtures/base-url.html', import.meta.url), 'utf8') -const expected = await readFile(new URL('../expected/base-url.html', import.meta.url), 'utf8') - -describe.concurrent('Base URL', () => { - test('Ignores invalid option', async () => { - expect( - await addBaseUrl(fixture, true) - ).toBe(fixture) - }) - - test('Works with other transformers', async () => { - expect( - await useTransformers(fixture, { - baseURL: 'https://example.com/', - // Expected string would be too long, so we disable auto-adding of attributes - attributes: { - add: { - table: false, - img: false - } - } - }).then(({ html }) => html) - ).toBe(expected) - }) - - test('Applies base URL (string option)', async () => { - expect( - await addBaseUrl(fixture, 'https://example.com/') - ).toBe(expected) - }) - - test('Applies base URL (object option)', async () => { - expect( - await addBaseUrl(fixture, { - url: 'https://example.com/', - allTags: true, - }) - ).toBe(expected) - }) -}) diff --git a/test/transformers/core.test.js b/test/transformers/core.test.js deleted file mode 100644 index d39c5fb1..00000000 --- a/test/transformers/core.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Core transformers', () => { - test('Removes <plaintext> tag in local dev', async () => { - const { html } = await useTransformers( - 'keep<plaintext>remove</plaintext>', - { _dev: true } - ) - - expect(html).toBe('keep') - }) - - test('Uses custom attributes to prevent inlining CSS from <style> tags', async () => { - const { html } = await useTransformers( - ` - <style no-inline>keep</style> - <style embed>this too</style> - <style no-inline data-embed>also this</style> - `, - { _dev: true } - ) - - expect(html).toContain('<style data-embed>keep</style>') - expect(html).toContain('<style data-embed>this too</style>') - expect(html).toContain('<style data-embed>also this</style>') - }) -}) diff --git a/test/transformers/filters.test.js b/test/transformers/filters.test.js deleted file mode 100644 index 730d15cf..00000000 --- a/test/transformers/filters.test.js +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from 'vitest' -import { readFile } from 'node:fs/promises' -import { filters } from '../../src/index.js' - -const fixture = await readFile(new URL('../fixtures/filters.html', import.meta.url), 'utf8') -const expected = await readFile(new URL('../expected/filters.html', import.meta.url), 'utf8') - -test('Filters', async () => { - const customFilters = { - 'underscore-case': string => string.split('').join('_'), - } - - expect( - await filters(fixture, customFilters) - ).toBe(expected) -}) diff --git a/test/transformers/inlineCSS.test.js b/test/transformers/inlineCSS.test.js deleted file mode 100644 index bc80ca17..00000000 --- a/test/transformers/inlineCSS.test.js +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { inlineCSS } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -const css = ` - .w-1 {width: 4px} - .h-1 {height: 4px} - .foo {color: red} - .bar {cursor: pointer} - .hover\\:foo:hover {color: blue} - .bg-custom {background-image: url('https://picsum.photos/600/400') !important} - @media (max-width: 600px) { - .sm\\:text-center {text-align: center} - } - u + .body .gmail\\:hidden { - display: none; - } -` - -const html = ` - <style>${css}</style> - <p class="bar">test</p> - <table class="w-1 h-1 sm:text-center bg-custom"> - <tr> - <td class="foo bar h-1 gmail:hidden">test</td> - </tr> - </table>` - -describe.concurrent('Inline CSS', () => { - test('Invalid input', async () => { - expect(await inlineCSS()).toBe('') - expect(await inlineCSS('')).toBe('') - }) - - test('Sanity test', async () => { - const result = await inlineCSS(html, { - removeInlinedSelectors: true, - codeBlocks: { - RB: { - start: '<%', - end: '%>', - }, - }, - }) - - expect(cleanString(result)).toBe(cleanString(` - <style>.hover\\:foo:hover { color: blue; } - @media (max-width: 600px) { - .sm\\:text-center { text-align: center; } - } - u + .body .gmail\\:hidden { display: none; }</style> - <p class="bar" style="cursor: pointer;">test</p> - <table class="w-1 h-1 sm:text-center bg-custom" style="width: 4px; height: 4px; background-image: url('https://picsum.photos/600/400')"> - <tr> - <td class="foo bar h-1 gmail:hidden" style="height: 4px; color: red; cursor: pointer;">test</td> - </tr> - </table>`)) - }) - - test('Preserves user-defined selectors', async () => { - const result = await inlineCSS(` - <style> - .bar {margin: 0} - .variant\\:foo {color: blue} - </style> - <p class="bar">test</p> - <span class="variant:foo"></span>`, - { - removeInlinedSelectors: true, - safelist: ['foo', '.bar'], - }) - - expect(cleanString(result)).toBe(cleanString(` - <style>.bar { margin: 0; } .variant\\:foo { color: blue; }</style> - <p class="bar" style="margin: 0">test</p> - <span class="variant:foo" style="color: blue"></span>` - )) - }) - - test('Preserves inlined selectors', async () => { - const result = await inlineCSS(html, { - removeInlinedSelectors: false, - }) - - expect(cleanString(result)).toBe(cleanString(` - <style> - .w-1 {width: 4px} - .h-1 {height: 4px} - .foo {color: red} - .bar {cursor: pointer} - .hover\\:foo:hover {color: blue} - .bg-custom {background-image: url('https://picsum.photos/600/400') !important} - @media (max-width: 600px) { - .sm\\:text-center {text-align: center} - } - u + .body .gmail\\:hidden { display: none; } - </style> - <p class="bar" style="cursor: pointer">test</p> - <table class="w-1 h-1 sm:text-center bg-custom" style="width: 4px; height: 4px; background-image: url('https://picsum.photos/600/400')"> - <tr> - <td class="foo bar h-1 gmail:hidden" style="height: 4px; color: red; cursor: pointer">test</td> - </tr> - </table>`)) - }) - - test('Works with `customCSS` option', async () => { - expect( - cleanString( - await inlineCSS( - '<p class="bar" style="color: red"></p>', - { - customCSS: '.bar {display: flex;}' - } - ) - ) - ).toBe('<p class="bar" style="display: flex; color: red;"></p>') - }) - - test('Works with `preferUnitlessValues` option disabled', async () => { - const result = cleanString( - await inlineCSS(` - <style>.m-0 {margin: 0px}</style> - <p class="m-0">test</p>`, - { - preferUnitlessValues: false, // default is true - } - ) - ) - - expect(result).toBe(`<p class="m-0" style="margin: 0px;">test</p>`) - }) - - test('`preferUnitlessValues` skips invalid inline CSS', async () => { - const result = cleanString( - await inlineCSS(` - <style>.m-0 {margin: 0px}</style> - <p class="m-0" style="color: #{{ $foo->theme }}">test</p>` - ) - ) - - expect(result).toBe(`<p class="m-0" style="margin: 0px; color: #{{ $foo->theme }};">test</p>`) - }) - - test('Works with `excludedProperties` option', async () => { - expect( - cleanString( - await inlineCSS(` - <style>.bar {cursor: pointer; margin: 0}</style> - <p class="bar">test</p>`, { - removeInlinedSelectors: true, - excludedProperties: ['margin'] - }) - ) - ).toBe(`<p class="bar" style="cursor: pointer;">test</p>`) - }) - - test('Uses `applyWidthAttributes` and `applyHeightAttributes` by default', async () => { - expect( - cleanString( - await useTransformers('<style>.size-10px {width: 10px; height: 10px}</style><img class="size-10px">', { - css: { inline: { removeInlinedSelectors: true } }, - }).then(({ html }) => html) - ) - ).toBe('<img class="size-10px" style="width: 10px; height: 10px;" width="10" height="10" alt>') - }) - - test('Does not inline <style> tags marked as "embedded"', async () => { - expect( - cleanString( - await inlineCSS(` - <style>.foo { font-size: 16px; }</style> - <style embed>.foo { color: red; }</style> - <style data-embed>.foo { display: flex; }</style> - <p class="foo">test</p>`) - ) - ).toBe( - cleanString(` - <style>.foo { color: red; }</style> - <style>.foo { display: flex; }</style> - <p class="foo" style="font-size: 16px;">test</p>`) - ) - }) - - test('useTransformers context', async () => { - expect( - cleanString( - await useTransformers(html, { - attributes: { add: false }, - css: { inline: { removeInlinedSelectors: true } }, - }).then(({ html }) => html) - ) - ).toBe(cleanString(` - <style>.hover-foo:hover { color: blue; } - @media (max-width: 600px) { - .sm-text-center { text-align: center; } - } - u + .body .gmail-hidden { display: none; }</style> - <p class="bar" style="cursor: pointer;">test</p> - <table class="w-1 h-1 sm-text-center bg-custom" style="width: 4px; height: 4px; background-image: url('https://picsum.photos/600/400')"> - <tr> - <td class="foo bar h-1 gmail-hidden" style="height: 4px; color: red; cursor: pointer;">test</td> - </tr> - </table>`)) - }) - - test('Works with base64-encoded CSS values', async () => { - expect( - cleanString( - await inlineCSS(` - <style> - .base64 { - background-image: url("data:image/gif;base64,R0lGODdhAQABAPAAAP8AAAAAACwAAAAAAQABAAACAkQBADs="); - } - </style> - <p class="base64">test</p>`, - ) - ) - ).toBe(cleanString(` - <p class="base64" style="background-image: url('data:image/gif;base64,R0lGODdhAQABAPAAAP8AAAAAACwAAAAAAQABAAACAkQBADs=');">test</p>`)) - }) - - test('Works with pseudo-classes', async () => { - expect( - cleanString( - await inlineCSS(` - <style> - li::marker {color: blue} - - ul > li { - color: red; - } - </style> - <ul> - <li>test</li> - </ul>`, - ) - ) - ).toBe(cleanString(` - <style>li::marker { color: blue; }</style> - <ul> - <li style="color: red;">test</li> - </ul>`)) - }) -}) diff --git a/test/transformers/markdown.test.js b/test/transformers/markdown.test.js deleted file mode 100644 index 301298e1..00000000 --- a/test/transformers/markdown.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { markdown } from '../../src/index.js' - -describe.concurrent('Markdown', () => { - test('Ignores empty strings', async () => { - expect(await markdown('')).toBe('') - }) - - test('Works with options', async () => { - expect( - await markdown('maizzle.com', { markdownit: { linkify: true } }) - ).toBe('<p><a href="http://maizzle.com">maizzle.com</a></p>\n') - }) - - test('Works with markdown content', async () => { - expect(await markdown('# Foo\n_foo_')) - .toBe('<h1>Foo</h1>\n<p><em>foo</em></p>\n') - }) - - test('Works with <md> tag', async () => { - expect( - await markdown('<md tag="section"># Foo\n_foo_</md>', { manual: true }) - ).toBe('<section>\n<h1>Foo</h1>\n<p><em>foo</em></p>\n</section>') - }) -}) diff --git a/test/transformers/minify.test.js b/test/transformers/minify.test.js deleted file mode 100644 index ee16aee7..00000000 --- a/test/transformers/minify.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { minify } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Minify', () => { - const html = '<div>\n\n<p>\n\n test</p></div>' - - test('Sanity test', async () => { - expect(await minify(html)).toBe('<div><p> test</p></div>') - }) - - test('Works with options', async () => { - expect(await minify(html, { lineLengthLimit: 4 })).toBe('<div>\n<p>\ntest\n</p>\n</div>') - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { minify: true }).then(({ html }) => html) - ).toBe('<div><p> test</p></div>') - }) -}) diff --git a/test/transformers/msoTags.test.js b/test/transformers/msoTags.test.js deleted file mode 100644 index 8947a7b7..00000000 --- a/test/transformers/msoTags.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { useMso } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' - -describe.concurrent('MSO tags', () => { - test('Sanity test', async () => { - expect( - cleanString( - await useMso(` - <outlook>show in outlook</outlook> - <not-outlook>hide from outlook</not-outlook> - `) - ) - ).toBe(cleanString(` - <!--[if mso]>show in outlook<![endif]--> - <!--[if !mso]><!-->hide from outlook<!--<![endif]--> - `)) - }) -}) diff --git a/test/transformers/preferAttributeSizes.test.js b/test/transformers/preferAttributeSizes.test.js deleted file mode 100644 index 658fe142..00000000 --- a/test/transformers/preferAttributeSizes.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { useAttributeSizes } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Prefer attribute sizes', () => { - const html = '<img src="image.jpg" style="width: 100px; height: auto">' - - test('Is disabled by default', async () => { - expect(await useAttributeSizes(html)).toBe(html) - }) - - test('Basic functionality', async () => { - expect(await useAttributeSizes(html, { - width: ['table'], - height: ['table'] - })).toBe(html) - }) - - test('Handles `img` attribute values correctly', async () => { - expect(await useAttributeSizes(html, { - width: ['img'], - height: ['img'] - })).toBe('<img src="image.jpg" width="100" height="auto">') - }) - - test('useTransformers context', async () => { - expect( - await useTransformers( - html, - { - css: { - inline: { - useAttributeSizes: true, - } - } - }) - .then(({ html }) => html) - ).toBe('<img src="image.jpg" width="100" height="auto" alt>') - }) -}) diff --git a/test/transformers/prettify.test.js b/test/transformers/prettify.test.js deleted file mode 100644 index 02dfb726..00000000 --- a/test/transformers/prettify.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { prettify } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Prettify', () => { - const html = '<div><p>test</p></div>' - - test('Basic functionality', async () => { - expect(await prettify(html)).toBe('<div>\n <p>test</p>\n</div>') - }) - - test('Works with options', async () => { - expect(await prettify(html, { indent_size: 4 })).toBe('<div>\n <p>test</p>\n</div>') - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { prettify: true }).then(({ html }) => html) - ).toBe('<div>\n <p>test</p>\n</div>') - }) -}) diff --git a/test/transformers/purgeCSS.test.js b/test/transformers/purgeCSS.test.js deleted file mode 100644 index 832eea50..00000000 --- a/test/transformers/purgeCSS.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { purgeCSS } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Purge CSS', () => { - const html = ` - <!DOCTYPE html> - <html> - <head> - <style> - @media (screen) { - .should-remove {color: yellow} - } - .foo {color: red} - .foo:hover {color: blue} - .should-keep {color: blue} - .should-remove {color: white} - </style> - </head> - <body> - <div class="foo {{ test }}">test div with some text</div> - </body> - </html>` - - const options = { - backend: [ - { heads: '{{', tails: '}}' } - ], - safelist: ['*keep*'] - } - - const expected = `<!DOCTYPE html> - <html> - <head> - <style> - .foo {color: red} - .foo:hover {color: blue} - .should-keep {color: blue} - </style> - </head> - <body> - <div class="foo {{ test }}">test div with some text</div> - </body> - </html>` - - test('Sanity test', async () => { - expect( - cleanString( - await purgeCSS(html, options) - ) - ).toBe(cleanString(expected)) - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { css: { purge: options } }).then(({ html }) => html) - ).toBe(expected) - }) -}) diff --git a/test/transformers/removeAttributes.test.js b/test/transformers/removeAttributes.test.js deleted file mode 100644 index ccfdfda9..00000000 --- a/test/transformers/removeAttributes.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { removeAttributes } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Remove attributes', () => { - const html = '<div style="" remove keep role="article" delete-me="with-regex">test</div>' - - const options = [ - { name: 'role', value: 'article' }, - 'remove', - { name: 'delete-me', value: /^with/ } - ] - - test('Sanity test', async () => { - expect(await removeAttributes(html, options)).toBe('<div keep>test</div>') - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { attributes: { remove: options } }).then(({ html }) => html) - ).toBe('<div keep>test</div>') - }) -}) diff --git a/test/transformers/replaceStrings.test.js b/test/transformers/replaceStrings.test.js deleted file mode 100644 index 551e6441..00000000 --- a/test/transformers/replaceStrings.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { replaceStrings } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Replace strings', () => { - test('Does nothing if no options were passed', async () => { - expect(await replaceStrings('initial text')).toBe('initial text') - expect(await replaceStrings('initial text', {})).toBe('initial text') - }) - - test('Skips targets it cannot find', async () => { - expect(await replaceStrings('initial text', { '/not/': 'found' })).toBe('initial text') - }) - - test('Replaces strings', async () => { - expect(await replaceStrings('initial text', { 'initial': 'updated' })).toBe('updated text') - }) - - test('Replaces capturing groups', async () => { - expect(await replaceStrings('initial [text]', { '(initial) \\[(text)\\]': '($2) updated' })).toBe('(text) updated') - expect(await replaceStrings('«initial» «text»', { '«(.*?)»' : '«&nbsp;$1&nbsp;»' })).toBe('«&nbsp;initial&nbsp;» «&nbsp;text&nbsp;»') - }) - - test('useTransformers context', async () => { - expect( - await useTransformers('initial text', { replaceStrings: { 'initial': 'updated' } }).then(({ html }) => html) - ).toBe('updated text') - }) -}) diff --git a/test/transformers/safeClassNames.test.js b/test/transformers/safeClassNames.test.js deleted file mode 100644 index dc093d4a..00000000 --- a/test/transformers/safeClassNames.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { safeClassNames } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Safe class names', () => { - const html = ` - <style> - .sm\\:text-left { - text-align: left; - } - .w-1\\.5 { - width: 1.5rem; - } - </style> - <div class="sm:text-left w-1.5">foo</div> - ` - - const expected = ` - <style> - .sm-text-left { - text-align: left; - } - .w-1_dot_5 { - width: 1.5rem; - } - </style> - <div class="sm-text-left w-1_dot_5">foo</div> - ` - - test('Works with options (object)', async () => { - expect( - cleanString( - await safeClassNames(html, { - replacements: { - '.': '_dot_', - } - }) - ) - ).toBe(cleanString(expected)) - }) - - test('Works with options (boolean)', async () => { - expect( - cleanString( - await safeClassNames(html, true) - ) - ).toBe(cleanString(` - <style> - .sm-text-left { - text-align: left; - } - .w-1_5 { - width: 1.5rem; - } - </style> - <div class="sm-text-left w-1_5">foo</div> - `)) - }) - - test('useTransformers context', async () => { - expect( - cleanString( - await useTransformers(html, { - css: { - safe: { - replacements: { - '.': '_dot_', - } - } - } - }).then(({ html }) => html) - ) - ).toBe(cleanString(` - <style> - .sm-text-left { - text-align: left; - } - .w-1_dot_5 { - width: 1.5rem; - } - </style> - <div class="sm-text-left w-1_dot_5">foo</div> - `)) - }) -}) diff --git a/test/transformers/shorthandCSS.test.js b/test/transformers/shorthandCSS.test.js deleted file mode 100644 index 086e3ce2..00000000 --- a/test/transformers/shorthandCSS.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { shorthandCSS } from '../../src/index.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Shorthand CSS', () => { - const html = '<div style="margin-top: 0px; margin-right: 4px; margin-bottom: 0; margin-left: 0; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 2px;"></div>' - const expected = '<div style="margin: 0px 4px 0 0; padding: 0 0 0 2px;"></div>' - - test('Sanity test', async () => { - expect(await shorthandCSS(html)).toBe(expected) - - expect(await shorthandCSS(html, { - tags: ['div'] - })).toBe(expected) - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { css: { shorthand: true } }).then(({ html }) => html) - ).toBe(expected) - }) -}) diff --git a/test/transformers/sixHEX.test.js b/test/transformers/sixHEX.test.js deleted file mode 100644 index 42fb0a42..00000000 --- a/test/transformers/sixHEX.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { sixHEX } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('Six-digit HEX', () => { - const html = ` - <div bgcolor="#000" style="color: #fff; background-color: #000">This should not change: #ffc</div> - <font color="#fff">Text</font> - ` - - const expected = ` - <div bgcolor="#000000" style="color: #fff; background-color: #000">This should not change: #ffc</div> - <font color="#ffffff">Text</font> - ` - - test('Sanity test', async () => { - expect( - cleanString( - await sixHEX(html) - ) - ).toBe(cleanString(expected)) - }) - - test('useTransformers context', async () => { - expect( - await useTransformers(html, { css: { sixHex: true } }).then(({ html }) => html) - ).toBe(expected) - }) -}) diff --git a/test/transformers/urlParameters.test.js b/test/transformers/urlParameters.test.js deleted file mode 100644 index e0e6da8c..00000000 --- a/test/transformers/urlParameters.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { addURLParams } from '../../src/index.js' -import { cleanString } from '../../src/utils/string.js' -import { run as useTransformers } from '../../src/transformers/index.js' - -describe.concurrent('URL parameters', () => { - test('Sanity test', async () => { - expect( - cleanString( - await addURLParams(` - <a href="https://example.com">test</a> - <link href="https://foo.bar"> - `, - { - bar: 'baz', - qix: 'qux' - } - ) - ) - ).toBe(cleanString(` - <a href="https://example.com?bar=baz&qix=qux">test</a> - <link href="https://foo.bar"> - `)) - }) - - test('With options', async () => { - expect( - cleanString( - await addURLParams( - `<a href="example.com">test</a> - <link href="https://foo.bar">`, - { - _options: { - tags: ['a[href*="example"]', 'link'], - strict: false, - qs: { - encode: true - } - }, - foo: '@Bar@', - bar: 'baz' - } - ) - ) - ).toBe(cleanString(` - <a href="example.com?bar=baz&foo=%40Bar%40">test</a> - <link href="https://foo.bar?bar=baz&foo=%40Bar%40"> - `)) - }) - - test('useTransformers context', async () => { - const { html: result } = await useTransformers(` - <a href="https://example.com">test</a> - <link href="https://foo.bar"> - `, - { - urlParameters: { - bar: 'baz', - qix: 'qux' - } - } - ) - - expect( - cleanString(result) - ).toBe(cleanString(` - <a href="https://example.com?bar=baz&qix=qux">test</a> - <link href="https://foo.bar"> - `)) - }) -}) diff --git a/test/transformers/widowWords.test.js b/test/transformers/widowWords.test.js deleted file mode 100644 index 5164ea1f..00000000 --- a/test/transformers/widowWords.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { preventWidows } from '../../src/index.js' - -describe.concurrent('Widow words', () => { - test('Prevents widow words', async () => { - const result = await preventWidows('<p no-widows>one two</p>', { minWords: 2 }) - - expect(result).toBe('<p>one&nbsp;two</p>') - }) - - test('Ignores strings inside expressions', async () => { - const result = await preventWidows('<div no-widows>{{{ one two three }}}</div>', { - ignore: [ - { start: '{{{', end: '}}}' } - ] - }) - - expect(result).toBe('<div>{{{ one two three }}}</div>') - }) - - test('Applies only to tags with the `no-widows` attribute', async () => { - const result = await preventWidows('<p no-widows>one two three</p><p>4 5 6</p>', { withAttributes: true }) - - expect(result).toBe('<p>one two&nbsp;three</p><p>4 5 6</p>') - }) - - test('Ignores MSO comments', async () => { - expect(await preventWidows('<!--[if mso]>one two three<![endif]-->')).toBe('<!--[if mso]>one two three<![endif]-->') - }) -}) diff --git a/test/utils.test.js b/test/utils.test.js deleted file mode 100644 index ea55939d..00000000 --- a/test/utils.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import os from 'node:os' -import { describe, expect, test, vi, afterAll } from 'vitest' -import { - injectScript, - findCommonPrefix, - formatMs, - formatTime, - humanFileSize -} from '../src/utils/string.js' - -import { - getLocalIP, - getFileSize, - getColorizedFileSize, - parseFrontMatter -} from '../src/utils/node.js' - -describe.concurrent('String utils', () => { - test('Injects script at correct location', () => { - const script = '<script src="test.js"></script>' - - // Returns the HTML if no valid location is found - expect(injectScript('<p></p>', script)).toBe(script + '<p></p>') - - // Injects script before </head> - expect(injectScript('<html><head><title>Test</title></head><body></body></html>', script)) - .toBe('<html><head><title>Test</title><script src="test.js"></script></head><body></body></html>') - - // Injects script after </title> - expect(injectScript('<html><title>Test</title><body></body></html>', script)) - .toBe('<html><title>Test</title><script src="test.js"></script><body></body></html>') - - // Injects script before </body> - expect(injectScript('<html><body><p>foo</p></body></html>', script)) - .toBe('<html><body><p>foo</p><script src="test.js"></script></body></html>') - - // Injects script before </html> - expect(injectScript('<html><p>foo</p></html>', script)) - .toBe('<html><p>foo</p><script src="test.js"></script></html>') - - // Injects script after <!doctype html> - expect(injectScript('<!doctype html><p>foo</p>', script)) - .toBe('<!doctype html><script src="test.js"></script><p>foo</p>') - }) - - test('Finds common prefix', () => { - expect(findCommonPrefix(['foo', 'foobar', 'foobar'])).toBe('foo') - - expect(() => findCommonPrefix(true)).toThrow() - }) - - test('Formats milliseconds to time', () => { - expect(formatMs(100000)).toBe('00:01:40') - }) - - test('Formats milliseconds to human-readable time', () => { - expect(formatTime(100000)).toBe('1.67 min') - expect(formatTime(40000)).toBe('40.00 s') - expect(formatTime(900)).toBe('900 ms') - }) - - test('Formats bytes to human-readable size', () => { - expect(humanFileSize(100)).toBe('100 B') - expect(humanFileSize(1048576)).toBe('1.00 MB') - expect(humanFileSize(1024, true)).toBe('1.02 KB') - }) -}) - -describe.concurrent('Node utils', () => { - test('Gets local IP address', () => { - expect(getLocalIP()).toBeDefined() - expect(getLocalIP()).toContain('.') - }) - - test('should return a valid IPv4 address if found', () => { - // Mock data for a valid IPv4 address - const mockInterfaces = { - eth0: [ - { family: 'IPv4', internal: false, address: '192.168.1.100' }, - { family: 'IPv6', internal: false, address: 'fe80::1' } - ] - } - - const mockedInterface = vi.spyOn(os, 'networkInterfaces').mockReturnValue(mockInterfaces) - - afterAll(() => { - mockedInterface.mockReset() - }) - - expect(getLocalIP()).toBe('192.168.1.100') - }) - - test('`getLocalIP` returns default IP when no suitable IP is found', () => { - const mockInterfaces = { - eth0: [ - { family: 'IPv6', internal: false, address: 'fe80::1' } - ] - } - - const mockNetworkInterfaces = vi.spyOn(os, 'networkInterfaces').mockReturnValue(mockInterfaces) - - afterAll(() => { - mockNetworkInterfaces.mockReset() - }) - - expect(getLocalIP()).toBe('127.0.0.1') - }) - - test('Gets file size of a string', () => { - expect(getFileSize('foo')).toBe('3.00') - - // Colorized file size - expect(getColorizedFileSize('foo')).toBe('3.00 B') - expect(getColorizedFileSize('x'.repeat(49 * 1024))).toContain('49.00 KB') - expect(getColorizedFileSize('x'.repeat(101 * 1024))).toContain('101.00 KB') - expect(getColorizedFileSize('x'.repeat(102 * 1024))).toContain('102.00 KB') - }) - - test('Parses front matter', () => { - const result = parseFrontMatter('---\ntitle: Test\n---\n\n<p>Test</p>') - - expect(result.data.title).toBe('Test') - expect(result.content.trim()).toBe('<p>Test</p>') - }) -}) diff --git a/test/websockets.test.js b/test/websockets.test.js deleted file mode 100644 index a70a80f8..00000000 --- a/test/websockets.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, test } from 'vitest' -import { initWebSockets } from '../src/server/websockets.js' - -describe.concurrent('Websockets', () => { - test('Starts websockets server', async () => { - class MockWebSocket extends EventEmitter { - constructor() { - super() - this.clients = new Set() - } - - send(data) { - this.emit('message', data) - } - } - - const mockWSS = new MockWebSocket() - initWebSockets(mockWSS) - - mockWSS.emit('connection', mockWSS) - - expect(mockWSS.listenerCount('connection')).toBe(1) - expect(mockWSS.listenerCount('message')).toBe(1) - - // Test if the message is broadcasted - const mockClient = new MockWebSocket() - mockClient.readyState = 1 - mockWSS.clients.add(mockClient) - - // Add a 'message' event listener to the mockClient instance - mockClient.on('message', () => {}) - - mockWSS.emit('message', JSON.stringify({ test: 'test' })) - - expect(mockClient.listenerCount('message')).toBe(1) - }) -}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..d8fb20aa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/server/ui/*"] + }, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 00000000..965d777e --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' +import { cpSync } from 'node:fs' + +export default defineConfig({ + entry: [ + 'src/**/*.ts', + '!src/**/*.test.ts', + '!src/server/ui/**', + '!src/types/modules.d.ts', + ], + format: 'esm', + dts: true, + unbundle: true, + external: ['lightningcss'], + outDir: 'dist', + clean: true, + hooks: { + 'build:done': () => { + // Copy Vue components (resolved at runtime by unplugin-vue-components) + cpSync('src/components', 'dist/components', { recursive: true }) + // Copy dev UI (served at runtime by Vite) + cpSync('src/server/ui', 'dist/server/ui', { recursive: true }) + }, + }, +}) diff --git a/types/build.d.ts b/types/build.d.ts deleted file mode 100644 index 4325f236..00000000 --- a/types/build.d.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { SpinnerName } from 'cli-spinners'; - -export default interface BuildConfig { - /** - * Paths where Maizzle should look for Templates to compile. - * - * @default ['src/templates/**\/*.html'] - * - * @example - * ``` - * export default { - * build: { - * content: ['src/templates/**\/*.html'] - * } - * } - * ``` - */ - content?: string[]; - - /** - * Define the output path for compiled Templates, and what file extension they should use. - * - * @example - * ``` - * export default { - * build: { - * output: { - * path: 'build_production', - * extension: 'html' - * } - * } - * } - * ``` - */ - output?: { - /** - * Directory where Maizzle should output compiled Templates. - * - * @default 'build_{env}' - */ - path?: string; - /** - * File extension to be used for compiled Templates. - * - * @default 'html' - */ - extension: string; - /** - * Path or array of paths that will be unwrapped. - * Everything inside them will be copied to - * the root of the output directory. - * - * @example - * - * ``` - * export default { - * build: { - * content: ['test/fixtures/**\/*.html'], - * output: { - * from: ['test/fixtures'], - * } - * } - * ``` - * - * This will copy everything inside `test/fixtures` to the root - * of the output directory, not creating the `test/fixtures` - * directory. - * - */ - from: string; - }; - - /** - * Source and destination directories for static files. - * - * @example - * ``` - * export default { - * build: { - * static: { - * source: ['src/images/**\/*.*'], - * destination: 'images' - * } - * } - * } - * ``` - */ - static?: { - /** - * Array of paths where Maizzle should look for static files. - * - * @default undefined - */ - source?: string[]; - /** - * Directory where static files should be copied to, - * relative to the `build.output` path. - * - * @default undefined - */ - destination?: string; - } | Array<{ - source?: string[]; - destination?: string; - }> - - /** - * Type of spinner to show in the console. - * - * @default 'dots' - * - * @example - * ``` - * export default { - * build: { - * spinner: 'bounce' - * } - * } - * ``` - */ - spinner?: SpinnerName; - - /** - * Show a summary of files that were compiled, along with their - * size and the time it took to compile them. - * - * @default false - * - * @example - * ``` - * export default { - * build: { - * summary: true - * } - * } - * ``` - */ - summary?: boolean; - - /** - * Information about the Template currently being compiled. - * - * @example - * - * ``` - * { - path: { - root: '', - dir: 'build_production', - base: 'transactional.html', - ext: '.html', - name: 'transactional' - } - } - * ``` - */ - readonly current?: { - path?: { - root: string; - dir: string; - base: string; - ext: string; - name: string; - }; - }; -} diff --git a/types/config.d.ts b/types/config.d.ts deleted file mode 100644 index 204236e6..00000000 --- a/types/config.d.ts +++ /dev/null @@ -1,616 +0,0 @@ -import type Events from './events'; -import type BuildConfig from './build'; -import type MinifyConfig from './minify'; -import type PostHTMLConfig from './posthtml'; -import type MarkdownConfig from './markdown'; -import type { ProcessOptions } from 'postcss'; -import type PurgeCSSConfig from './css/purge'; -import type PlaintextConfig from './plaintext'; -import type CSSInlineConfig from './css/inline'; -import type { SpinnerName } from 'cli-spinners'; -import type WidowWordsConfig from './widowWords'; -import type { CoreBeautifyOptions } from 'js-beautify'; -import type { BaseURLConfig } from 'posthtml-base-url'; -import type URLParametersConfig from './urlParameters'; -import type { PostCssCalcOptions } from 'postcss-calc'; -import type { PostHTMLFetchConfig } from 'posthtml-fetch'; -import type { Config as TailwindConfig } from 'tailwindcss'; -import type { PostHTMLComponents } from 'posthtml-component'; -import type { PostHTMLExpressions } from 'posthtml-expressions'; - -export default interface Config { - /** - * Add or remove attributes from elements. - */ - attributes?: { - /** - * Add attributes to specific elements. - * - * @default {} - * - * @example - * ``` - * export default { - * attributes: { - * add: { - * table: { - * cellpadding: 0, - * cellspacing: 0, - * } - * } - * } - * } - * ``` - */ - add?: Record<string, Record<string, string | number>>; - - /** - * Remove attributes from elements. - * - * @default {} - * - * @example - * ``` - * export default { - * attributes: { - * remove: [ - * { - * name: 'width', - * value: '100', // or RegExp: /\d/ - * }, - * ], // or as array: ['width', 'height'] - * } - * } - * ``` - */ - remove?: Array<string | { name: string; value: string | RegExp }>; - } - - /** - * Configure build settings. - */ - build?: BuildConfig; - - /** - Define a string that will be prepended to sources and hrefs in your HTML and CSS. - - @example - - Prepend to all sources and hrefs: - - ``` - export default { - baseURL: 'https://cdn.example.com/' - } - ``` - - Prepend only to `src` attributes on image tags: - - ``` - export default { - baseURL: { - url: 'https://cdn.example.com/', - tags: ['img'], - }, - } - ``` - */ - baseURL?: string | BaseURLConfig; - - /** - * Configure components. - */ - components?: PostHTMLComponents; - - /** - * Configure how CSS is handled. - */ - css?: { - /** - * Configure CSS inlining. - */ - inline?: CSSInlineConfig; - - /** - * Configure CSS purging. - */ - purge?: PurgeCSSConfig; - - /** - * Resolve CSS `calc()` expressions to their static values. - */ - resolveCalc?: boolean | PostCssCalcOptions; - - /** - * Resolve CSS custom properties to their static values. - */ - resolveProps?: boolean | { - /** - * Whether to preserve custom properties in the output. - * - * @default false - */ - preserve?: boolean | 'computed'; - /** - * Define CSS variables that will be added to :root. - * - * @default {} - */ - variables?: { - [key: string]: string | { - /** - * The value of the CSS variable. - */ - value: string; - /** - * Whether the variable is !important. - */ - isImportant?: boolean; - }; - }; - /** - * Whether to preserve variables injected via JS with the `variables` option. - * - * @default true - */ - preserveInjectedVariables?: boolean; - /** - * Whether to preserve `@media` rules order. - * - * @default false - */ - preserveAtRulesOrder?: boolean; - }; - - /** - * Normalize escaped character class names like `\:` or `\/` by replacing them - * with email-safe alternatives. - * - * @example - * ``` - * export default { - * css: { - * safe: { - * ':': '__', - * '!': 'i-', - * } - * } - * } - * ``` - */ - safe?: boolean | Record<string, string>; - - /** - * Rewrite longhand CSS inside style attributes with shorthand syntax. - * Only works with `margin`, `padding` and `border`, and only when - * all sides are specified. - * - * @default [] - * - * @example - * ``` - * export default { - * css: { - * shorthand: { - * tags: ['td', 'div'], - * } // or shorthand: true - * } - * } - * ``` - */ - shorthand?: boolean | Record<string, string[]>; - - /** - * Ensure that all your HEX colors inside `bgcolor` and `color` attributes are defined with six digits. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * sixHex: false, - * } - * } - * ``` - */ - sixHex?: boolean; - - /** - * Use a custom Tailwind CSS configuration object. - */ - tailwind?: TailwindConfig; - } - - /** - Configure [posthtml-expressions](https://github.com/posthtml/posthtml-expressions) options. - */ - expressions?: PostHTMLExpressions; - - /** - * Configure the [`<fetch>`](https://maizzle.com/docs/tags#fetch) tag. - */ - fetch?: PostHTMLFetchConfig; - - /** - * Transform text inside elements marked with custom attributes. - * Filters work only on elements that contain only text. - * - * @default {} - * - * @example - * ``` - * export default { - * filters: { - * uppercase: str => str.toUpperCase(), - * } - * } - * ``` - */ - filters?: boolean | Record<string, (str: string) => string>; - - /** - * Define variables outside of the `page` object. - * - * @default {} - * - * @example - * ``` - * export default { - * locals: { - * company: { - * name: 'Spacely Space Sprockets, Inc.' - * } - * } - * } - * ``` - * - * `company` can then be used like this: - * - * ``` - * <p>{{ company.name }}</p> - * ``` - */ - locals?: Record<string, any>; - - /** - * Configure the Markdown parser. - * - * @example - * - * ``` - * export default { - * markdown: { - * root: './', // Path relative to which markdown files are imported - * encoding: 'utf8', // Encoding for imported Markdown files - * markdownit: {}, // Options passed to markdown-it - * plugins: [], // Plugins for markdown-it - * } - * } - * ``` - */ - markdown?: MarkdownConfig; - - /** - * Minify the compiled HTML email code. - * - * @default false - * - * @example - * ``` - * export default { - * minify: true, - * } - * ``` - */ - minify?: boolean | MinifyConfig; - - /** - Configure the `posthtml-mso` plugin. - */ - outlook?: { - /** - The tag name to use for Outlook conditional comments. - - @default 'outlook' - - @example - ``` - export default { - outlook: { - tag: 'mso' - } - } - // You now write <mso>...</mso> instead of <outlook>...</outlook> - ``` - */ - tag?: string; - }; - - /** - * Configure plaintext generation. - */ - plaintext?: PlaintextConfig; - - /** - * PostHTML configuration. - */ - posthtml?: PostHTMLConfig; - - /** - * Configure PostCSS - */ - postcss?: { - /** - * Additional PostCSS plugins that you would like to use. - * @default [] - * @example - * ``` - * import examplePlugin from 'postcss-example-plugin' - * export default { - * postcss: { - * plugins: [ - * examplePlugin() - * ] - * } - * } - * ``` - */ - plugins?: Array<() => void>; - - /** - * PostCSS options - * @default {} - * @example - * ``` - * export default { - * postcss: { - * options: { - * map: true - * } - * } - * ``` - */ - options?: ProcessOptions; - }; - - /** - * [Pretty print](https://maizzle.com/docs/transformers/prettify) your HTML email code - * so that it's nicely indented and more human-readable. - * - * @default undefined - * - * @example - * ``` - * export default { - * prettify: true, - * } - * ``` - */ - prettify?: boolean | CoreBeautifyOptions; - - /** - * Batch-replace strings in your HTML. - * - * @default {} - * - * @example - * ``` - * export default { - * replaceStrings: { - * 'replace this exact string': 'with this one', - * '\\s?data-src=""': '', // remove empty data-src="" attributes - * } - * } - * ``` - */ - replaceStrings?: Record<string, string>; - - /** - * Configure local server settings. - */ - server?: { - /** - * Port to run the local server on. - * - * @default 3000 - */ - port?: number; - - /** - * Enable HMR-like local development. - * When enabled, Maizzle will watch for changes in your templates - * and inject them into the browser without a full page reload. - * - * @default true - */ - hmr?: boolean; - - /** - * Enable synchronized scrolling across browser tabs. - * - * @default false - */ - scrollSync?: boolean; - - /** - * Paths to watch for changes. - * When a file in these paths changes, Maizzle will do a full rebuild. - * - * @default [] - */ - watch?: string[]; - - /** - * Toggle reporting compiled file size in the console. - * - * @default false - */ - reportFileSize?: boolean; - - /** - * Type of spinner to show in the console. - * - * @default 'circleHalves' - * - * @example - * ``` - * export default { - * server: { - * spinner: 'bounce' - * } - * } - * ``` - */ - spinner?: SpinnerName; - } - - /** - Configure custom parameters to append to URLs. - - @example - ``` - module.exports = { - urlParameters: { - _options: { - tags: ['a'], - qs: {}, // options for the `query-string` library - }, - utm_source: 'maizzle', - utm_campaign: 'Campaign Name', - utm_medium: 'email', - custom_parameter: 'foo', - '1stOfApril': 'bar', - } - } - ``` - */ - urlParameters?: URLParametersConfig; - - /** - * Enable or disable all Transformers. - * - * @default true - * - * @example - * ``` - * export default { - * useTransformers: false, - * } - * ``` - */ - useTransformers?: boolean; - - /** - * Prevent widow words inside a tag by adding a `&nbsp;` between its last two words. - * - * @default - * { - * minWordCount: 3, - * attrName: 'prevent-widows' - * } - * - * @example - * ``` - * export default { - * widowWords: { - * minWordCount: 5, - * }, - * } - * ``` - */ - widowWords?: WidowWordsConfig; - - /** - * Runs after the Environment config has been computed, but before Templates are processed. - * Exposes the `config` object so you can further customize it. - * - * @default undefined - * - * @example - * ``` - * export default { - * beforeCreate: async ({config}) => { - * // do something with `config` - * } - * } - * ``` - */ - beforeCreate?: Events['beforeCreate']; - - /** - * Runs after the Template's config has been computed, but just before it is compiled. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * beforeRender: async ({html, matter, config, posthtml, transform}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - beforeRender?: Events['beforeRender']; - - /** - * Runs after the Template has been compiled, but before any Transformers have been applied. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterRender: async ({html, matter, config, posthtml, transform}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - afterRender?: Events['afterRender']; - - /** - * Runs after all Transformers have been applied, just before the final HTML is returned. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterTransformers: async ({html, matter, config, posthtml, transform}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - afterTransformers?: Events['afterTransformers']; - - /** - * Runs after all Templates have been compiled and output to disk. - * `files` will contain the paths to all the files inside the `build.output.path` directory. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterBuild: async ({files, config, transform}) => { - * // do something... - * } - * } - * ``` - */ - afterBuild?: Events['afterBuild']; - - [key: string]: any; -} diff --git a/types/css/inline.d.ts b/types/css/inline.d.ts deleted file mode 100644 index 260f0380..00000000 --- a/types/css/inline.d.ts +++ /dev/null @@ -1,218 +0,0 @@ -export type AttributeToStyleSupportedAttributes = - | 'width' - | 'height' - | 'bgcolor' - | 'background' - | 'align' - | 'valign'; - -export default interface InlineCSSConfig { - /** - * Which CSS properties should be duplicated as what HTML attributes. - * - * @default {} - * - * @example - * ``` - * export default { - * css: { - * inline: { - * styleToAttribute: { - * 'background-color': 'bgcolor', - * } - * } - * } - * } - * ``` - */ - styleToAttribute?: Record<string, string>; - - /** - * Duplicate HTML attributes to inline CSS. - * - * @default false - * - * @example - * ``` - * export default { - * css: { - * inline: { - * attributeToStyle: ['width', 'bgcolor', 'background'] - * } - * } - * } - * ``` - */ - attributeToStyle?: boolean | AttributeToStyleSupportedAttributes[]; - - /** - * Use any CSS pixel widths to create `width` attributes on elements set in `widthElements`. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * inline: { - * applyWidthAttributes: true, - * } - * } - * } - * ``` - */ - applyWidthAttributes?: boolean; - - /** - * Use any CSS pixel widths to create `height` attributes on elements set in `heightElements`. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * inline: { - * applyHeightAttributes: true, - * } - * } - * } - * ``` - */ - applyHeightAttributes?: boolean; - - /** - * Prefer HTML `width` and `height` attributes over inline CSS. - * The inline CSS `width` and `height` will be removed. - * - * Applies to elements set in `widthElements` and `heightElements`. - * - * @example - * ``` - * export default { - * css: { - * inline: { - * useAttributeSizes: true - * } - * } - * } - * ``` - */ - useAttributeSizes?: boolean; - - /** - * Array of CSS property names that should be excluded from the CSS inlining process. - * - * @default [] - * - * @example - * ``` - * export default { - * css: { - * inline: { - * excludedProperties: ['padding', 'padding-left'] - * } - * } - * } - * ``` - */ - excludedProperties?: string[]; - - /** - * Fenced code blocks that should be ignored during CSS inlining. - * - * @default - * { - * EJS: { start: '<%', end: '%>' }, - * HBS: { start: '{{', end: '}}' } - * } - * - * @example - * ``` - * export default { - * css: { - * inline: { - * codeBlocks: { - * EJS: { start: '<%', end: '%>' }, - * HBS: { start: '{{', end: '}}' }, - * } - * } - * } - * } - * ``` - */ - codeBlocks?: Record<string, { start: string; end: string }>; - - /** - * Provide your own CSS to be inlined. Must be vanilla or pre-compiled CSS. - * - * Existing `<style>` in your HTML tags will be ignored and their contents won't be inlined. - * - * @default false - * - * @example - * ``` - * export default { - * css: { - * inline: { - * customCSS: `.custom-class { color: red }` - * } - * } - * } - * ``` - */ - customCSS?: string | false; - - /** - * Remove inlined CSS selectors from the `<style>` tag. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * inline: { - * removeInlinedSelectors: true - * } - * } - * ``` - */ - removeInlinedSelectors?: boolean; - - /** - * Prefer unitless CSS values - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * inline: { - * preferUnitless: true - * } - * } - * } - * ``` - */ - preferUnitlessValues?: boolean; - - /** - * Array of CSS selectors that should be preserved after inlining. - * - * @default [] // array of email-client targeting selectors - * - * @example - * ``` - * export default { - * css: { - * inline: { - * safelist: ['.line', '.bg-red-200'] - * } - * } - * } - * ``` - */ - safelist?: string[]; -} diff --git a/types/css/purge.d.ts b/types/css/purge.d.ts deleted file mode 100644 index ef6ef1f4..00000000 --- a/types/css/purge.d.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { Opts } from 'email-comb'; - -export default interface PurgeCSSConfig { - /** - * Classes or IDs that you don't want removed. - * - * @default [] - * - * @example - * ``` - * export default { - * css: { - * purge: { - * safelist: ['.some-class', '.Mso*', '#*'], - * } - * } - * } - * ``` - */ - safelist?: Opts['whitelist']; - - /** - * Start and end delimiters for computed classes that you don't want removed. - * - * @default - * [ - * { - * heads: '{{', - * tails: '}}', - * }, - * { - * heads: '{%', - * tails: '%}', - * } - * ] - * - * @example - * ``` - * export default { - * css: { - * purge: { - * backend: [ - * { heads: '[[', tails: ']]' }, - * ] - * } - * } - * } - * ``` - */ - backend?: Opts['backend']; - - /** - * Whether to remove `<!-- HTML comments -->`. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * purge: { - * removeHTMLComments: false, - * } - * } - * } - * ``` - */ - removeHTMLComments?: Opts['removeHTMLComments']; - - /** - * Whether to remove `/* CSS comments *\/`. - * - * @default true - * - * @example - * ``` - * export default { - * css: { - * purge: { - * removeCSSComments: false, - * } - * } - * } - * ``` - */ - removeCSSComments?: Opts['removeCSSComments']; - - /** - * List of strings representing start of a conditional comment that should not be removed. - * - * @default - * ['[if', '[endif'] - * - * @example - * ``` - * export default { - * css: { - * purge: { - * doNotRemoveHTMLCommentsWhoseOpeningTagContains: ['[if', '[endif'], - * } - * } - * } - * ``` - */ - doNotRemoveHTMLCommentsWhoseOpeningTagContains?: Opts['doNotRemoveHTMLCommentsWhoseOpeningTagContains']; - - /** - * Rename all classes and IDs in both your `<style>` tags and your body HTML elements, - * to be as few characters as possible. - * - * @default false - * - * @example - * ``` - * export default { - * css: { - * purge: { - * uglify: true, - * } - * } - * } - * ``` - */ - uglify?: Opts['uglify']; -} diff --git a/types/events.d.ts b/types/events.d.ts deleted file mode 100644 index 86671f21..00000000 --- a/types/events.d.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type Config from "./config"; - -export default interface Events { - /** - * Runs after the Environment config has been computed, but before Templates are processed. - * Exposes the `config` object so you can further customize it. - * - * @default undefined - * - * @example - * ``` - * export default { - * beforeCreate: async ({config}) => { - * // do something with `config` - * } - * } - * ``` - */ - beforeCreate?: (params: { - /** - * The computed Maizzle config object. - */ - config: Config - }) => void | Promise<void>; - - /** - * Runs after the Template's config has been computed, but just before it is compiled. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * beforeRender: async ({html, matter, config}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - beforeRender?: (params: { - /** - * The Template's HTML string. - */ - html: string; - /** - * The Template's Front Matter data. - */ - matter: { [key: string]: string }; - /** - * The Template's computed config. - * - * This is the Environment config merged with the Template's Front Matter. - */ - config: Config; - }) => string | Promise<string>; - - /** - * Runs after the Template has been compiled, but before any Transformers have been applied. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterRender: async async ({html, matter, config}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - afterRender?: (params: { - /** - * The Template's HTML string. - */ - html: string; - /** - * The Template's Front Matter data. - */ - matter: { [key: string]: string }; - /** - * The Template's computed config. - * - * This is the Environment config merged with the Template's Front Matter. - */ - config: Config; - }) => string | Promise<string>; - - /** - * Runs after all Transformers have been applied, just before the final HTML is returned. - * - * Must return the `html` string, otherwise the original will be used. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterTransformers: async ({html, matter, config}) => { - * // do something... - * return html; - * } - * } - * ``` - */ - afterTransformers?: (params: { - /** - * The Template's HTML string. - */ - html: string; - /** - * The Template's Front Matter data. - */ - matter: { [key: string]: string }; - /** - * The Template's computed config. - * - * This is the Environment config merged with the Template's Front Matter. - */ - config: Config; - }) => string | Promise<string>; - - /** - * Runs after all Templates have been compiled and output to disk. - * `files` will contain the paths to all the files inside the `build.output.path` directory. - * - * @default undefined - * - * @example - * ``` - * export default { - * afterBuild: async ({config, files}) => { - * // do something... - * } - * } - * ``` - */ - afterBuild?: (params: { - /** - * The computed Maizzle config object. - */ - config: Config; - /** - * An array of paths to all the files inside the `build.output.path` directory. - */ - files: string[]; - }) => string | Promise<string>; -} diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index 03d5a956..00000000 --- a/types/index.d.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type Config from './config'; -import type MinifyConfig from './minify'; -import type PostHTMLConfig from './posthtml'; -import type { RenderOutput } from './render'; -import type MarkdownConfig from './markdown'; -import type PurgeCSSConfig from './css/purge'; -import type PlaintextConfig from './plaintext'; -import type CSSInlineConfig from './css/inline'; -import type WidowWordsConfig from './widowWords'; -import type { BaseURLConfig } from 'posthtml-base-url'; -import type { HTMLBeautifyOptions } from 'js-beautify' -import type { Opts as PlaintextOptions } from 'string-strip-html'; -import type { URLParametersConfig } from 'posthtml-url-parameters'; -import type { AttributeToStyleSupportedAttributes } from './css/inline'; - -declare namespace MaizzleFramework { - /** - * Compile an HTML string with Maizzle. - * - * @param {string} html The HTML string to render. - * @param {Config} [config] A Maizzle configuration object. - * @returns {Promise<RenderOutput>} The rendered HTML and the Maizzle configuration object. - */ - function render(html: string, config?: Config): Promise<RenderOutput>; - - /** - * Normalize escaped character class names like `\:` or `\/` by replacing them with email-safe alternatives. - * - * @param {string} html The HTML string to normalize. - * @param {Record<string, string>} replacements A dictionary of replacements to apply. - * @returns {string} The normalized HTML string. - * @see https://maizzle.com/docs/transformers/safe-class-names - */ - function safeClassNames(html: string, replacements: Record<string, string>): string; - - /** - * Compile Markdown to HTML. - * - * @param {string} input The Markdown string to compile. - * @param {MarkdownConfig} [options] A configuration object for the Markdown compiler. - * @param {PostHTMLConfig} [posthtmlOptions] A configuration object for PostHTML. - * @returns {Promise<string>} The compiled HTML string. - * @see https://maizzle.com/docs/markdown#api - */ - function markdown(input: string, options?: MarkdownConfig, posthtmlOptions?: PostHTMLConfig): Promise<string>; - - /** - * Prevent widow words inside a tag by adding a `&nbsp;` between its last two words. - * - * @param {string} html The HTML string to process. - * @param {WidowWordsConfig} [options] A configuration object for the widow words transformer. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/widows - */ - function preventWidows(html: string, options?: WidowWordsConfig): string; - - /** - * Duplicate HTML attributes to inline CSS. - * - * @param {string} html The HTML string to process. - * @param {AttributeToStyleSupportedAttributes[]} attributes An array of attributes to inline. - * @param {PostHTMLConfig} [posthtmlConfig] A configuration object for PostHTML. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/attribute-to-style - */ - function attributeToStyle( - html: string, - attributes: AttributeToStyleSupportedAttributes[], - posthtmlConfig?: PostHTMLConfig - ): string; - - /** - * Inline CSS styles in an HTML string. - * - * @param {string} html The HTML string to process. - * @param {CSSInlineConfig} [options] A configuration object for the CSS inliner. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/inline-css - */ - function inlineCSS(html: string, options?: CSSInlineConfig): string; - - /** - * Rewrite longhand CSS inside style attributes with shorthand syntax. - * Only works with margin, padding and border, and only when all sides are specified. - * - * @param {string} html The HTML string to process. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/shorthand-css - */ - function shorthandCSS(html: string): string; - - /** - * Remove unused CSS from `<style>` tags and HTML elements. - * - * @param {string} html The HTML string to process. - * @param {PurgeCSSConfig} [options] A configuration object for `email-comb`. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/remove-unused-css - */ - function removeUnusedCSS(html: string, options?: PurgeCSSConfig): string; - - /** - * Remove HTML attributes from an HTML string. - * - * @param {string} html The HTML string to process. - * @param {string[] | Array<{ name: string; value: string | RegExp }>} [options] An array of attribute names to remove, or an array of objects with `name` and `value` properties. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/remove-attributes - */ - function removeAttributes( - html: string, - options?: - | string[] - | Array<{ - name: string; - value: string | RegExp; - }> - ): string; - - /** - * Add attributes to elements in an HTML string. - * - * @param {string} html The HTML string to process. - * @param {Record<string, unknown>} [options] A dictionary of attributes to add. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/add-attributes - */ - function addAttributes(html: string, options?: Record<string, unknown>): string; - - /** - * Pretty-print an HTML string. - * - * @param {string} html The HTML string to prettify. - * @param {HTMLBeautifyOptions} [options] A configuration object for `js-beautify`. - * @returns {string} The prettified HTML string. - * @see https://maizzle.com/docs/transformers/prettify - */ - function prettify(html: string, options?: HTMLBeautifyOptions): string; - - /** - * Prepend a string to sources and hrefs in an HTML string. - * - * @param {string} html The HTML string to process. - * @param {string | BaseURLConfig} [options] A string to prepend to sources and hrefs, or a configuration object for `posthtml-base-url`. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/base-url - */ - function applyBaseURL(html: string, options?: string | BaseURLConfig): string; - - /** - * Append parameters to URLs in an HTML string. - * - * @param {string} html The HTML string to process. - * @param {URLParametersConfig} [options] A configuration object for `posthtml-url-parameters`. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/url-parameters - */ - function addURLParameters(html: string, options?: URLParametersConfig): string; - - /** - * Ensure that all HEX colors inside `bgcolor` and `color` attributes are defined with six digits. - * - * @param {string} html The HTML string to process. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/six-hex - */ - function sixHEX(html: string): string; - - /** - * Minify an HTML string, with email client considerations. - * - * @param {string} html The HTML string to minify. - * @param {MinifyConfig} [options] A configuration object for `html-minifier`. - * @returns {string} The minified HTML string. - * @see https://maizzle.com/docs/transformers/minify - */ - function minify(html: string, options?: MinifyConfig): string; - - /** - * Batch-replace strings in an HTML string. - * - * @param {string} html The HTML string to process. - * @param {Record<string, string>} [replacements] A dictionary of strings to replace. - * @returns {string} The processed HTML string. - * @see https://maizzle.com/docs/transformers/replace-strings - */ - function replaceStrings(html: string, replacements?: Record<string, string>): string; - - /** - * Generate a plaintext version of an HTML string. - * @param {string} html - The HTML string to convert to plaintext. - * @param {Object} [config={}] - Configuration object. - * @param {PostHTMLConfig} [config.posthtml] - PostHTML options. - * @param {PlaintextOptions} [config.strip] - Options for `string-strip-html`. - * @returns {Promise<string>} A string representing the HTML converted to plaintext. - * @see https://maizzle.com/docs/plaintext - */ - function generatePlaintext( - html: string, - config?: { - /** - * Configure PostHTML options. - */ - posthtml?: PostHTMLConfig - } - & PlaintextOptions, - ): Promise<string>; - - export { - Config, - render, - safeClassNames, - markdown, - preventWidows, - attributeToStyle, - inlineCSS, - shorthandCSS, - removeUnusedCSS, - removeUnusedCSS as purgeCSS, - removeAttributes, - addAttributes, - prettify, - applyBaseURL, - addURLParameters as addUrlParams, - sixHEX, - minify, - replaceStrings, - generatePlaintext, - } -} - -export = MaizzleFramework; diff --git a/types/markdown.d.ts b/types/markdown.d.ts deleted file mode 100644 index 8f304492..00000000 --- a/types/markdown.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Options as MarkdownItOptions } from 'markdown-it'; - -export default interface MarkdownConfig { - /** - * Path relative to which markdown files are imported. - * - * @default './' - */ - root?: string; - - /** - * Encoding for imported Markdown files. - * - * @default 'utf8' - */ - encoding?: string; - - /** - * Options to pass to the `markdown-it` library. - * - * @default {} - */ - markdownit?: MarkdownItOptions; - - /** - * Plugins for the `markdown-it` library. - * @default [] - */ - plugins?: Array<{ - plugin: string; - options: Record<string, unknown>; - }>; -} diff --git a/types/minify.d.ts b/types/minify.d.ts deleted file mode 100644 index 48e37b0e..00000000 --- a/types/minify.d.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Opts } from 'html-crush'; - -export default interface MinifyConfig { - /** - * Maximum line length. Works only when `removeLineBreaks` is `true`. - * - * @default 500 - */ - lineLengthLimit?: Opts['lineLengthLimit']; - - /** - * Remove all line breaks from HTML when minifying. - * - * @default true - */ - removeLineBreaks?: Opts['removeLineBreaks']; - - /** - * Remove code indentation when minifying HTML. - * - * @default true - */ - removeIndentations?: Opts['removeIndentations']; - - /** - * Remove `<!-- HTML comments -->` when minifying HTML. - * - * `0` - don't remove any HTML comments - * - * `1` - remove all comments except Outlook conditional comments - * - * `2` - remove all comments, including Outlook conditional comments - * - * @default false - */ - removeHTMLComments?: Opts['removeHTMLComments']; - - /** - * Remove CSS comments when minifying HTML. - * - * @default true - */ - removeCSSComments?: Opts['removeCSSComments']; - - /** - * When any of given strings are encountered and `removeLineBreaks` is true, current line will be terminated. - * - * @default - * [ - * '</td', - * '<html', - * '</html', - * '<head', - * '</head', - * '<meta', - * '<link', - * '<table', - * '<script', - * '</script', - * '<!DOCTYPE', - * '<style', - * '</style', - * '<title', - * '<body', - * '@media', - * '</body', - * '<!--[if', - * '<!--<![endif', - * '<![endif]' - * ] - */ - breakToTheLeftOf?: Opts['breakToTheLeftOf']; - - /** - * Some inline tags can accidentally introduce extra text. - * The minifier will take extra precaution when minifying around these tags. - * - * @default - * [ - * 'a', - * 'abbr', - * 'acronym', - * 'audio', - * 'b', - * 'bdi', - * 'bdo', - * 'big', - * 'br', - * 'button', - * 'canvas', - * 'cite', - * 'code', - * 'data', - * 'datalist', - * 'del', - * 'dfn', - * 'em', - * 'embed', - * 'i', - * 'iframe', - * 'img', - * 'input', - * 'ins', - * 'kbd', - * 'label', - * 'map', - * 'mark', - * 'meter', - * 'noscript', - * 'object', - * 'output', - * 'picture', - * 'progress', - * 'q', - * 'ruby', - * 's', - * 'samp', - * 'script', - * 'select', - * 'slot', - * 'small', - * 'span', - * 'strong', - * 'sub', - * 'sup', - * 'svg', - * 'template', - * 'textarea', - * 'time', - * 'u', - * 'tt', - * 'var', - * 'video', - * 'wbr' - * ] - */ - mindTheInlineTags?: Opts['mindTheInlineTags']; -} diff --git a/types/plaintext.d.ts b/types/plaintext.d.ts deleted file mode 100644 index cc2e9699..00000000 --- a/types/plaintext.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Opts as PlaintextOptions } from 'string-strip-html'; - -export default interface PlaintextConfig extends PlaintextOptions { - /** - * Configure where plaintext files should be output. - * - * @example - * ``` - * export default { - * plaintext: { - * output: { - * path: 'dist/brand/plaintext', - * extension: 'rtxt' - * } - * } - * } - * ``` - */ - output?: { - /** - * Directory where Maizzle should output compiled Plaintext files. - * - * @default 'build_{env}' - * - * @example - * ``` - * export default { - * plaintext: { - * output: { - * path: 'dist/brand/plaintext' - * } - * } - * } - * ``` - */ - path?: string; - - /** - * File extension to be used for compiled Plaintext files. - * - * @default 'txt' - * - * @example - * ``` - * export default { - * plaintext: { - * output: { - * extension: 'rtxt' - * } - * } - * } - * ``` - */ - extension: string; - }; -} diff --git a/types/posthtml.d.ts b/types/posthtml.d.ts deleted file mode 100644 index 15ca3d61..00000000 --- a/types/posthtml.d.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Directive } from 'posthtml-parser'; -import type { PostHTMLExpressions } from 'posthtml-expressions'; - -export interface PostHTMLOptions { - /** - * Configure the PostHTML parser to process custom directives. - * - * @default [] - */ - directives?: Directive[]; - - /** - * Enable `xmlMode` if you're using Maizzle to output XML content, and not actual HTML. - * - * @default false - */ - xmlMode?: boolean; - - /** - * Decode entities in the HTML. - * - * @default false - */ - decodeEntities?: boolean; - - /** - * Output all tags in lowercase. Works only when `xmlMode` is disabled. - * - * @default false - */ - lowerCaseTags?: boolean; - - /** - * Output all attribute names in lowercase. - * - * @default false - */ - lowerCaseAttributeNames?: boolean; - - /** - * Recognize CDATA sections as text even if the `xmlMode` option is disabled. - * - * @default false - */ - recognizeCDATA?: boolean; - - /** - * Recognize self-closing tags. - * Disabling this will cause rendering to stop at the first self-closing custom (non-HTML) tag. - * - * @default true - */ - recognizeSelfClosing?: boolean; - - /** - * If enabled, AST nodes will have a location property containing the `start` and `end` line and column position of the node. - * - * @default false - */ - sourceLocations?: boolean; - - /** - * Whether attributes with no values should render exactly as they were written, without `=""` appended. - * - * @default true - */ - recognizeNoValueAttribute?: boolean; - - /** - * Make PostHTML to treat custom tags as self-closing. - * - * @default [] - */ - singleTags?: string[] | RegExp[]; - - /** - * Define the closing format for single tags. - * - * @default 'default' - */ - closingSingleTag?: 'tag' | 'slash'; - - /** - * Whether to quote all attribute values. - * - * @default true - */ - quoteAllAttributes?: boolean; - - /** - * Replaces quotes in attribute values with `&quote;`. - * - * @default true - */ - replaceQuote?: boolean; - - /** - * Specify the style of quote around the attribute values. - * - * @default 2 - * - * @example - * - * `0` - Quote style is based on attribute values (an alternative for `replaceQuote` option) - * - * ``` - * <img src="example.jpg" onload='testFunc("test")'> - * ``` - * - * @example - * - * `1` - Attribute values are wrapped in single quotes - * - * ``` - * <img src='example.jpg' onload='testFunc("test")'> - * ``` - * - * @example - * - * `2` - Attribute values are wrapped in double quotes - * - * ``` - * <img src="example.jpg" onload="testFunc("test")"> - * ``` - */ - quoteStyle?: 0 | 1 | 2; -} - -export default interface PostHTMLConfig { - /** - Configure [posthtml-expressions](https://github.com/posthtml/posthtml-expressions) options. - */ - expressions?: PostHTMLExpressions; - - /** - Configure PostHTML options. - */ - options?: PostHTMLOptions; - - /** - * PostHTML plugins to apply before or after Maizzle's own plugins. - * - * @example - * ``` - * import spaceless from 'posthtml-spaceless' - * export default { - * posthtml: { - * plugins: [ - * spaceless() - * ] - * } - * } - * ``` - */ - plugins?: { - before: Array<() => void>; - /** - * Plugins to apply after Maizzle's own plugins. - */ - after: Array<() => void>; - } | (() => void)[]; -} diff --git a/types/render.d.ts b/types/render.d.ts deleted file mode 100644 index 19f95d55..00000000 --- a/types/render.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type Config from './config'; - -export type RenderOutput = { - /** - The rendered HTML. - */ - html: string; - - /** - The Maizzle configuration object. - */ - config: Config; -}; diff --git a/types/urlParameters.d.ts b/types/urlParameters.d.ts deleted file mode 100644 index aac39e96..00000000 --- a/types/urlParameters.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { URLParametersConfig as URLParamsConfig } from 'posthtml-url-parameters'; - -export default interface URLParametersConfig { - [key: string]: any; - - _options?: { - /** - * Array of tag names to process. Only URLs inside `href` attributes - * of tags in this array will be processed. - * - * @default ['a'] - * - * @example - * ``` - * export default { - * urlParameters: { - * _options: { - * tags: ['a[href*="example.com"]'], - * }, - * utm_source: 'maizzle', - * } - * } - * ``` - */ - tags?: URLParamsConfig['tags']; - - /** - By default, query parameters are appended only to valid URLs. - Disable strict mode to append parameters to any string. - - @default true - */ - strict?: boolean; - - /** - Options to pass to the `query-string` library. - */ - qs?: URLParamsConfig['qs']; - }; -} diff --git a/types/widowWords.d.ts b/types/widowWords.d.ts deleted file mode 100644 index 897efed6..00000000 --- a/types/widowWords.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -export default interface WidowWordsConfig { - /** - The attribute name to use. - - @default ['prevent-widows', 'no-widows'] - */ - attributes?: Array<string>; - - /** - Replace all widow word `nbsp;` instances with a single space. - This is basically the opposite of preventing widow words. - - @default false - */ - createWidows?: Boolean; - - /** - The minimum amount of words in a target string, in order to trigger the transformer. - - @default 3 - */ - minWords?: Number; - - /** - Start/end pairs of strings that will prevent the transformer from removing widow words inside them. - - @default [ - { start: '{{', end: '}}' }, // Handlebars, Liquid, Nunjucks, Twig, Jinja2, Mustache - { start: '{%', end: '%}' }, // Liquid, Nunjucks, Twig, Jinja2 - { start: '<%=', end: '%>' }, // EJS, ERB - { start: '<%', end: '%>' }, // EJS, ERB - { start: '{$', end: '}' }, // Smarty - { start: '<\\?', end: '\\?>' }, // PHP - { start: '#{', end: '}' } // Pug - ] - */ - ignore?: Array<{ start: string; end: string }>; -} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 1017a671..00000000 --- a/vite.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - include: ['test/**/*test.js'], - testTimeout: 10000, - }, -}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..3bb06d0d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'happy-dom', + }, +})