feat: add vite-layers
This commit is contained in:
@@ -47,8 +47,14 @@ pnpm dev # build + watch into dist/
|
||||
pnpm build # type-check + production build into dist/
|
||||
pnpm test # unit tests (color + geometry utils)
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm icons # regenerate the extension icons (PNG sizes + SVG source)
|
||||
```
|
||||
|
||||
The toolbar icon sizes are generated from the source artwork `src/assets/logo.png` by
|
||||
[`scripts/generate-icons.mjs`](scripts/generate-icons.mjs) (pure Node — decodes the PNG and
|
||||
area-downsamples it). They are written to `public/icons/`, which Vite copies into `dist/` where
|
||||
the manifest references them. To change the logo, replace `src/assets/logo.png` and run `pnpm icons`.
|
||||
|
||||
Then load it in Chrome:
|
||||
|
||||
1. Open `chrome://extensions`, enable **Developer mode**.
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Element Inspector",
|
||||
"version": "0.0.0",
|
||||
"description": "Isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.",
|
||||
"version": "1.0.0",
|
||||
"description": "Isolate any page element on a clean canvas: box model, colors, inherited styles, grid/flex layout and responsive testing.",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Inspect element (Alt+Shift+E)"
|
||||
"default_title": "Inspect element (Alt+Shift+E)",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "src/background.ts",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "element-inspector",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Browser extension to isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.",
|
||||
"description": "Browser extension to isolate any page element on a clean canvas and inspect its box model, colors, inherited styles, grid/flex layout and responsive behavior.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"icons": "node scripts/generate-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.6.0-beta.13"
|
||||
@@ -21,7 +22,7 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.13",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-web-extension": "^4.5.1",
|
||||
"vitest": "^4.1.8",
|
||||
"vue-jsx-vapor": "^3.2.14"
|
||||
|
||||
Generated
+29
-12
@@ -31,7 +31,7 @@ importers:
|
||||
specifier: ~6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.13
|
||||
specifier: ^8.0.16
|
||||
version: 8.0.16(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vite-plugin-web-extension:
|
||||
specifier: ^4.5.1
|
||||
@@ -164,36 +164,42 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
|
||||
@@ -262,24 +268,28 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||
@@ -407,21 +417,25 @@ packages:
|
||||
resolution: {integrity: sha512-nQ5+lm7sCscWlh7FpAZyatHChfhyUjPtqlpXrUq/zGilHq57Ls4OLxoI6krPPTUpxMR6GaqhJjozgoniy1CzZQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@vue-jsx-vapor/compiler-rs-linux-arm64-musl@3.2.14':
|
||||
resolution: {integrity: sha512-SCjl5dlnH7HKuaCdwvJZ/Z1L7AYw+R6Bheruvf2HDfyzabNMn4ovW2LLZ3rSBrzciTlkfH34RDJBqHr/GV8Q2A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@vue-jsx-vapor/compiler-rs-linux-x64-gnu@3.2.14':
|
||||
resolution: {integrity: sha512-mD8Ooy11xGucEf/RClUNBP9iQyQKw5kLCCQZ3+dX/Q+V12OSYkrt/4Bz1quCeoink0uIz7FxpQay1/o2qbFo4Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@vue-jsx-vapor/compiler-rs-linux-x64-musl@3.2.14':
|
||||
resolution: {integrity: sha512-hoeRrCtJTMHAQCMYn3NZbiV69efaX/0PHPg54SmWgCXxkXvl05AKHli9QpOERWmG4DYRo8APLCs9FvLe7Lyp9g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@vue-jsx-vapor/compiler-rs-wasm32-wasi@3.2.14':
|
||||
resolution: {integrity: sha512-m17CyZXe0t72xc3EwSMIRavxeW9ZnOIOjMzRvWLxnUHZr4cH5nrrG/QhO4wul1gqpMWQrQkQMdUKclALNPFt4A==}
|
||||
@@ -981,24 +995,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -1072,9 +1090,8 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
obug@2.1.2:
|
||||
resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
@@ -1178,8 +1195,8 @@ packages:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
semver@7.8.2:
|
||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2442,7 +2459,7 @@ snapshots:
|
||||
dependencies:
|
||||
growly: 1.3.0
|
||||
is-wsl: 2.2.0
|
||||
semver: 7.8.2
|
||||
semver: 7.8.1
|
||||
shellwords: 0.1.1
|
||||
uuid: 8.3.2
|
||||
which: 2.0.2
|
||||
@@ -2451,7 +2468,7 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
obug@2.1.2: {}
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
@@ -2462,7 +2479,7 @@ snapshots:
|
||||
ky: 1.14.3
|
||||
registry-auth-token: 5.1.1
|
||||
registry-url: 6.0.1
|
||||
semver: 7.8.2
|
||||
semver: 7.8.1
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
@@ -2578,7 +2595,7 @@ snapshots:
|
||||
|
||||
sax@1.6.0: {}
|
||||
|
||||
semver@7.8.2: {}
|
||||
semver@7.8.1: {}
|
||||
|
||||
set-value@4.1.0:
|
||||
dependencies:
|
||||
@@ -2722,7 +2739,7 @@ snapshots:
|
||||
is-npm: 6.1.0
|
||||
latest-version: 9.0.0
|
||||
pupa: 3.3.0
|
||||
semver: 7.8.2
|
||||
semver: 7.8.1
|
||||
xdg-basedir: 5.1.0
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
@@ -2783,7 +2800,7 @@ snapshots:
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.2
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
spawn-sync: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 635 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -64,14 +64,25 @@ function ensureHost(): ShadowRoot {
|
||||
function deactivate(): void {
|
||||
if (mode === 'idle') return;
|
||||
mode = 'idle';
|
||||
// Each teardown step is isolated so a throw in one (e.g. during Vapor unmount) can't
|
||||
// strand the overlay on the page — the host is always removed.
|
||||
if (picker) {
|
||||
const active = picker;
|
||||
picker = null;
|
||||
active.cancel();
|
||||
try {
|
||||
active.cancel();
|
||||
} catch {
|
||||
/* picker already torn down */
|
||||
}
|
||||
}
|
||||
if (canvas) {
|
||||
canvas.unmount();
|
||||
const active = canvas;
|
||||
canvas = null;
|
||||
try {
|
||||
active.unmount();
|
||||
} catch {
|
||||
/* app already unmounted */
|
||||
}
|
||||
}
|
||||
if (host) {
|
||||
host.remove();
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function App() {
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true));
|
||||
|
||||
return (
|
||||
<div class="ei-root flex h-full w-full flex-col overflow-hidden bg-[#0b0e14] font-sans text-[13px] text-slate-200 antialiased">
|
||||
<div class="flex h-full w-full flex-col overflow-hidden bg-[#0b0e14] font-sans text-[13px] text-slate-200 antialiased">
|
||||
<Toolbar />
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<Stage />
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import type { ColorSwatch as Swatch } from '../store';
|
||||
import { useClipboard } from '../composables';
|
||||
|
||||
// A single color row: swatch + label + value (CSS variable name when resolved, else hex).
|
||||
// Clicking copies `var(--name)` or the hex to the clipboard.
|
||||
export default function ColorSwatch(props: { swatch: Swatch }) {
|
||||
const copy = (): void => {
|
||||
const text = props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex;
|
||||
navigator.clipboard?.writeText(text).catch(() => {
|
||||
/* clipboard may be blocked on some pages */
|
||||
});
|
||||
const { copy, copied } = useClipboard();
|
||||
const onCopy = (): void => {
|
||||
void copy(props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
onClick={onCopy}
|
||||
title="Copy"
|
||||
class="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-white/5"
|
||||
>
|
||||
<span class="h-6 w-6 shrink-0 rounded border border-white/15" style={{ background: props.swatch.hex }} />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[10px] uppercase tracking-wide text-slate-500">{props.swatch.label}</span>
|
||||
<span class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{props.swatch.label}
|
||||
{props.swatch.inherited ? (
|
||||
<span title="Inherited from an ancestor" class="rounded-sm bg-amber-500/15 px-1 text-[9px] normal-case text-amber-300">
|
||||
inh
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span class="block truncate font-mono text-[11px] text-slate-200">
|
||||
{props.swatch.varName ?? props.swatch.hex}
|
||||
</span>
|
||||
</span>
|
||||
{props.swatch.varName ? <span class="shrink-0 font-mono text-[10px] text-slate-500">{props.swatch.hex}</span> : null}
|
||||
<span class="shrink-0 font-mono text-[10px] text-slate-500">
|
||||
{copied.value ? 'copied' : props.swatch.varName ? props.swatch.hex : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import { removeGuide, state, updateGuide } from '../store';
|
||||
|
||||
// The ruler band size (must match Rulers' SIZE). Dropping a guide back over its ruler removes it.
|
||||
const RULER = 20;
|
||||
|
||||
// Interactive guides drawn over the canvas. Each guide is a thin draggable strip: drag to
|
||||
// reposition, drag back onto its ruler (or double-click) to remove. New guides are pulled out
|
||||
// of the rulers (see Rulers.tsx). Positions are stored in iframe-content pixels and mapped to
|
||||
// the viewport via the current pan/zoom.
|
||||
export default function GuidesLayer() {
|
||||
const layer = shallowRef<HTMLDivElement>();
|
||||
|
||||
const startMove = (axis: 'x' | 'y', index: number): void => {
|
||||
const rect = layer.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const move = (ev: PointerEvent): void => {
|
||||
const pos =
|
||||
axis === 'x'
|
||||
? (ev.clientX - rect.left - state.panX) / state.zoom
|
||||
: (ev.clientY - rect.top - state.panY) / state.zoom;
|
||||
updateGuide(axis, index, pos);
|
||||
};
|
||||
const up = (ev: PointerEvent): void => {
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', up, true);
|
||||
// Released over the originating ruler band → remove the guide.
|
||||
const overRuler = axis === 'x' ? ev.clientY - rect.top < RULER : ev.clientX - rect.left < RULER;
|
||||
if (overRuler) removeGuide(axis, index);
|
||||
};
|
||||
window.addEventListener('pointermove', move, true);
|
||||
window.addEventListener('pointerup', up, true);
|
||||
};
|
||||
|
||||
const begin = (e: { preventDefault(): void; stopPropagation(): void }, axis: 'x' | 'y', index: number): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startMove(axis, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={layer} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{state.guides.x.map((gx, i) => (
|
||||
<div
|
||||
key={`x${i}`}
|
||||
class="pointer-events-auto absolute bottom-0 top-0 -ml-1 w-2 cursor-ew-resize"
|
||||
style={{ left: `${state.panX + gx * state.zoom}px` }}
|
||||
onPointerdown={(e) => begin(e, 'x', i)}
|
||||
onDblclick={() => removeGuide('x', i)}
|
||||
>
|
||||
<div class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-sky-400/80" />
|
||||
<span class="absolute left-1.5 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">
|
||||
{Math.round(gx)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{state.guides.y.map((gy, i) => (
|
||||
<div
|
||||
key={`y${i}`}
|
||||
class="pointer-events-auto absolute left-0 right-0 -mt-1 h-2 cursor-ns-resize"
|
||||
style={{ top: `${state.panY + gy * state.zoom}px` }}
|
||||
onPointerdown={(e) => begin(e, 'y', i)}
|
||||
onDblclick={() => removeGuide('y', i)}
|
||||
>
|
||||
<div class="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-sky-400/80" />
|
||||
<span class="absolute left-1 top-1.5 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">
|
||||
{Math.round(gy)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { computed } from 'vue';
|
||||
import { state } from '../store';
|
||||
import type { LayoutHighlight, StyleItem } from '../store';
|
||||
import ColorSwatch from './ColorSwatch';
|
||||
|
||||
// Right sidebar. Shows the hovered element's metrics (falling back to the selected one):
|
||||
// dimensions, spacing/radius, typography and colors (resolved to CSS variables when possible).
|
||||
// dimensions, layout (with hover-to-highlight on the canvas), spacing, typography, effects and
|
||||
// colors — inherited values are flagged, and colors resolve to CSS variables when possible.
|
||||
export default function InspectorPanel() {
|
||||
const info = computed(() => state.hover ?? state.selected);
|
||||
|
||||
const highlightFor = (label: string): LayoutHighlight =>
|
||||
label === 'Gap' ? 'gap' : label === 'Columns' || label === 'Rows' ? 'tracks' : 'none';
|
||||
|
||||
return (
|
||||
<aside class="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-white/10 bg-[#0e131c] p-4">
|
||||
<aside class="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-white/10 bg-[#0e131c] p-4 [&::-webkit-scrollbar-thumb]:rounded-md [&::-webkit-scrollbar-thumb]:bg-slate-400/25 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5">
|
||||
{info.value ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -27,6 +32,44 @@ export default function InspectorPanel() {
|
||||
<Row label="Height" value={`${info.value.height}px`} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading text="Layout" />
|
||||
<div class="flex items-baseline justify-between gap-2 py-0.5">
|
||||
<span class="text-[11px] text-slate-400">Display</span>
|
||||
<span
|
||||
class={[
|
||||
'ml-2 truncate rounded px-1.5 font-mono text-[11px]',
|
||||
info.value.layout.kind === 'flex'
|
||||
? 'bg-violet-500/20 text-violet-300'
|
||||
: info.value.layout.kind === 'grid'
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'bg-white/5 text-slate-200',
|
||||
]}
|
||||
>
|
||||
{info.value.layout.display}
|
||||
</span>
|
||||
</div>
|
||||
{info.value.layout.props.map((prop) => (
|
||||
<div
|
||||
key={prop.label}
|
||||
class="-mx-1 rounded px-1 hover:bg-white/5"
|
||||
onMouseenter={() => (state.layoutHighlight = highlightFor(prop.label))}
|
||||
onMouseleave={() => (state.layoutHighlight = 'none')}
|
||||
>
|
||||
<Row label={prop.label} value={prop.value} />
|
||||
</div>
|
||||
))}
|
||||
{info.value.layout.items.length ? (
|
||||
<div
|
||||
class="-mx-1 rounded px-1 hover:bg-white/5"
|
||||
onMouseenter={() => (state.layoutHighlight = 'items')}
|
||||
onMouseleave={() => (state.layoutHighlight = 'none')}
|
||||
>
|
||||
<Row label="Items" value={String(info.value.layout.items.length)} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading text="Spacing" />
|
||||
<Row label="Padding" value={info.value.padding} />
|
||||
@@ -36,12 +79,20 @@ export default function InspectorPanel() {
|
||||
|
||||
<section>
|
||||
<Heading text="Typography" />
|
||||
<Row label="Font" value={info.value.font.family || '—'} />
|
||||
<Row label="Size" value={info.value.font.size} />
|
||||
<Row label="Weight" value={info.value.font.weight} />
|
||||
<Row label="Line" value={info.value.font.lineHeight} />
|
||||
{info.value.typography.map((item) => (
|
||||
<StyleRow key={item.label} item={item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{info.value.effects.length ? (
|
||||
<section>
|
||||
<Heading text="Effects" />
|
||||
{info.value.effects.map((item) => (
|
||||
<StyleRow key={item.label} item={item} />
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{info.value.colors.length ? (
|
||||
<section>
|
||||
<Heading text="Colors" />
|
||||
@@ -70,3 +121,20 @@ function Row(props: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A computed-style row that flags inherited values with an `inh` badge.
|
||||
function StyleRow(props: { item: StyleItem }) {
|
||||
return (
|
||||
<div class="flex items-baseline justify-between gap-2 py-0.5">
|
||||
<span class="flex shrink-0 items-center gap-1 text-[11px] text-slate-400">
|
||||
{props.item.label}
|
||||
{props.item.inherited ? (
|
||||
<span title="Inherited from an ancestor" class="rounded-sm bg-amber-500/15 px-1 text-[9px] text-amber-300">
|
||||
inh
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span class="ml-2 truncate font-mono text-[11px] text-slate-200">{props.item.value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computed } from 'vue';
|
||||
import { state } from '../store';
|
||||
|
||||
// Visualizes the selected element's grid/flex structure on the canvas: track lines with line
|
||||
// numbers, shaded gaps and item outlines. Lives in viewport space and converts iframe-content
|
||||
// pixels via the current pan/zoom (same mapping as the measurement layer). Hovering the Layout
|
||||
// rows in the panel sets `state.layoutHighlight` to emphasize gaps / tracks / items.
|
||||
export default function LayoutOverlay() {
|
||||
const sx = (x: number): number => state.panX + x * state.zoom;
|
||||
const sy = (y: number): number => state.panY + y * state.zoom;
|
||||
const sl = (len: number): number => len * state.zoom;
|
||||
const last = (a: number[] | undefined): number => (a && a.length ? a[a.length - 1]! : 0);
|
||||
|
||||
// Flatten everything to plain, always-present arrays so the template maps without juggling
|
||||
// nullable getters (which neither Vapor's compiler nor TS narrowing handle gracefully).
|
||||
const decor = computed(() => {
|
||||
const layout = state.showGrid && state.selected?.layout.kind !== 'block' ? state.selected?.layout : null;
|
||||
const grid = layout?.kind === 'grid';
|
||||
return {
|
||||
gapTone: grid ? 'rgba(236,72,153,0.30)' : 'rgba(168,85,247,0.30)',
|
||||
gaps: layout?.gaps ?? [],
|
||||
items: layout?.items ?? [],
|
||||
vlines: (layout?.gridLines?.xs ?? []).map((x, i) => ({ x, n: i + 1 })),
|
||||
hlines: (layout?.gridLines?.ys ?? []).map((y, i) => ({ y, n: i + 1 })),
|
||||
x0: layout?.gridLines?.xs[0] ?? 0,
|
||||
x1: last(layout?.gridLines?.xs),
|
||||
y0: layout?.gridLines?.ys[0] ?? 0,
|
||||
y1: last(layout?.gridLines?.ys),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{decor.value.gaps.map((g, i) => (
|
||||
<div
|
||||
key={`gap${i}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(g.x)}px`,
|
||||
top: `${sy(g.y)}px`,
|
||||
width: `${sl(g.width)}px`,
|
||||
height: `${sl(g.height)}px`,
|
||||
background: decor.value.gapTone,
|
||||
outline: state.layoutHighlight === 'gap' ? '1px solid rgba(236,72,153,0.9)' : 'none',
|
||||
opacity: state.layoutHighlight === 'gap' ? 1 : 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{decor.value.items.map((it, i) => (
|
||||
<div
|
||||
key={`item${i}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(it.x)}px`,
|
||||
top: `${sy(it.y)}px`,
|
||||
width: `${sl(it.width)}px`,
|
||||
height: `${sl(it.height)}px`,
|
||||
outline: `1px dashed rgba(56,189,248,${state.layoutHighlight === 'items' ? 0.95 : 0.5})`,
|
||||
outlineOffset: '-1px',
|
||||
background: state.layoutHighlight === 'items' ? 'rgba(56,189,248,0.10)' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{decor.value.vlines.map((l) => (
|
||||
<div
|
||||
key={`vx${l.n}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(l.x)}px`,
|
||||
top: `${sy(decor.value.y0)}px`,
|
||||
width: '1px',
|
||||
height: `${sl(decor.value.y1 - decor.value.y0)}px`,
|
||||
background: state.layoutHighlight === 'tracks' ? 'rgba(56,189,248,0.95)' : 'rgba(56,189,248,0.55)',
|
||||
}}
|
||||
>
|
||||
<span class="absolute -top-4 left-0 rounded-sm bg-sky-500 px-1 text-[9px] font-medium text-white">{l.n}</span>
|
||||
</div>
|
||||
))}
|
||||
{decor.value.hlines.map((l) => (
|
||||
<div
|
||||
key={`hy${l.n}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(decor.value.x0)}px`,
|
||||
top: `${sy(l.y)}px`,
|
||||
width: `${sl(decor.value.x1 - decor.value.x0)}px`,
|
||||
height: '1px',
|
||||
background: state.layoutHighlight === 'tracks' ? 'rgba(56,189,248,0.95)' : 'rgba(56,189,248,0.55)',
|
||||
}}
|
||||
>
|
||||
<span class="absolute -left-4 top-0 -translate-y-1/2 rounded-sm bg-sky-500 px-1 text-[9px] font-medium text-white">
|
||||
{l.n}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { state } from '../store';
|
||||
import type { Box } from '../../utils/rect';
|
||||
|
||||
// Draws the box-model overlay for the hovered element, a persistent outline for the
|
||||
// selected element, and any guides. Lives in viewport space (constant-size badges) and
|
||||
// converts iframe-pixel coordinates to screen coordinates via the current pan/zoom.
|
||||
// Draws the box-model overlay for the hovered element and a persistent outline for the
|
||||
// selected element. Lives in viewport space (constant-size badges) and converts iframe-pixel
|
||||
// coordinates to screen coordinates via the current pan/zoom. (Guides live in GuidesLayer.)
|
||||
export default function MeasureLayer() {
|
||||
const boxStyle = (b: Box) => ({
|
||||
position: 'absolute' as const,
|
||||
@@ -28,25 +28,6 @@ export default function MeasureLayer() {
|
||||
{state.selected ? (
|
||||
<div style={{ ...boxStyle(state.selected.box.border), outline: '2px solid #3b82f6', outlineOffset: '-1px' }} />
|
||||
) : null}
|
||||
|
||||
{state.guides.x.map((gx) => (
|
||||
<div
|
||||
key={`x${gx}`}
|
||||
class="absolute bottom-0 top-0 w-px bg-sky-400/80"
|
||||
style={{ left: `${state.panX + gx * state.zoom}px` }}
|
||||
>
|
||||
<span class="absolute left-1 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">{gx}</span>
|
||||
</div>
|
||||
))}
|
||||
{state.guides.y.map((gy) => (
|
||||
<div
|
||||
key={`y${gy}`}
|
||||
class="absolute left-0 right-0 h-px bg-sky-400/80"
|
||||
style={{ top: `${state.panY + gy * state.zoom}px` }}
|
||||
>
|
||||
<span class="absolute left-1 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">{gy}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { setFrameSize, state } from '../store';
|
||||
import { usePointerDrag } from '../composables';
|
||||
|
||||
type Mode = 'r' | 'b' | 'rb';
|
||||
|
||||
// Drag handles on the right / bottom / corner of the frame to resize it (top-left anchored),
|
||||
// which re-fires the page's media queries inside the iframe. Positioned in viewport space.
|
||||
// which re-fires the page's media queries inside the iframe. Positioned in viewport space —
|
||||
// every coordinate reads reactive store state so the handles track pan/zoom/size live.
|
||||
export default function ResizeHandles() {
|
||||
const startDrag = (e: PointerEvent, mode: Mode): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startW = state.frameWidth;
|
||||
const startH = state.frameHeight;
|
||||
const rightHandle = shallowRef<HTMLDivElement>();
|
||||
const bottomHandle = shallowRef<HTMLDivElement>();
|
||||
const cornerHandle = shallowRef<HTMLDivElement>();
|
||||
|
||||
const move = (ev: PointerEvent): void => {
|
||||
const dw = (ev.clientX - startX) / state.zoom;
|
||||
const dh = (ev.clientY - startY) / state.zoom;
|
||||
setFrameSize(mode === 'b' ? startW : startW + dw, mode === 'r' ? startH : startH + dh);
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', up, true);
|
||||
};
|
||||
window.addEventListener('pointermove', move, true);
|
||||
window.addEventListener('pointerup', up, true);
|
||||
};
|
||||
const right = computed(() => state.panX + state.frameWidth * state.zoom);
|
||||
const bottom = computed(() => state.panY + state.frameHeight * state.zoom);
|
||||
const midX = computed(() => state.panX + (state.frameWidth * state.zoom) / 2);
|
||||
const midY = computed(() => state.panY + (state.frameHeight * state.zoom) / 2);
|
||||
|
||||
const right = state.panX + state.frameWidth * state.zoom;
|
||||
const bottom = state.panY + state.frameHeight * state.zoom;
|
||||
const midX = state.panX + (state.frameWidth * state.zoom) / 2;
|
||||
const midY = state.panY + (state.frameHeight * state.zoom) / 2;
|
||||
bindResize(rightHandle, 'r');
|
||||
bindResize(bottomHandle, 'b');
|
||||
bindResize(cornerHandle, 'rb');
|
||||
|
||||
return (
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div
|
||||
class="pointer-events-auto absolute h-7 w-1.5 -translate-x-1/2 cursor-ew-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
|
||||
style={{ left: `${right}px`, top: `${midY - 14}px` }}
|
||||
onPointerdown={(e) => startDrag(e, 'r')}
|
||||
ref={rightHandle}
|
||||
class="pointer-events-auto absolute h-7 w-1.5 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
|
||||
style={{ left: `${right.value}px`, top: `${midY.value}px` }}
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-auto absolute h-1.5 w-7 -translate-y-1/2 cursor-ns-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
|
||||
style={{ left: `${midX - 14}px`, top: `${bottom}px` }}
|
||||
onPointerdown={(e) => startDrag(e, 'b')}
|
||||
ref={bottomHandle}
|
||||
class="pointer-events-auto absolute h-1.5 w-7 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
|
||||
style={{ left: `${midX.value}px`, top: `${bottom.value}px` }}
|
||||
/>
|
||||
<div
|
||||
ref={cornerHandle}
|
||||
class="pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-sm border border-white/40 bg-sky-500 hover:bg-sky-400"
|
||||
style={{ left: `${right}px`, top: `${bottom}px` }}
|
||||
onPointerdown={(e) => startDrag(e, 'rb')}
|
||||
style={{ left: `${right.value}px`, top: `${bottom.value}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bindResize(handle: ShallowRef<HTMLDivElement | undefined>, mode: Mode): void {
|
||||
let startW = 0;
|
||||
let startH = 0;
|
||||
usePointerDrag(handle, {
|
||||
onStart: (e) => {
|
||||
e.preventDefault();
|
||||
startW = state.frameWidth;
|
||||
startH = state.frameHeight;
|
||||
},
|
||||
onMove: ({ dx, dy }) => {
|
||||
const width = mode === 'b' ? startW : startW + dx / state.zoom;
|
||||
const height = mode === 'r' ? startH : startH + dy / state.zoom;
|
||||
setFrameSize(width, height);
|
||||
},
|
||||
pointerCapture: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
|
||||
import { addGuide, state } from '../store';
|
||||
import { shallowRef, watchEffect } from 'vue';
|
||||
import { addGuide, state, updateGuide } from '../store';
|
||||
import { useDevicePixelRatio, useEventListener } from '../composables';
|
||||
|
||||
const SIZE = 20;
|
||||
const STEPS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000];
|
||||
|
||||
// Zoom/pan-aware rulers drawn on <canvas>. Clicking a ruler drops a guide at that position.
|
||||
// Zoom/pan-aware rulers drawn on <canvas>. Press a ruler and drag to pull out a guide (it
|
||||
// follows the cursor); the guide can then be moved/removed via GuidesLayer. A plain click drops
|
||||
// a guide at that point. Geometry is driven entirely by reactive store state (zoom/pan/viewport)
|
||||
// so the rulers redraw whenever the canvas is panned, zoomed or resized.
|
||||
export default function Rulers() {
|
||||
const topCanvas = ref<HTMLCanvasElement>();
|
||||
const leftCanvas = ref<HTMLCanvasElement>();
|
||||
const topCanvas = shallowRef<HTMLCanvasElement>();
|
||||
const leftCanvas = shallowRef<HTMLCanvasElement>();
|
||||
const { pixelRatio } = useDevicePixelRatio();
|
||||
|
||||
const draw = (): void => {
|
||||
if (topCanvas.value) drawAxis(topCanvas.value, 'x');
|
||||
if (leftCanvas.value) drawAxis(leftCanvas.value, 'y');
|
||||
// `flush: 'post'` so template refs are populated before the first draw.
|
||||
watchEffect(
|
||||
() => {
|
||||
const dpr = pixelRatio.value;
|
||||
const { zoom, panX, panY, viewportW, viewportH } = state;
|
||||
if (topCanvas.value && viewportW > 0) drawAxis(topCanvas.value, 'x', viewportW, zoom, panX, dpr);
|
||||
if (leftCanvas.value && viewportH > 0) drawAxis(leftCanvas.value, 'y', viewportH, zoom, panY, dpr);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
// Pull-out gesture: press a ruler to create a guide, then drag to position it. Bound natively
|
||||
// (not via JSX) so we get real DOM PointerEvents. The guide follows the cursor until release;
|
||||
// moving/removing an existing guide is handled by GuidesLayer.
|
||||
const startCreate = (axis: 'x' | 'y', e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
const originEl = (axis === 'x' ? topCanvas.value : leftCanvas.value)?.getBoundingClientRect();
|
||||
if (!originEl) return;
|
||||
const toPos = (ev: PointerEvent): number =>
|
||||
axis === 'x'
|
||||
? (ev.clientX - originEl.left - state.panX) / state.zoom
|
||||
: (ev.clientY - originEl.top - state.panY) / state.zoom;
|
||||
const index = addGuide(axis, toPos(e));
|
||||
const move = (ev: PointerEvent): void => updateGuide(axis, index, toPos(ev));
|
||||
const up = (): void => {
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', up, true);
|
||||
};
|
||||
window.addEventListener('pointermove', move, true);
|
||||
window.addEventListener('pointerup', up, true);
|
||||
};
|
||||
|
||||
let stop: (() => void) | undefined;
|
||||
onMounted(() => {
|
||||
stop = watchEffect(draw);
|
||||
window.addEventListener('resize', draw);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
stop?.();
|
||||
window.removeEventListener('resize', draw);
|
||||
});
|
||||
|
||||
const onTopClick = (e: MouseEvent): void => {
|
||||
const offset = e.clientX - (topCanvas.value?.getBoundingClientRect().left ?? 0);
|
||||
addGuide('x', (offset - state.panX) / state.zoom);
|
||||
};
|
||||
const onLeftClick = (e: MouseEvent): void => {
|
||||
const offset = e.clientY - (leftCanvas.value?.getBoundingClientRect().top ?? 0);
|
||||
addGuide('y', (offset - state.panY) / state.zoom);
|
||||
};
|
||||
useEventListener(topCanvas, 'pointerdown', (e: PointerEvent) => startCreate('x', e));
|
||||
useEventListener(leftCanvas, 'pointerdown', (e: PointerEvent) => startCreate('y', e));
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={topCanvas}
|
||||
onClick={(e) => onTopClick(e.nativeEvent)}
|
||||
class="absolute left-0 top-0 cursor-crosshair"
|
||||
style={{ height: `${SIZE}px` }}
|
||||
<canvas ref={topCanvas} class="absolute left-0 top-0 cursor-crosshair" style={{ height: `${SIZE}px` }} />
|
||||
<canvas ref={leftCanvas} class="absolute left-0 top-0 cursor-crosshair" style={{ width: `${SIZE}px` }} />
|
||||
<div
|
||||
class="absolute left-0 top-0 border-b border-r border-white/10 bg-[#11151f]"
|
||||
style={{ width: `${SIZE}px`, height: `${SIZE}px` }}
|
||||
/>
|
||||
<canvas
|
||||
ref={leftCanvas}
|
||||
onClick={(e) => onLeftClick(e.nativeEvent)}
|
||||
class="absolute left-0 top-0 cursor-crosshair"
|
||||
style={{ width: `${SIZE}px` }}
|
||||
/>
|
||||
<div class="absolute left-0 top-0 border-b border-r border-white/10 bg-[#11151f]" style={{ width: `${SIZE}px`, height: `${SIZE}px` }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,22 +68,25 @@ function niceStep(zoom: number, minPx: number): number {
|
||||
return STEPS[STEPS.length - 1]!;
|
||||
}
|
||||
|
||||
function drawAxis(canvas: HTMLCanvasElement, axis: 'x' | 'y'): void {
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const length = axis === 'x' ? parent.clientWidth : parent.clientHeight;
|
||||
function drawAxis(
|
||||
canvas: HTMLCanvasElement,
|
||||
axis: 'x' | 'y',
|
||||
length: number,
|
||||
zoom: number,
|
||||
pan: number,
|
||||
dpr: number,
|
||||
): void {
|
||||
const cssW = axis === 'x' ? length : SIZE;
|
||||
const cssH = axis === 'x' ? SIZE : length;
|
||||
|
||||
canvas.width = cssW * dpr;
|
||||
canvas.height = cssH * dpr;
|
||||
canvas.width = Math.max(1, Math.round(cssW * dpr));
|
||||
canvas.height = Math.max(1, Math.round(cssH * dpr));
|
||||
canvas.style.width = `${cssW}px`;
|
||||
canvas.style.height = `${cssH}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
ctx.fillStyle = '#11151f';
|
||||
ctx.fillRect(0, 0, cssW, cssH);
|
||||
@@ -83,13 +95,12 @@ function drawAxis(canvas: HTMLCanvasElement, axis: 'x' | 'y'): void {
|
||||
ctx.font = '9px ui-monospace, monospace';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const pan = axis === 'x' ? state.panX : state.panY;
|
||||
const major = niceStep(state.zoom, 56);
|
||||
const major = niceStep(zoom, 56);
|
||||
const minor = major / (major % 5 === 0 ? 5 : 4);
|
||||
|
||||
const firstValue = Math.floor((0 - pan) / state.zoom / minor) * minor;
|
||||
for (let v = firstValue; pan + v * state.zoom <= length; v += minor) {
|
||||
const pos = pan + v * state.zoom;
|
||||
const firstValue = Math.floor((0 - pan) / zoom / minor) * minor;
|
||||
for (let v = firstValue; pan + v * zoom <= length; v += minor) {
|
||||
const pos = pan + v * zoom;
|
||||
if (pos < 0) continue;
|
||||
const isMajor = Math.abs(v % major) < 0.001;
|
||||
const tick = isMajor ? SIZE * 0.6 : SIZE * 0.3;
|
||||
|
||||
@@ -1,74 +1,69 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { shallowRef, watch } from 'vue';
|
||||
import { recenter, state, zoomAt } from '../store';
|
||||
import { useFrame } from '../composables/useFrame';
|
||||
import { useElementSize, useEventListener, useFrame, usePointerDrag } from '../composables';
|
||||
import LayoutOverlay from './LayoutOverlay';
|
||||
import MeasureLayer from './MeasureLayer';
|
||||
import GuidesLayer from './GuidesLayer';
|
||||
import ResizeHandles from './ResizeHandles';
|
||||
import Rulers from './Rulers';
|
||||
|
||||
// The canvas: a pannable/zoomable viewport holding the device frame (an iframe with the
|
||||
// isolated element), plus the measurement overlay, resize handles and rulers.
|
||||
export default function Stage() {
|
||||
const viewport = ref<HTMLDivElement>();
|
||||
const frame = ref<HTMLIFrameElement>();
|
||||
const viewport = shallowRef<HTMLDivElement>();
|
||||
const frame = shallowRef<HTMLIFrameElement>();
|
||||
useFrame(frame);
|
||||
|
||||
let observer: ResizeObserver | undefined;
|
||||
onMounted(() => {
|
||||
const el = viewport.value;
|
||||
if (!el) return;
|
||||
state.viewportW = el.clientWidth;
|
||||
state.viewportH = el.clientHeight;
|
||||
recenter();
|
||||
observer = new ResizeObserver(() => {
|
||||
state.viewportW = el.clientWidth;
|
||||
state.viewportH = el.clientHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
});
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
// Track the viewport size for centering + ruler geometry; center once it has a real size.
|
||||
const { width, height } = useElementSize(viewport);
|
||||
let centered = false;
|
||||
watch(
|
||||
[width, height],
|
||||
([w, h]) => {
|
||||
state.viewportW = w;
|
||||
state.viewportH = h;
|
||||
if (!centered && w > 0 && h > 0) {
|
||||
centered = true;
|
||||
recenter();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
const rect = viewport.value!.getBoundingClientRect();
|
||||
zoomAt(e.deltaY < 0 ? 1.1 : 0.9, e.clientX - rect.left, e.clientY - rect.top);
|
||||
};
|
||||
// Wheel = zoom about the cursor. Bound natively (not via JSX) so we can opt out of passive
|
||||
// and call preventDefault to stop the page scrolling underneath.
|
||||
useEventListener(
|
||||
viewport,
|
||||
'wheel',
|
||||
(e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = viewport.value!.getBoundingClientRect();
|
||||
zoomAt(e.deltaY < 0 ? 1.1 : 0.9, e.clientX - rect.left, e.clientY - rect.top);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
let panning = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
// Drag the empty background to pan.
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
const onPointerdown = (e: PointerEvent): void => {
|
||||
if (e.target !== viewport.value) return; // only pan on the empty background
|
||||
panning = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
originX = state.panX;
|
||||
originY = state.panY;
|
||||
viewport.value!.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onPointermove = (e: PointerEvent): void => {
|
||||
if (!panning) return;
|
||||
state.panX = originX + (e.clientX - startX);
|
||||
state.panY = originY + (e.clientY - startY);
|
||||
};
|
||||
const onPointerup = (e: PointerEvent): void => {
|
||||
panning = false;
|
||||
try {
|
||||
viewport.value!.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* pointer already released */
|
||||
}
|
||||
};
|
||||
usePointerDrag(viewport, {
|
||||
onStart: (e) => {
|
||||
if (e.target !== viewport.value) return false; // only pan on the empty background
|
||||
e.preventDefault();
|
||||
originX = state.panX;
|
||||
originY = state.panY;
|
||||
},
|
||||
onMove: ({ dx, dy }) => {
|
||||
state.panX = originX + dx;
|
||||
state.panY = originY + dy;
|
||||
},
|
||||
pointerCapture: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewport}
|
||||
class="ei-grid relative min-w-0 flex-1 cursor-grab overflow-hidden active:cursor-grabbing"
|
||||
onWheel={(e) => onWheel(e.nativeEvent)}
|
||||
onPointerdown={onPointerdown}
|
||||
onPointermove={onPointermove}
|
||||
onPointerup={onPointerup}
|
||||
class="relative min-w-0 flex-1 cursor-grab overflow-hidden bg-[#0b0e14] bg-[radial-gradient(circle,rgba(148,163,184,0.12)_1px,transparent_1px)] [background-size:16px_16px] active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-0 origin-top-left"
|
||||
@@ -87,7 +82,9 @@ export default function Stage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LayoutOverlay />
|
||||
<MeasureLayer />
|
||||
<GuidesLayer />
|
||||
<ResizeHandles />
|
||||
{state.showRulers ? <Rulers /> : null}
|
||||
</div>
|
||||
|
||||
@@ -39,15 +39,15 @@ export default function Toolbar() {
|
||||
class="w-16 rounded bg-white/5 px-1.5 py-1 text-center outline-none focus:bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
<ToolButton label="Rotate" onClick={() => { rotateFrame(); recenter(); }} />
|
||||
<ToolButton label="Rotate" action={() => { rotateFrame(); recenter(); }} />
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<ToolButton label="−" onClick={() => setZoom(state.zoom - 0.1)} />
|
||||
<ToolButton label="−" action={() => setZoom(state.zoom - 0.1)} />
|
||||
<span class="w-12 text-center font-mono text-[11px] text-slate-300">{Math.round(state.zoom * 100)}%</span>
|
||||
<ToolButton label="+" onClick={() => setZoom(state.zoom + 0.1)} />
|
||||
<ToolButton label="+" action={() => setZoom(state.zoom + 0.1)} />
|
||||
<ToolButton
|
||||
label="Fit"
|
||||
onClick={() => {
|
||||
action={() => {
|
||||
resetSize();
|
||||
recenter();
|
||||
}}
|
||||
@@ -55,8 +55,19 @@ export default function Toolbar() {
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-white/10" />
|
||||
|
||||
<ToolButton label="Rulers" active={state.showRulers} onClick={() => (state.showRulers = !state.showRulers)} />
|
||||
<ToolButton label="Clear guides" onClick={clearGuides} />
|
||||
<ToolButton label="Rulers" active={state.showRulers} action={() => (state.showRulers = !state.showRulers)} />
|
||||
<ToolButton label="Grid" active={state.showGrid} action={() => (state.showGrid = !state.showGrid)} />
|
||||
<ToolButton
|
||||
label={state.clicksEnabled ? 'Clicks: on' : 'Clicks: off'}
|
||||
active={state.clicksEnabled}
|
||||
title={
|
||||
state.clicksEnabled
|
||||
? 'Clicking the canvas re-selects an element'
|
||||
: 'Clicks are locked — hover to inspect without misclicks'
|
||||
}
|
||||
action={() => (state.clicksEnabled = !state.clicksEnabled)}
|
||||
/>
|
||||
<ToolButton label="Clear guides" action={clearGuides} />
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-white/10" />
|
||||
|
||||
@@ -72,11 +83,16 @@ export default function Toolbar() {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton(props: { label: string; active?: boolean; onClick: () => void }) {
|
||||
// NOTE: the callback prop is `action`, not `onClick`. An `on*`-named prop on a *component* is
|
||||
// treated by Vue as an event-listener binding (it never lands in `props`), so a function-
|
||||
// component would read `props.onClick` as `undefined`. A plain prop name is passed through
|
||||
// normally; we then bind it to the inner DOM button's real `onClick`.
|
||||
function ToolButton(props: { label: string; active?: boolean; title?: string; action: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
onClick={props.action}
|
||||
class={[
|
||||
'rounded px-2 py-1 text-[12px] transition-colors',
|
||||
props.active ? 'bg-sky-600 text-white' : 'bg-white/5 text-slate-300 hover:bg-white/10',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useEventListener } from './useEventListener';
|
||||
export { useElementSize } from './useElementSize';
|
||||
export { useDevicePixelRatio } from './useDevicePixelRatio';
|
||||
export { usePointerDrag } from './usePointerDrag';
|
||||
export type { DragState, PointerDragOptions } from './usePointerDrag';
|
||||
export { useClipboard } from './useClipboard';
|
||||
export { useFrame } from './useFrame';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { onScopeDispose, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard with a transient `copied` flag for UI feedback. Resolves
|
||||
* `false` if the clipboard is unavailable (some pages block it). Mirrors VueUse's
|
||||
* `useClipboard`.
|
||||
*/
|
||||
export function useClipboard(timeout = 1200): {
|
||||
copy: (text: string) => Promise<boolean>;
|
||||
copied: Ref<boolean>;
|
||||
} {
|
||||
const copied = ref(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const copy = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (copied.value = false), timeout);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
return { copy, copied };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { onScopeDispose, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Reactive `window.devicePixelRatio`. Updates when the page is zoomed or moved between
|
||||
* displays of differing density (so canvas-backed UI can stay crisp). Mirrors VueUse's
|
||||
* `useDevicePixelRatio`.
|
||||
*/
|
||||
export function useDevicePixelRatio(): { pixelRatio: Ref<number> } {
|
||||
const pixelRatio = ref(1);
|
||||
let media: MediaQueryList | undefined;
|
||||
|
||||
const update = (): void => {
|
||||
pixelRatio.value = window.devicePixelRatio || 1;
|
||||
media?.removeEventListener('change', update);
|
||||
// A media query only fires for the exact ratio it was created with, so re-arm each change.
|
||||
media = window.matchMedia(`(resolution: ${pixelRatio.value}dppx)`);
|
||||
media.addEventListener('change', update, { once: true });
|
||||
};
|
||||
|
||||
update();
|
||||
onScopeDispose(() => media?.removeEventListener('change', update));
|
||||
return { pixelRatio };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onScopeDispose, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Track an element's content-box size via `ResizeObserver`. Re-observes if the target ref
|
||||
* changes and disconnects on scope dispose. Mirrors VueUse's `useElementSize`.
|
||||
*/
|
||||
export function useElementSize<T extends HTMLElement = HTMLElement>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
): { width: Ref<number>; height: Ref<number> } {
|
||||
const width = ref(0);
|
||||
const height = ref(0);
|
||||
let observer: ResizeObserver | undefined;
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(el) => {
|
||||
observer?.disconnect();
|
||||
observer = undefined;
|
||||
if (!el) return;
|
||||
observer = new ResizeObserver(() => {
|
||||
width.value = el.clientWidth;
|
||||
height.value = el.clientHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
width.value = el.clientWidth;
|
||||
height.value = el.clientHeight;
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopWatch();
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onScopeDispose, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* Attach a DOM event listener that is bound when the (possibly ref/getter) target becomes
|
||||
* available and torn down automatically when the owning scope disposes. Re-binds if the
|
||||
* target changes. Mirrors VueUse's `useEventListener`.
|
||||
*/
|
||||
export function useEventListener<E extends Event = Event, T extends EventTarget = EventTarget>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
type: string,
|
||||
listener: (event: E) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): () => void {
|
||||
let detach = (): void => {};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(el) => {
|
||||
detach();
|
||||
if (!el) return;
|
||||
const handler = listener as EventListener;
|
||||
el.addEventListener(type, handler, options);
|
||||
detach = () => el.removeEventListener(type, handler, options);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
const stop = (): void => {
|
||||
stopWatch();
|
||||
detach();
|
||||
detach = () => {};
|
||||
};
|
||||
|
||||
onScopeDispose(stop);
|
||||
return stop;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { markRaw, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { TARGET_ATTR } from '../../content/capture';
|
||||
import { computeBoxModel } from '../../utils/rect';
|
||||
import type { Box, Edges } from '../../utils/rect';
|
||||
import { colorKey, isTransparent, parseColor, rgbaToHex } from '../../utils/color';
|
||||
import { requestExit, state } from '../store';
|
||||
import type { ColorSwatch, Inspection } from '../store';
|
||||
import type { ColorSwatch, Inspection, LayoutInfo, LayoutProp, StyleItem } from '../store';
|
||||
|
||||
// Owns the canvas iframe: writes the captured srcdoc, then reads layout/styles back out of
|
||||
// the (same-origin) iframe document to drive the inspector overlays.
|
||||
@@ -16,6 +16,12 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
let win: (Window & typeof globalThis) | null = null;
|
||||
let target: Element | null = null;
|
||||
let varMap = new Map<string, string>();
|
||||
let removalObserver: MutationObserver | null = null;
|
||||
// Hover hot-path state: the last element we inspected (skip re-work while the cursor stays on
|
||||
// it) and a pending rAF id (coalesce a burst of mousemove events into one inspect per frame).
|
||||
let lastHover: Element | null = null;
|
||||
let moveRaf = 0;
|
||||
let movePoint = { x: 0, y: 0 };
|
||||
|
||||
const onLoad = (): void => {
|
||||
const frame = frameRef.value;
|
||||
@@ -32,6 +38,13 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
doc.addEventListener('mouseleave', onLeave, true);
|
||||
doc.addEventListener('click', onClick, true);
|
||||
doc.addEventListener('keydown', onKey, true);
|
||||
|
||||
// If the inspected element is removed from the frame document (e.g. a script in the
|
||||
// captured markup tears it down), there is nothing left to inspect — close the overlay.
|
||||
removalObserver = new MutationObserver(() => {
|
||||
if (target && !target.isConnected) requestExit();
|
||||
});
|
||||
removalObserver.observe(doc, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -42,18 +55,35 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
}
|
||||
};
|
||||
|
||||
const flushMove = (): void => {
|
||||
moveRaf = 0;
|
||||
if (!doc) return;
|
||||
const el = doc.elementFromPoint(movePoint.x, movePoint.y);
|
||||
if (!el || el === lastHover) return; // same element under cursor → nothing to recompute
|
||||
lastHover = el;
|
||||
state.hover = inspect(el);
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent): void => {
|
||||
if (state.tool !== 'inspect' || !doc) return;
|
||||
const el = doc.elementFromPoint(e.clientX, e.clientY);
|
||||
if (el) state.hover = inspect(el);
|
||||
// Coalesce a burst of mousemove events into a single inspect on the next frame.
|
||||
movePoint.x = e.clientX;
|
||||
movePoint.y = e.clientY;
|
||||
if (!moveRaf) moveRaf = requestAnimationFrame(flushMove);
|
||||
};
|
||||
|
||||
const onLeave = (): void => {
|
||||
lastHover = null;
|
||||
state.hover = null;
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
if (state.tool !== 'inspect' || !doc) return;
|
||||
if (!doc) return;
|
||||
// Always swallow the click so the captured markup can't navigate (links) or trip an
|
||||
// interactive control while you analyse it. Re-selecting is opt-in (clicks are locked by
|
||||
// default to avoid misclicks); when unlocked, a click re-targets the inspector.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!state.clicksEnabled) return;
|
||||
const el = doc.elementFromPoint(e.clientX, e.clientY);
|
||||
if (el) {
|
||||
target = el;
|
||||
@@ -64,13 +94,16 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
function inspect(el: Element): Inspection {
|
||||
const w = win!;
|
||||
const cs = w.getComputedStyle(el);
|
||||
const parentCs = el.parentElement ? w.getComputedStyle(el.parentElement) : null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const borderBox: Box = { x: r.left, y: r.top, width: r.width, height: r.height };
|
||||
const padding = edges(cs, 'padding', '');
|
||||
const border = edges(cs, 'border', '-width');
|
||||
const margin = edges(cs, 'margin', '');
|
||||
|
||||
return {
|
||||
// The inspection is an immutable snapshot replaced wholesale on each hover/click; mark it
|
||||
// raw so Vue never deep-proxies its nested box-model / arrays (hot path on every mousemove).
|
||||
return markRaw({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id ?? '',
|
||||
classes: typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean) : [],
|
||||
@@ -80,39 +113,117 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
radius: cs.borderRadius || '0px',
|
||||
padding: shorthand(padding),
|
||||
margin: shorthand(margin),
|
||||
font: {
|
||||
family: cs.fontFamily.split(',')[0]?.replace(/["']/g, '').trim() ?? '',
|
||||
size: cs.fontSize,
|
||||
weight: cs.fontWeight,
|
||||
lineHeight: cs.lineHeight,
|
||||
},
|
||||
colors: collectColors(cs),
|
||||
};
|
||||
layout: detectLayout(el, cs, parentCs, r),
|
||||
typography: collectTypography(cs, parentCs),
|
||||
effects: collectEffects(cs),
|
||||
colors: collectColors(el, cs, parentCs),
|
||||
});
|
||||
}
|
||||
|
||||
function collectColors(cs: CSSStyleDeclaration): ColorSwatch[] {
|
||||
// A computed value counts as "inherited" when it's an inheritable property and resolves to
|
||||
// the same thing on the parent — i.e. the element didn't set it itself. A heuristic (a rule
|
||||
// could coincidentally re-set the same value), but a reliable signal in practice.
|
||||
function inheritedFrom(parentCs: CSSStyleDeclaration | null, prop: string, value: string): boolean {
|
||||
return parentCs != null && parentCs.getPropertyValue(prop) === value;
|
||||
}
|
||||
|
||||
function collectColors(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
parentCs: CSSStyleDeclaration | null,
|
||||
): ColorSwatch[] {
|
||||
const swatches: ColorSwatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (label: string, value: string): void => {
|
||||
const add = (label: string, value: string, inheritProp?: string): void => {
|
||||
const color = parseColor(value);
|
||||
if (!color || isTransparent(color)) return;
|
||||
const key = label + colorKey(color);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
swatches.push({ label, color, hex: rgbaToHex(color), varName: varMap.get(colorKey(color)) ?? null });
|
||||
swatches.push({
|
||||
label,
|
||||
color,
|
||||
hex: rgbaToHex(color),
|
||||
varName: varMap.get(colorKey(color)) ?? null,
|
||||
inherited: inheritProp ? inheritedFrom(parentCs, inheritProp, value) : false,
|
||||
});
|
||||
};
|
||||
add('Text', cs.color);
|
||||
add('Text', cs.color, 'color');
|
||||
add('Background', cs.backgroundColor);
|
||||
if (parseFloat(cs.borderTopWidth) > 0) add('Border', cs.borderTopColor);
|
||||
if (parseFloat(cs.outlineWidth) > 0) add('Outline', cs.outlineColor);
|
||||
if (cs.textDecorationLine !== 'none') add('Decoration', cs.textDecorationColor);
|
||||
add('Caret', cs.caretColor, 'caret-color');
|
||||
add('Accent', cs.accentColor, 'accent-color');
|
||||
const shadowColor = firstColor(cs.boxShadow);
|
||||
if (shadowColor) add('Shadow', shadowColor);
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
|
||||
add('Fill', cs.fill, 'fill');
|
||||
add('Stroke', cs.stroke, 'stroke');
|
||||
}
|
||||
return swatches;
|
||||
}
|
||||
|
||||
function collectTypography(cs: CSSStyleDeclaration, parentCs: CSSStyleDeclaration | null): StyleItem[] {
|
||||
const items: StyleItem[] = [];
|
||||
const add = (label: string, prop: string, value = cs.getPropertyValue(prop)): void => {
|
||||
if (!value || value === 'normal' || value === 'none' || value === 'auto') return;
|
||||
items.push({ label, value, inherited: inheritedFrom(parentCs, prop, value) });
|
||||
};
|
||||
items.push({
|
||||
label: 'Font',
|
||||
value: cs.fontFamily.split(',')[0]?.replace(/["']/g, '').trim() || '—',
|
||||
inherited: inheritedFrom(parentCs, 'font-family', cs.fontFamily),
|
||||
});
|
||||
add('Size', 'font-size');
|
||||
add('Weight', 'font-weight');
|
||||
add('Line', 'line-height');
|
||||
add('Letter', 'letter-spacing');
|
||||
add('Align', 'text-align');
|
||||
add('Transform', 'text-transform');
|
||||
add('Style', 'font-style');
|
||||
add('Decoration', 'text-decoration-line');
|
||||
add('Whitespace', 'white-space');
|
||||
return items;
|
||||
}
|
||||
|
||||
function collectEffects(cs: CSSStyleDeclaration): StyleItem[] {
|
||||
const items: StyleItem[] = [];
|
||||
const add = (label: string, value: string, hideWhen: string[]): void => {
|
||||
if (!value || hideWhen.includes(value)) return;
|
||||
items.push({ label, value });
|
||||
};
|
||||
add('Opacity', cs.opacity, ['1']);
|
||||
add('Shadow', cs.boxShadow, ['none']);
|
||||
add('Filter', cs.filter, ['none']);
|
||||
add('Backdrop', cs.backdropFilter || cs.getPropertyValue('-webkit-backdrop-filter'), ['none', '']);
|
||||
add('Blend', cs.mixBlendMode, ['normal']);
|
||||
add('Transform', cs.transform, ['none']);
|
||||
add('Cursor', cs.cursor, ['auto']);
|
||||
return items;
|
||||
}
|
||||
|
||||
const reinspect = (): void => {
|
||||
if (target && win) state.selected = inspect(target);
|
||||
if (!target || !win) return;
|
||||
// A relayout (resize / media query) can drop the element out of flow or hide it; once it
|
||||
// no longer renders a box there is nothing to inspect, so dismiss the overlay.
|
||||
if (!target.isConnected || win.getComputedStyle(target).display === 'none') {
|
||||
requestExit();
|
||||
return;
|
||||
}
|
||||
// Boxes moved — invalidate the hover skip so the next mousemove re-inspects.
|
||||
lastHover = null;
|
||||
state.selected = inspect(target);
|
||||
};
|
||||
|
||||
function teardown(): void {
|
||||
removalObserver?.disconnect();
|
||||
removalObserver = null;
|
||||
if (moveRaf) {
|
||||
cancelAnimationFrame(moveRaf);
|
||||
moveRaf = 0;
|
||||
}
|
||||
lastHover = null;
|
||||
if (!doc) return;
|
||||
doc.removeEventListener('mousemove', onMove, true);
|
||||
doc.removeEventListener('mouseleave', onLeave, true);
|
||||
@@ -185,6 +296,183 @@ function buildVarMap(win: Window, doc: Document): Map<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Summarize the element's layout: its own container model (flex/grid flow + alignment + gap)
|
||||
// and, when it sits inside a flex/grid parent, how it places itself as an item. `parentCs` and
|
||||
// `rect` are passed in from the caller's single computed-style / bounding-rect reads.
|
||||
function detectLayout(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
parentCs: CSSStyleDeclaration | null,
|
||||
rect: DOMRect,
|
||||
): LayoutInfo {
|
||||
const display = cs.display;
|
||||
const isFlex = display === 'flex' || display === 'inline-flex';
|
||||
const isGrid = display === 'grid' || display === 'inline-grid';
|
||||
const props: LayoutProp[] = [];
|
||||
|
||||
if (isFlex) {
|
||||
props.push({ label: 'Direction', value: cs.flexDirection });
|
||||
if (cs.flexWrap !== 'nowrap') props.push({ label: 'Wrap', value: cs.flexWrap });
|
||||
props.push({ label: 'Justify', value: cs.justifyContent });
|
||||
props.push({ label: 'Align', value: cs.alignItems });
|
||||
} else if (isGrid) {
|
||||
props.push({ label: 'Columns', value: summarizeTracks(cs.gridTemplateColumns) });
|
||||
props.push({ label: 'Rows', value: summarizeTracks(cs.gridTemplateRows) });
|
||||
if (cs.gridAutoFlow !== 'row') props.push({ label: 'Auto flow', value: cs.gridAutoFlow });
|
||||
props.push({ label: 'Justify', value: cs.justifyItems });
|
||||
props.push({ label: 'Align', value: cs.alignItems });
|
||||
}
|
||||
if (isFlex || isGrid) {
|
||||
const g = formatGap(cs);
|
||||
if (g) props.push({ label: 'Gap', value: g });
|
||||
}
|
||||
|
||||
// Item placement, when this element is a child of a flex/grid container.
|
||||
if (parentCs) {
|
||||
const pd = parentCs.display;
|
||||
if (PARENT_FLEX.test(pd)) {
|
||||
if (cs.flex !== '0 1 auto') props.push({ label: 'Flex (self)', value: cs.flex });
|
||||
if (cs.alignSelf !== 'auto' && cs.alignSelf !== 'normal') {
|
||||
props.push({ label: 'Align self', value: cs.alignSelf });
|
||||
}
|
||||
} else if (PARENT_GRID.test(pd)) {
|
||||
const area = cs.gridArea;
|
||||
if (area && area !== 'auto' && area !== 'auto / auto / auto / auto') {
|
||||
props.push({ label: 'Grid area', value: area });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = isFlex || isGrid ? captureGeometry(el, cs, isGrid, rect) : EMPTY_GEOMETRY;
|
||||
return { display, kind: isFlex ? 'flex' : isGrid ? 'grid' : 'block', props, ...geometry };
|
||||
}
|
||||
|
||||
const PARENT_FLEX = /(^|-)flex$/;
|
||||
const PARENT_GRID = /(^|-)grid$/;
|
||||
|
||||
const EMPTY_GEOMETRY: Pick<LayoutInfo, 'gridLines' | 'gaps' | 'items'> = {
|
||||
gridLines: null,
|
||||
gaps: [],
|
||||
items: [],
|
||||
};
|
||||
|
||||
// Build the visual-overlay geometry (track lines, gap rects, item boxes) in iframe-content
|
||||
// pixels — the same coordinate space as the box model, so the overlay maps them with the
|
||||
// current pan/zoom exactly like the measurement layer.
|
||||
function captureGeometry(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
isGrid: boolean,
|
||||
rect: DOMRect,
|
||||
): Pick<LayoutInfo, 'gridLines' | 'gaps' | 'items'> {
|
||||
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
||||
const bt = parseFloat(cs.borderTopWidth) || 0;
|
||||
const pl = parseFloat(cs.paddingLeft) || 0;
|
||||
const pt = parseFloat(cs.paddingTop) || 0;
|
||||
const contentX = rect.left + bl + pl;
|
||||
const contentY = rect.top + bt + pt;
|
||||
const contentW = rect.width - bl - (parseFloat(cs.borderRightWidth) || 0) - pl - (parseFloat(cs.paddingRight) || 0);
|
||||
const contentH = rect.height - bt - (parseFloat(cs.borderBottomWidth) || 0) - pt - (parseFloat(cs.paddingBottom) || 0);
|
||||
|
||||
const items: Box[] = [];
|
||||
const children = el.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const cr = children[i]!.getBoundingClientRect();
|
||||
if (cr.width === 0 && cr.height === 0) continue;
|
||||
items.push({ x: cr.left, y: cr.top, width: cr.width, height: cr.height });
|
||||
}
|
||||
|
||||
const gaps: Box[] = [];
|
||||
const colGap = parseFloat(cs.columnGap) || 0;
|
||||
const rowGap = parseFloat(cs.rowGap) || 0;
|
||||
|
||||
if (isGrid) {
|
||||
const cols = parseTracks(cs.gridTemplateColumns);
|
||||
const rows = parseTracks(cs.gridTemplateRows);
|
||||
const xs: number[] = [contentX];
|
||||
let x = contentX;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
x += cols[i]!;
|
||||
if (i < cols.length - 1) {
|
||||
if (colGap > 0) gaps.push({ x, y: contentY, width: colGap, height: contentH });
|
||||
x += colGap;
|
||||
}
|
||||
xs.push(x);
|
||||
}
|
||||
const ys: number[] = [contentY];
|
||||
let y = contentY;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
y += rows[i]!;
|
||||
if (i < rows.length - 1) {
|
||||
if (rowGap > 0) gaps.push({ x: contentX, y, width: contentW, height: rowGap });
|
||||
y += rowGap;
|
||||
}
|
||||
ys.push(y);
|
||||
}
|
||||
return { gridLines: cols.length ? { xs, ys } : null, gaps, items };
|
||||
}
|
||||
|
||||
// Flex: shade the gaps between consecutive items along the main axis (same-line pairs).
|
||||
const isRow = cs.flexDirection.startsWith('row');
|
||||
const gapPx = isRow ? colGap : rowGap;
|
||||
if (gapPx > 0 && items.length > 1) {
|
||||
const sorted = [...items].sort((a, b) => (isRow ? a.x - b.x : a.y - b.y));
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]!;
|
||||
const b = sorted[i + 1]!;
|
||||
if (isRow && b.x - (a.x + a.width) > 0.5 && spans(a.y, a.height, b.y, b.height)) {
|
||||
const start = a.x + a.width;
|
||||
const top = Math.min(a.y, b.y);
|
||||
gaps.push({ x: start, y: top, width: b.x - start, height: Math.max(a.y + a.height, b.y + b.height) - top });
|
||||
} else if (!isRow && b.y - (a.y + a.height) > 0.5 && spans(a.x, a.width, b.x, b.width)) {
|
||||
const start = a.y + a.height;
|
||||
const left = Math.min(a.x, b.x);
|
||||
gaps.push({ x: left, y: start, width: Math.max(a.x + a.width, b.x + b.width) - left, height: b.y - start });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { gridLines: null, gaps, items };
|
||||
}
|
||||
|
||||
// Split on whitespace that is not inside parentheses (e.g. keep `minmax(0, 1fr)` intact).
|
||||
const TRACK_SPLIT = /\s+(?![^(]*\))/;
|
||||
const COLOR_TOKEN = /(rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]{3,8})/i;
|
||||
|
||||
/** Parse a computed grid template (resolved to px) into track sizes; `none` → []. */
|
||||
function parseTracks(value: string): number[] {
|
||||
if (!value || value === 'none') return [];
|
||||
return value
|
||||
.trim()
|
||||
.split(TRACK_SPLIT)
|
||||
.map((t) => parseFloat(t))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
/** Do two 1D ranges [aStart, aStart+aLen] and [bStart, bStart+bLen] overlap? */
|
||||
function spans(aStart: number, aLen: number, bStart: number, bLen: number): boolean {
|
||||
return aStart < bStart + bLen && bStart < aStart + aLen;
|
||||
}
|
||||
|
||||
/** First color token in a CSS value (e.g. the color of a `box-shadow`), or null. */
|
||||
function firstColor(value: string): string | null {
|
||||
if (!value || value === 'none') return null;
|
||||
const m = value.match(COLOR_TOKEN);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
function summarizeTracks(value: string): string {
|
||||
if (!value || value === 'none') return 'none';
|
||||
const count = value.trim().split(TRACK_SPLIT).length;
|
||||
return `${count} × ${value}`;
|
||||
}
|
||||
|
||||
function formatGap(cs: CSSStyleDeclaration): string {
|
||||
const row = cs.rowGap === 'normal' ? '0px' : cs.rowGap;
|
||||
const col = cs.columnGap === 'normal' ? '0px' : cs.columnGap;
|
||||
if (parseFloat(row) === 0 && parseFloat(col) === 0) return '';
|
||||
return row === col ? row : `${row} ${col}`;
|
||||
}
|
||||
|
||||
function edges(cs: CSSStyleDeclaration, prefix: string, suffix: string): Edges {
|
||||
const get = (side: string): number => parseFloat(cs.getPropertyValue(`${prefix}-${side}${suffix}`)) || 0;
|
||||
return { top: get('top'), right: get('right'), bottom: get('bottom'), left: get('left') };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { onScopeDispose, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export interface DragState {
|
||||
/** Total movement since the drag started, in client pixels. */
|
||||
dx: number;
|
||||
dy: number;
|
||||
event: PointerEvent;
|
||||
}
|
||||
|
||||
export interface PointerDragOptions {
|
||||
/** Return `false` to ignore this pointerdown (e.g. wrong target). */
|
||||
onStart?: (event: PointerEvent) => boolean | void;
|
||||
onMove?: (state: DragState) => void;
|
||||
onEnd?: (state: DragState) => void;
|
||||
/** Capture the pointer on the target for the duration of the drag. */
|
||||
pointerCapture?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer-drag gesture on a (ref/getter) target: tracks pointerdown → move → up with
|
||||
* window-level move/up listeners so the drag survives the pointer leaving the element.
|
||||
* Reports cumulative deltas. All listeners are cleaned up on scope dispose.
|
||||
*/
|
||||
export function usePointerDrag<T extends HTMLElement = HTMLElement>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
options: PointerDragOptions,
|
||||
): void {
|
||||
let el: T | null = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let pointerId = -1;
|
||||
let dragging = false;
|
||||
|
||||
const onMove = (event: PointerEvent): void => {
|
||||
if (!dragging) return;
|
||||
options.onMove?.({ dx: event.clientX - startX, dy: event.clientY - startY, event });
|
||||
};
|
||||
|
||||
const onUp = (event: PointerEvent): void => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
if (options.pointerCapture && el) {
|
||||
try {
|
||||
el.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
options.onEnd?.({ dx: event.clientX - startX, dy: event.clientY - startY, event });
|
||||
};
|
||||
|
||||
const onDown = (event: PointerEvent): void => {
|
||||
if (options.onStart?.(event) === false) return;
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
pointerId = event.pointerId;
|
||||
if (options.pointerCapture && el) {
|
||||
try {
|
||||
el.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
/* capture unavailable */
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointermove', onMove, true);
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(next) => {
|
||||
el?.removeEventListener('pointerdown', onDown);
|
||||
el = next ?? null;
|
||||
el?.addEventListener('pointerdown', onDown);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopWatch();
|
||||
el?.removeEventListener('pointerdown', onDown);
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reactive } from 'vue';
|
||||
import type { BoxModel } from '../utils/rect';
|
||||
import type { Box, BoxModel } from '../utils/rect';
|
||||
import type { Rgba } from '../utils/color';
|
||||
import type { Capture } from '../content/capture';
|
||||
|
||||
@@ -8,6 +8,35 @@ export interface ColorSwatch {
|
||||
color: Rgba;
|
||||
hex: string;
|
||||
varName: string | null;
|
||||
/** The value is inherited from an ancestor rather than set on the element itself. */
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
/** A generic computed-style line shown in the panel. */
|
||||
export interface StyleItem {
|
||||
label: string;
|
||||
value: string;
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutProp {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LayoutInfo {
|
||||
/** Computed `display` (e.g. `flex`, `grid`, `block`, `inline-flex`). */
|
||||
display: string;
|
||||
/** Coarse classification used to tint the panel and select an overlay. */
|
||||
kind: 'flex' | 'grid' | 'block';
|
||||
/** Container properties (flow, alignment, gap) plus the element's own item placement. */
|
||||
props: LayoutProp[];
|
||||
/** Grid line positions in iframe-content pixels (for the visual overlay), or null. */
|
||||
gridLines: { xs: number[]; ys: number[] } | null;
|
||||
/** Gap rectangles (between grid tracks / flex items) in iframe-content pixels. */
|
||||
gaps: Box[];
|
||||
/** Child item border boxes in iframe-content pixels. */
|
||||
items: Box[];
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
@@ -21,10 +50,15 @@ export interface Inspection {
|
||||
radius: string;
|
||||
padding: string;
|
||||
margin: string;
|
||||
font: { family: string; size: string; weight: string; lineHeight: string };
|
||||
layout: LayoutInfo;
|
||||
typography: StyleItem[];
|
||||
effects: StyleItem[];
|
||||
colors: ColorSwatch[];
|
||||
}
|
||||
|
||||
/** What the layout overlay should emphasize, driven by hovering panel rows. */
|
||||
export type LayoutHighlight = 'none' | 'gap' | 'tracks' | 'items';
|
||||
|
||||
export interface DevicePreset {
|
||||
label: string;
|
||||
width: number;
|
||||
@@ -53,8 +87,13 @@ interface State {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
tool: 'inspect' | 'guides';
|
||||
showRulers: boolean;
|
||||
/** Visualize the selected element's grid/flex structure on the canvas. */
|
||||
showGrid: boolean;
|
||||
/** When false (default), clicks inside the frame never re-select — avoids misclicks. */
|
||||
clicksEnabled: boolean;
|
||||
/** Panel-driven emphasis for the layout overlay. */
|
||||
layoutHighlight: LayoutHighlight;
|
||||
guides: { x: number[]; y: number[] };
|
||||
hover: Inspection | null;
|
||||
selected: Inspection | null;
|
||||
@@ -72,8 +111,10 @@ export const state = reactive<State>({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
tool: 'inspect',
|
||||
showRulers: true,
|
||||
showGrid: true,
|
||||
clicksEnabled: false,
|
||||
layoutHighlight: 'none',
|
||||
guides: { x: [], y: [] },
|
||||
hover: null,
|
||||
selected: null,
|
||||
@@ -100,7 +141,8 @@ export function initFromCapture(capture: Capture): void {
|
||||
state.frameWidth = Math.max(MIN_FRAME, capture.naturalWidth + 64);
|
||||
state.frameHeight = Math.max(MIN_FRAME, capture.naturalHeight + 64);
|
||||
state.zoom = 1;
|
||||
state.tool = 'inspect';
|
||||
state.clicksEnabled = false;
|
||||
state.layoutHighlight = 'none';
|
||||
state.guides = { x: [], y: [] };
|
||||
state.hover = null;
|
||||
state.selected = null;
|
||||
@@ -148,8 +190,19 @@ export function recenter(): void {
|
||||
if (state.viewportW && state.viewportH) centerIn(state.viewportW, state.viewportH);
|
||||
}
|
||||
|
||||
export function addGuide(axis: 'x' | 'y', position: number): void {
|
||||
state.guides[axis].push(Math.round(position));
|
||||
/** Append a guide on the given axis and return its index (so a drag can keep updating it). */
|
||||
export function addGuide(axis: 'x' | 'y', position: number): number {
|
||||
return state.guides[axis].push(Math.round(position)) - 1;
|
||||
}
|
||||
|
||||
export function updateGuide(axis: 'x' | 'y', index: number, position: number): void {
|
||||
const arr = state.guides[axis];
|
||||
if (index >= 0 && index < arr.length) arr[index] = Math.round(position);
|
||||
}
|
||||
|
||||
export function removeGuide(axis: 'x' | 'y', index: number): void {
|
||||
const arr = state.guides[axis];
|
||||
if (index >= 0 && index < arr.length) arr.splice(index, 1);
|
||||
}
|
||||
|
||||
export function clearGuides(): void {
|
||||
|
||||
@@ -2,22 +2,3 @@
|
||||
|
||||
/* Scan the JSX components for utility classes. */
|
||||
@source '../../**/*.{ts,tsx}';
|
||||
|
||||
/* Dotted canvas background for the stage. */
|
||||
@layer components {
|
||||
.ei-grid {
|
||||
background-color: #0b0e14;
|
||||
background-image: radial-gradient(circle, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact scrollbars inside the overlay. */
|
||||
.ei-root *::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.ei-root *::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
probe
|
||||
.vite-layers
|
||||
*.tgz
|
||||
@@ -0,0 +1,243 @@
|
||||
# vite-layers
|
||||
|
||||
Framework-agnostic **слои в стиле Nuxt**, портированные на чистый Vite: файловые оверрайды через
|
||||
`extends` + мёрж конфигов, плюс побрендовый build-time dead-code elimination. Работает с любым
|
||||
фреймворком — у ядра **нет фреймворк-зависимостей**; `vue()`/`react()`/и т.п. подключаются на уровне слоя.
|
||||
|
||||
Логика стека слоёв (порядок, дедуп, авто-скан, алиасы) портирована напрямую из исходников Nuxt
|
||||
(`@nuxt/kit` `loadNuxtConfig` + `@nuxt/schema`), с тремя осознанными улучшениями.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pnpm add -D vite-layers # peer-зависимость: vite ^8
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
Каждое приложение/бренд — это директория с `app.config.ts` и однострочным `vite.config.ts`:
|
||||
|
||||
```ts
|
||||
// apps/main/app.config.ts
|
||||
import { defineLayerConfig } from 'vite-layers'
|
||||
export default defineLayerConfig({
|
||||
name: 'main',
|
||||
features: { billing: true },
|
||||
vite: { plugins: [vue()] }, // фреймворк-плагин живёт здесь, а не в ядре
|
||||
})
|
||||
|
||||
// apps/brand/app.config.ts — только диффы
|
||||
import { defineLayerConfig } from 'vite-layers'
|
||||
export default defineLayerConfig({
|
||||
name: 'brand',
|
||||
extends: ['../main'],
|
||||
features: { billing: false }, // бренд полностью убирает страницу billing
|
||||
})
|
||||
|
||||
// apps/<любой>/vite.config.ts
|
||||
import { buildViteConfig } from 'vite-layers'
|
||||
export default buildViteConfig(import.meta.dirname)
|
||||
```
|
||||
|
||||
Чтобы перекрыть файл, положите его по тому же относительному пути в слое с более высоким приоритетом
|
||||
(`apps/brand/src/components/Header.vue` затеняет `apps/main/src/components/Header.vue`).
|
||||
|
||||
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
|
||||
|
||||
```ts
|
||||
// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен
|
||||
const routes = [
|
||||
{ path: '/', component: () => import('@/pages/Home') },
|
||||
...(__FEATURES__.billing ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
|
||||
]
|
||||
```
|
||||
|
||||
Тип `__FEATURES__` генерируется из `merged.features` в `.vite-layers/features.d.ts`, поэтому опечатка
|
||||
(`__FEATURES__.biling`) — ошибка компиляции, а не молчком-falsy.
|
||||
|
||||
Флаги вшиваются через `define` и сворачиваются esbuild ещё **до** построения графа Rollup, поэтому
|
||||
выключенная ветка и её `import()` физически не попадают в бандл. Дотированные литералы эмитятся на
|
||||
любую глубину (`__FEATURES__.nested.enabled` тоже сворачивается). Правила, чтобы DCE сработало:
|
||||
|
||||
- обращайтесь **напрямую** — `__FEATURES__.billing`; алиас/деструктуризация (`const f = __FEATURES__; f.billing`)
|
||||
и динамический доступ (`__FEATURES__[name]`) не сворачиваются;
|
||||
- гейт оборачивает сам `import()` (тернарник/`&&`/спред), а не `.filter` после — reachable-импорт не вырезается;
|
||||
- ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект `__FEATURES__`,
|
||||
но без DCE);
|
||||
- в тестах продублируйте `define` (в `vitest.config`) или гардите `globalThis.__FEATURES__ ?? {}`.
|
||||
|
||||
**Dev-режим.** В build флаги работают через `define` (+ DCE). В dev Vite 8 / rolldown-vite **не**
|
||||
инлайнит пользовательский `define` в исходники, поэтому `vite-layers` сам подставляет `__FEATURES__`
|
||||
в рантайме (dev-only плагин). Плюс при изменении любого `app.config.*` слоя dev-сервер
|
||||
**автоматически перезапускается** (`app.config` грузится c12, вне графа Vite — сам он не следит) —
|
||||
так фичи обновляются без ручного рестарта. В шаблонах `.vue` `__FEATURES__` напрямую использовать
|
||||
нельзя — компилятор префиксует его в `_ctx.__FEATURES__` (define/рантайм-подстановка не матчат);
|
||||
читайте флаг в `<script setup>` и используйте в шаблоне локальную переменную.
|
||||
|
||||
## Префиксы импортов
|
||||
|
||||
| Префикс | Куда резолвится | Примечания |
|
||||
|---|---|---|
|
||||
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
||||
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
||||
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
||||
|
||||
## Модель приоритета (из Nuxt)
|
||||
|
||||
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
|
||||
авто-сканированные `layers/*` сортируются по убыванию (`Z` > `A`, выше числовой префикс — выше приоритет).
|
||||
Коллизии решаются как **меньший индекс слоя выигрывает**. Конфиги мёржатся через `defu` (проект
|
||||
выигрывает; массивы конкатятся).
|
||||
|
||||
## Слоёвые `public/`-ассеты (брендинг)
|
||||
|
||||
У каждого слоя может быть своя `public/` — резолвится **first-match по слоям**, как `@/`:
|
||||
`brand/public/logo.svg` затеняет `main/public/logo.svg`, а `favicon.svg` из базы наследуется.
|
||||
Удобно для лого/favicon/шрифтов per-brand. Работает и в dev (отдаётся через sirv по приоритету),
|
||||
и в build (эмитится в `outDir`, верхний слой перетирает нижний). `publicDir` Vite при этом
|
||||
отключается автоматически (он одиночный) — плагин берёт обслуживание на себя.
|
||||
|
||||
```
|
||||
apps/main/public/{logo.svg, favicon.svg} # база
|
||||
apps/brand/public/logo.svg # бренд перекрывает только лого
|
||||
→ dist/brand/{logo.svg = brand, favicon.svg = main}
|
||||
```
|
||||
|
||||
## Env-оверрайды слоёв
|
||||
|
||||
Слой в `app.config.ts` может переопределять себя по Vite `mode` (через c12 `$<env>`/`$env`):
|
||||
|
||||
```ts
|
||||
export default defineLayerConfig({
|
||||
features: { analytics: false },
|
||||
$production: { features: { analytics: true } }, // применится при mode=production
|
||||
})
|
||||
```
|
||||
|
||||
## Опции
|
||||
|
||||
`buildViteConfig(appDir, options?)`:
|
||||
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}` — `GenerateTsConfigOptions`.
|
||||
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
|
||||
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
|
||||
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
|
||||
|
||||
## Хуки жизненного цикла
|
||||
|
||||
Как в Nuxt (на `unjs/hookable`): типизированные, **серийные в порядке слоёв (база первой)**,
|
||||
**mutation-style** (хендлер мутирует общий аргумент). Хуки каждого слоя из `app.config.ts`
|
||||
**накапливаются** (одноимённые из разных слоёв все выполняются), не перетираются.
|
||||
|
||||
```ts
|
||||
export default defineLayerConfig({
|
||||
hooks: {
|
||||
'layers:resolved': (stack) => { stack.merged.features ??= {}; /* править merged/features/layers */ },
|
||||
'vite:config': (ctx) => { ctx.config.plugins?.push(myPlugin()) }, // финальный Vite-конфиг
|
||||
'tsconfig:generate': (ctx) => { ctx.tsconfig.compilerOptions!.strict = true }, // перед записью
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
| Хук | Аргумент | Когда |
|
||||
|---|---|---|
|
||||
| `layers:resolved` | `LayerStack` | после резолва стека (до чтения features/алиасов) |
|
||||
| `vite:config` | `{ config, env, stack }` | финальный Vite-конфиг перед возвратом |
|
||||
| `tsconfig:generate` | `{ appDir, tsconfig, stack }` | сгенерированный tsconfig перед записью |
|
||||
|
||||
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
|
||||
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
|
||||
|
||||
## Улучшения над Nuxt/c12
|
||||
|
||||
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
||||
чтобы дотянуться до базового файла. В Nuxt такого механизма нет.
|
||||
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
||||
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
||||
`resolve`-хуке c12 обрывает рекурсию.
|
||||
3. **Побрендовый DCE** — гейтированные динамические `import()` выпиливаются из бандлов выключенных
|
||||
брендов через дотированные `__FEATURES__.<key>` defines (esbuild сворачивает литерал ещё до того,
|
||||
как Rollup построит граф модулей).
|
||||
|
||||
## TypeScript (автогенерация tsconfig)
|
||||
|
||||
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
|
||||
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` типизирует
|
||||
`__FEATURES__`); `tsconfig.json` приложения его расширяет:
|
||||
|
||||
```jsonc
|
||||
// apps/brand/tsconfig.json
|
||||
{ "extends": "./.vite-layers/tsconfig.json" }
|
||||
// сюда добавляются фреймворк-опции (для Vue: "jsx": "preserve", "jsxImportSource": "vue")
|
||||
```
|
||||
|
||||
Сгенерированный `paths['@/*']`/`['~/*']` — это массив **`srcDir` всех слоёв в порядке приоритета**,
|
||||
поэтому `tsc`/`vue-tsc` резолвит слоёвые импорты по first-existing-file — точно как рантайм-резолвер.
|
||||
`~~`/`@@` → корень проекта; `#layers/<name>` → каждый слой.
|
||||
|
||||
**Два tsconfig — как в Nuxt (app + node).** `tsconfig.json` — для кода приложения (слои, DOM, `paths`);
|
||||
`tsconfig.node.json` — для конфиг-файлов (`vite.config.*`/`app.config.*` всех слоёв) с node-типами,
|
||||
без DOM и без слоёвых `paths`. Проверять оба:
|
||||
|
||||
```bash
|
||||
vue-tsc --noEmit -p apps/brand # код приложения
|
||||
vue-tsc --noEmit -p apps/brand/.vite-layers/tsconfig.node.json # конфиг-файлы (node)
|
||||
```
|
||||
|
||||
**Настройка на уровне слоя** через поле `tsConfig` в `app.config.ts` (это pkg-types
|
||||
[`TSConfig`](https://github.com/unjs/pkg-types)), мёржится по стеку как Nuxt `typescript.tsConfig` —
|
||||
сгенерированные `paths` всегда побеждают:
|
||||
|
||||
```ts
|
||||
// apps/main/app.config.ts
|
||||
export default defineLayerConfig({
|
||||
name: 'main',
|
||||
tsConfig: { compilerOptions: { jsxImportSource: 'vue', strict: true } }, // наследуется брендами
|
||||
})
|
||||
```
|
||||
|
||||
Ручная генерация для CI:
|
||||
|
||||
```bash
|
||||
vite-layers prepare apps/brand # пишет apps/brand/.vite-layers/tsconfig.json
|
||||
vue-tsc --noEmit -p apps/brand # или tsc --noEmit
|
||||
```
|
||||
|
||||
Отключить авто-запись: `buildViteConfig(dir, { tsconfig: false })`. Добавьте `.vite-layers/` в `.gitignore`.
|
||||
|
||||
## API
|
||||
|
||||
- `buildViteConfig(appDir, options?)` — дефолтный экспорт для `vite.config.ts` (резолвер + автоген
|
||||
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
|
||||
- `resolveLayerStack(cwd)` → `{ merged, layers }` — резолвнутый упорядоченный стек.
|
||||
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
|
||||
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
|
||||
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
|
||||
|
||||
## Пример (Vue)
|
||||
|
||||
`example/apps/{main,brand}` — запускаемое Vue-демо. `main` — база (`vue()` + страница `billing`),
|
||||
`brand` расширяет её, перекрывает `AppHeader.vue` и выключает `billing`. Соберите оба и сравните:
|
||||
|
||||
```bash
|
||||
npx vite build example/apps/main # эмитит чанки Home + Billing
|
||||
npx vite build example/apps/brand # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт
|
||||
```
|
||||
|
||||
Что демонстрирует демо:
|
||||
|
||||
- **Оверрайд:** `brand/src/components/AppHeader.vue` затеняет версию из `main` (`@/components/AppHeader.vue`).
|
||||
- **DCE:** `features.billing: false` → динамический `import('@/pages/Billing.vue')` мёртв → чанк не эмитится.
|
||||
- **Алиасы/резолвер:** `main.ts` тянет страницы и компонент через `@/…` сквозь слои.
|
||||
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`), `vue-tsc` зелёный:
|
||||
|
||||
```bash
|
||||
vite-layers prepare example/apps/brand
|
||||
npx vue-tsc --noEmit -p example/apps/brand
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig
|
||||
pnpm type-check
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI for vite-layers. Loads the TypeScript source via jiti (no build step needed).
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
|
||||
const [cmd, appArg] = process.argv.slice(2)
|
||||
|
||||
if (cmd !== 'prepare') {
|
||||
console.error('Usage: vite-layers prepare [appDir]')
|
||||
process.exit(cmd ? 1 : 0)
|
||||
}
|
||||
|
||||
const appDir = resolve(process.cwd(), appArg ?? '.')
|
||||
const { writeTsConfig } = await jiti.import(resolve(here, '../src/tsconfig.ts'))
|
||||
const file = await writeTsConfig(appDir)
|
||||
console.log(`vite-layers: wrote ${file}`)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineLayerConfig } from '../../../src/index.ts'
|
||||
|
||||
export default defineLayerConfig({
|
||||
name: 'brand',
|
||||
extends: ['../main'],
|
||||
features: { billing: false }, // brand drops the billing page entirely (DCE)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-layers — brand</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
BRAND_LOGO_OVERRIDE
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const title = 'BRAND_HEADER_OVERRIDE'
|
||||
const p2p = __FEATURES__.p2p
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>{{ title }}</header>
|
||||
<p v-if="p2p">p2p</p>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Brand has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.vite-layers/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildViteConfig } from '../../../src/index.ts'
|
||||
|
||||
export default buildViteConfig(import.meta.dirname)
|
||||
@@ -0,0 +1,18 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineLayerConfig } from '../../../src/index.ts'
|
||||
|
||||
export default defineLayerConfig({
|
||||
name: 'main',
|
||||
features: { billing: true, p2p: true },
|
||||
// The framework plugin lives in the layer config, not in vite-layers' core.
|
||||
vite: {
|
||||
plugins: [vue()],
|
||||
build: { rolldownOptions: { input: { main: '@/main.ts' } } },
|
||||
},
|
||||
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
|
||||
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue' } },
|
||||
|
||||
$production: {
|
||||
features: { p2p: false },
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-layers — main</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
SHARED_FAVICON
|
||||
@@ -0,0 +1 @@
|
||||
MAIN_LOGO_SVG
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const title = 'MAIN_HEADER'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>{{ title }}</header>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue'
|
||||
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue'))
|
||||
|
||||
// `__FEATURES__` is typed by the generated `.vite-layers/features.d.ts` — no manual `declare` needed.
|
||||
|
||||
// Pages are gated on build-time feature flags: a disabled page's dynamic import() is
|
||||
// statically dead, so its chunk is never emitted (per-brand dead-code elimination).
|
||||
const routes = [
|
||||
{ path: '/', component: () => import('@/pages/Home.vue') },
|
||||
...(__FEATURES__.billing
|
||||
? [{ path: '/billing', component: () => import('@/pages/Billing.vue') }]
|
||||
: []),
|
||||
]
|
||||
|
||||
// A tiny hash router so `routes` (and thus the gated import) is actually reachable.
|
||||
const current = shallowRef<Component | null>(null)
|
||||
async function navigate() {
|
||||
const path = location.hash.slice(1) || '/'
|
||||
const route = routes.find(r => r.path === path) ?? routes[0]
|
||||
current.value = route ? ((await route.component()).default as Component) : null
|
||||
}
|
||||
window.addEventListener('hashchange', navigate)
|
||||
void navigate()
|
||||
|
||||
createApp({
|
||||
render: () => h('div', [h(AppHeader), current.value ? h(current.value) : null]),
|
||||
}).mount('#app')
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main>BILLING_PAGE_HEAVY_MARKER</main>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main>Home page</main>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.vite-layers/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildViteConfig } from '../../../src/index.ts'
|
||||
|
||||
export default buildViteConfig(import.meta.dirname)
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "vite-layers",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Framework-agnostic Nuxt-style layers (extends-based file override + config merge) ported to plain Vite.",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"vite-layers": "./bin/vite-layers.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"example:build": "vite build example/apps/main && vite build example/apps/brand",
|
||||
"example:check": "node bin/vite-layers.mjs prepare example/apps/main && node bin/vite-layers.mjs prepare example/apps/brand && vue-tsc --noEmit -p example/apps/main && vue-tsc --noEmit -p example/apps/main/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/brand && vue-tsc --noEmit -p example/apps/brand/.vite-layers/tsconfig.node.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"c12": "^3.3.4",
|
||||
"defu": "^6.1.4",
|
||||
"hookable": "^6.1.1",
|
||||
"jiti": "^2.4.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"pkg-types": "^2.3.1",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ufo": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.7",
|
||||
"vue": "^3.5.35",
|
||||
"vue-tsc": "^3.3.3"
|
||||
}
|
||||
}
|
||||
Generated
+1187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import { basename, relative, resolve } from 'node:path'
|
||||
import { loadConfig, type ConfigLayer } from 'c12'
|
||||
import { createDefu } from 'defu'
|
||||
import { glob } from 'tinyglobby'
|
||||
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
||||
import type { Layer, LayerConfig, LayerStack } from './types'
|
||||
|
||||
/** Identity helper for typed `app.config.ts` files. */
|
||||
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
|
||||
|
||||
/**
|
||||
* Normalize to forward slashes. c12 returns `cwd` posix-style while node `resolve()` is
|
||||
* OS-native (backslashes on Windows); paths must be canonicalized before they are compared
|
||||
* for dedup or emitted into a Vite config (where posix is conventional).
|
||||
*/
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/**
|
||||
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
|
||||
* (See `@nuxt/kit` `loadNuxtConfig`.)
|
||||
*/
|
||||
const merger = createDefu((obj, key, value) => {
|
||||
const target = obj as Record<PropertyKey, unknown>
|
||||
if (Array.isArray(target[key]) && Array.isArray(value)) {
|
||||
target[key] = (target[key] as unknown[]).concat(value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the full layer stack for an app directory, faithfully porting Nuxt's
|
||||
* `loadNuxtConfig` behavior on top of c12:
|
||||
*
|
||||
* 1. Auto-scan `layers/*` and prepend them (descending sort → "Z>A" / higher numeric prefix wins).
|
||||
* 2. Load + merge the `extends` graph via c12 (`defu`, arrays concatenated, project wins).
|
||||
* 3. Normalize: dedup layers by resolved `rootDir` (first-wins), resolve `srcDir` and layer name.
|
||||
*
|
||||
* Improvement over Nuxt/c12: a cycle-guard in c12's `resolve` hook. Raw c12 neither dedups nor
|
||||
* detects cycles and will stack-overflow on a back-edge (`A→B→A`); Nuxt's own dedup runs only
|
||||
* *after* c12's recursive walk, so it does not prevent the overflow. Returning a terminal empty
|
||||
* layer the second time a source is seen cuts the recursion (returning null/undefined would fall
|
||||
* back to c12's default resolution and still recurse).
|
||||
*
|
||||
* Pass `mode` (typically Vite's `env.mode`) to enable per-layer environment overrides — c12 applies
|
||||
* a layer's `$development`/`$production`/`$env[mode]` block when `mode` matches (Nuxt parity).
|
||||
*
|
||||
* @returns layers ordered high→low priority; `layers[0]` is the project itself.
|
||||
*/
|
||||
export async function resolveLayerStack(
|
||||
cwd: string,
|
||||
opts: { mode?: string } = {},
|
||||
): Promise<LayerStack> {
|
||||
// 1) Auto-scan `layers/*` — descending sort so "Z"/higher numeric prefix wins, like Nuxt.
|
||||
const localLayers = (await glob('layers/*', { onlyDirectories: true, cwd }))
|
||||
.map(d => withTrailingSlash(resolve(cwd, d)))
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
|
||||
const seen = new Set<string>()
|
||||
|
||||
const { config, layers = [] } = await loadConfig<LayerConfig>({
|
||||
cwd,
|
||||
configFile: 'app.config',
|
||||
extend: { extendKey: ['_extends', 'extends'] },
|
||||
overrides: { _extends: localLayers } as LayerConfig,
|
||||
// Per-layer env overrides ($production/$development/$env). Undefined → c12 uses NODE_ENV.
|
||||
// Do NOT set `omit$Keys` — it would strip `$meta`, which we read for layer names below.
|
||||
envName: opts.mode,
|
||||
rcFile: false,
|
||||
packageJson: false,
|
||||
globalRc: false,
|
||||
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
|
||||
resolve(id, opts) {
|
||||
const abs = resolve(opts?.cwd ?? cwd, id)
|
||||
if (seen.has(abs)) return { config: {}, cwd: abs }
|
||||
seen.add(abs)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
// 3) Normalization — dedup by resolved rootDir (first-wins), resolve srcDir + name.
|
||||
const all: ConfigLayer<LayerConfig>[] = layers.length ? layers : [{ config, cwd }]
|
||||
const stack: Layer[] = []
|
||||
const processed = new Set<string>()
|
||||
const localRel = new Set(localLayers.map(l => relative(cwd, withoutTrailingSlash(l))))
|
||||
|
||||
for (const layer of all) {
|
||||
const rawRoot = layer.config?.rootDir ?? layer.cwd
|
||||
if (!rawRoot) continue
|
||||
const rootDir = toPosix(rawRoot)
|
||||
if (processed.has(rootDir)) continue
|
||||
processed.add(rootDir)
|
||||
|
||||
const srcDir = toPosix(resolve(rootDir, layer.config?.srcDir ?? 'src'))
|
||||
let name = layer.config?.$meta?.name ?? layer.config?.name
|
||||
if (!name && layer.cwd && localRel.has(relative(cwd, layer.cwd))) {
|
||||
name = basename(layer.cwd)
|
||||
}
|
||||
|
||||
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
|
||||
}
|
||||
|
||||
return { merged: config, layers: stack }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import MagicString from 'magic-string'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const existingConfigFiles = (rootDirs: string[]): Set<string> => {
|
||||
const files = new Set<string>()
|
||||
for (const dir of rootDirs) {
|
||||
for (const ext of CONFIG_EXTENSIONS) {
|
||||
const file = resolve(dir, `app.config${ext}`)
|
||||
try {
|
||||
if (statSync(file).isFile()) files.add(toPosix(file))
|
||||
} catch {
|
||||
// not present in this layer
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
|
||||
*
|
||||
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
|
||||
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
|
||||
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file
|
||||
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which
|
||||
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`.
|
||||
*/
|
||||
export function configWatchPlugin(rootDirs: string[]): Plugin {
|
||||
return {
|
||||
name: 'vite-layers:config-watch',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
const files = existingConfigFiles(rootDirs)
|
||||
if (files.size === 0) return
|
||||
server.watcher.add([...files]) // ensure extended layers outside the root are watched too
|
||||
|
||||
const onChange = (file: string) => {
|
||||
if (!files.has(toPosix(file))) return
|
||||
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
|
||||
void server.restart()
|
||||
}
|
||||
server.watcher.on('change', onChange)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches a standalone `__FEATURES__` reference (not a `.__FEATURES__` property access). */
|
||||
const STANDALONE_FEATURES_RE = /(?<![.\w$])__FEATURES__\b/
|
||||
|
||||
/**
|
||||
* Dev-only plugin: make `__FEATURES__` resolve at runtime in the dev server.
|
||||
*
|
||||
* Vite 8 / rolldown-vite does **not** inline user `define` into dev-served source modules (only
|
||||
* `import.meta.env` is special-cased), so `__FEATURES__` would be an undefined global in dev. For
|
||||
* production, `define` (with DCE) still does the job; here we prepend a module-local
|
||||
* `const __FEATURES__ = {…}` to each served module that references the global, so feature flags have
|
||||
* correct values in dev — and pick up edits after a config-change restart (see {@link configWatchPlugin}).
|
||||
*
|
||||
* Only standalone references are handled (not `_ctx.__FEATURES__` from Vue templates — same as
|
||||
* `define`); gate features in `<script>`, not in template expressions.
|
||||
*/
|
||||
export function featuresRuntimePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
const json = JSON.stringify(features)
|
||||
return {
|
||||
name: 'vite-layers:features-runtime',
|
||||
apply: 'serve',
|
||||
transform(code, id) {
|
||||
if (id.includes('/node_modules/') || !STANDALONE_FEATURES_RE.test(code)) return null
|
||||
// NOTE: rolldown's *native* magic-string (the transform `meta.magicString` in the rolldown
|
||||
// docs) is NOT surfaced by Vite plugins — `meta` is `{ inMap, moduleType, ssr }` with no
|
||||
// `magicString` in dev or build. So we use the npm `magic-string` fallback the rolldown docs
|
||||
// recommend for non-native hosts; it also produces clean cross-platform sourcemaps.
|
||||
// Prepend on line 1 (keeps line numbers); module-local const shadows the missing global.
|
||||
const s = new MagicString(code)
|
||||
s.prepend(`const __FEATURES__=${json};`)
|
||||
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHooks, type Hookable, type NestedHooks } from 'hookable'
|
||||
import type { TSConfig } from 'pkg-types'
|
||||
import type { ConfigEnv, UserConfig } from 'vite'
|
||||
import type { Layer, LayerStack } from './types'
|
||||
|
||||
/** Hook handlers return nothing (mutation-style) — they may be async. */
|
||||
export type HookResult = void | Promise<void>
|
||||
|
||||
export interface ViteConfigHookContext {
|
||||
/** The fully-assembled Vite config (mutate in place, or replace `.config`). */
|
||||
config: UserConfig
|
||||
env: ConfigEnv
|
||||
stack: LayerStack
|
||||
}
|
||||
|
||||
export interface TsconfigHookContext {
|
||||
appDir: string
|
||||
/** The generated app/client tsconfig (mutate in place). */
|
||||
tsconfig: TSConfig
|
||||
/** The generated node tsconfig for config files (mutate in place). */
|
||||
nodeTsconfig: TSConfig
|
||||
stack: LayerStack
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hooks (powered by `hookable`, like Nuxt). Handlers run **serially in layer order —
|
||||
* base layers first** — and are **mutation-style**: they receive a shared argument and mutate it.
|
||||
*/
|
||||
export interface LayerHooks {
|
||||
/** After the stack is resolved and all hooks are registered. Mutate `stack` (merged/layers/features). */
|
||||
'layers:resolved': (stack: LayerStack) => HookResult
|
||||
/** The final Vite config, just before it is returned from `buildViteConfig`. */
|
||||
'vite:config': (ctx: ViteConfigHookContext) => HookResult
|
||||
/** The generated tsconfig, just before it is written. */
|
||||
'tsconfig:generate': (ctx: TsconfigHookContext) => HookResult
|
||||
}
|
||||
|
||||
/** Declarative hook map accepted in `app.config.ts` (`hooks`) — supports nested/dotted keys. */
|
||||
export type LayerHooksConfig = NestedHooks<LayerHooks>
|
||||
|
||||
export type LayerHookable = Hookable<LayerHooks>
|
||||
|
||||
/** Create an empty hookable instance for the layer lifecycle. */
|
||||
export const createLayerHooks = (): LayerHookable => createHooks<LayerHooks>()
|
||||
|
||||
/**
|
||||
* Register each layer's `hooks` onto the hookable, **base layers first** (so higher-priority
|
||||
* layers' handlers run later), then the programmatic hooks last. Mirrors Nuxt's per-layer
|
||||
* `addHooks` loop: functions can't be deep-merged, so same-name handlers **accumulate** instead of
|
||||
* overwriting.
|
||||
*
|
||||
* @param layers stack layers ordered high→low priority (as returned by `resolveLayerStack`).
|
||||
*/
|
||||
export function registerLayerHooks(
|
||||
hooks: LayerHookable,
|
||||
layers: Pick<Layer, 'config'>[],
|
||||
programmatic?: LayerHooksConfig,
|
||||
): void {
|
||||
for (const layer of [...layers].reverse()) {
|
||||
if (layer.config.hooks) hooks.addHooks(layer.config.hooks)
|
||||
}
|
||||
if (programmatic) hooks.addHooks(programmatic)
|
||||
}
|
||||
|
||||
/** Build a hookable from a stack's layer-declared hooks (used when no shared instance is provided). */
|
||||
export function hooksFromStack(layers: Pick<Layer, 'config'>[]): LayerHookable {
|
||||
const hooks = createLayerHooks()
|
||||
registerLayerHooks(hooks, layers)
|
||||
return hooks
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export { defineLayerConfig, resolveLayerStack } from './config'
|
||||
export { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
||||
export { publicLayersPlugin } from './public'
|
||||
export {
|
||||
createLayerHooks,
|
||||
registerLayerHooks,
|
||||
hooksFromStack,
|
||||
type HookResult,
|
||||
type LayerHookable,
|
||||
type LayerHooks,
|
||||
type LayerHooksConfig,
|
||||
type TsconfigHookContext,
|
||||
type ViteConfigHookContext,
|
||||
} from './hooks'
|
||||
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve'
|
||||
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
|
||||
export {
|
||||
generateTsConfig,
|
||||
writeTsConfig,
|
||||
tsconfigPlugin,
|
||||
featuresDts,
|
||||
type GenerateTsConfigOptions,
|
||||
type TSConfig,
|
||||
} from './tsconfig'
|
||||
export type { Layer, LayerConfig, LayerStack } from './types'
|
||||
@@ -0,0 +1,153 @@
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
|
||||
import { resolveLayerStack } from './config'
|
||||
import { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
||||
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
|
||||
import { publicLayersPlugin } from './public'
|
||||
import { layersResolver } from './resolve'
|
||||
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
|
||||
|
||||
export interface BuildViteConfigOptions {
|
||||
/** Extra Vite config merged at the very end (highest priority). */
|
||||
vite?: UserConfig
|
||||
/** Output directory. Default: `dist/<basename(appDir)>`. */
|
||||
outDir?: string
|
||||
/**
|
||||
* Auto-generate `.vite-layers/tsconfig.json` on config resolution (dev + build).
|
||||
* Pass options to customize, or `false` to disable. Default: enabled.
|
||||
*/
|
||||
tsconfig?: GenerateTsConfigOptions | false
|
||||
/** Override the layered resolver's import prefixes / probed extensions. */
|
||||
resolver?: { prefixes?: string[]; extensions?: string[] }
|
||||
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
|
||||
hooks?: LayerHooksConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* `mergeConfig` concatenates arrays — including `plugins` — so a plugin added by several
|
||||
* layers (e.g. a framework plugin in the base and re-declared in a brand) ends up duplicated.
|
||||
* Dedupe by `plugin.name`, keeping the highest-priority (last-merged) instance in original order.
|
||||
*/
|
||||
function dedupePlugins(config: UserConfig): UserConfig {
|
||||
if (!Array.isArray(config.plugins)) return config
|
||||
const flat = (config.plugins as PluginOption[]).flat(Infinity as 1)
|
||||
const indexByName = new Map<string, number>()
|
||||
const out: PluginOption[] = []
|
||||
for (const p of flat) {
|
||||
const name = p && typeof p === 'object' && 'name' in p ? (p as { name?: unknown }).name : undefined
|
||||
if (typeof name === 'string' && indexByName.has(name)) {
|
||||
out[indexByName.get(name)!] = p // keep position, take later (higher-priority) instance
|
||||
continue
|
||||
}
|
||||
if (typeof name === 'string') indexByName.set(name, out.length)
|
||||
out.push(p)
|
||||
}
|
||||
return { ...config, plugins: out }
|
||||
}
|
||||
|
||||
/** A member-expression define key segment must be a plain JS identifier. */
|
||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
/**
|
||||
* Build the `define` map for feature flags. Emits the whole `__FEATURES__` object (for runtime
|
||||
* reads) plus a dotted entry for **every nested path** whose segments are valid identifiers
|
||||
* (`__FEATURES__.billing`, `__FEATURES__.nested.enabled`, …).
|
||||
*
|
||||
* The dotted entries are what make dead-code elimination work: esbuild folds a replaced literal
|
||||
* (`false ? import('…') : []` → `[]`) and drops the dynamic import *before* Rollup builds the
|
||||
* module graph, so the page's chunk is never emitted. A member access on an object literal
|
||||
* (`{"enabled":false}.enabled`) is NOT folded, so the object form alone does not DCE — which is why
|
||||
* we walk recursively and emit a literal at every depth.
|
||||
*
|
||||
* Keys that are not valid identifiers (e.g. `'kebab-flag'`) are skipped rather than emitted: a
|
||||
* dotted define with such a segment is an `INVALID_DEFINE_CONFIG` build error, and you cannot fold
|
||||
* a bracket access anyway. The key still lives inside the whole-object `__FEATURES__` for runtime.
|
||||
*/
|
||||
function featureDefines(features: Record<string, unknown> = {}): Record<string, string> {
|
||||
const define: Record<string, string> = { __FEATURES__: JSON.stringify(features) }
|
||||
const walk = (obj: Record<string, unknown>, prefix: string) => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (!IDENTIFIER_RE.test(key)) continue
|
||||
const path = `${prefix}.${key}`
|
||||
define[path] = JSON.stringify(value)
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
walk(value as Record<string, unknown>, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(features, '__FEATURES__')
|
||||
return define
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
|
||||
*
|
||||
* ```ts
|
||||
* export default buildViteConfig(import.meta.dirname)
|
||||
* ```
|
||||
*
|
||||
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
|
||||
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins).
|
||||
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases.
|
||||
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination.
|
||||
*/
|
||||
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
|
||||
return defineConfig(async (env) => {
|
||||
const stack = await resolveLayerStack(appDir, { mode: env.mode })
|
||||
|
||||
// Hooks: register each layer's `hooks` (base-first) + programmatic, then let `layers:resolved`
|
||||
// mutate the stack (merged config / features / layers) before anything reads it.
|
||||
const hooks = createLayerHooks()
|
||||
registerLayerHooks(hooks, stack.layers, options.hooks)
|
||||
await hooks.callHook('layers:resolved', stack)
|
||||
|
||||
const { merged, layers } = stack
|
||||
const roots = layers.map(l => l.srcDir)
|
||||
|
||||
const project = layers[0]! // resolveLayerStack always returns at least the project layer
|
||||
const alias: Record<string, string> = {
|
||||
'~~': project.rootDir,
|
||||
'@@': project.rootDir,
|
||||
}
|
||||
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
|
||||
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
|
||||
|
||||
let vite: UserConfig = {
|
||||
resolve: { alias },
|
||||
build: { outDir: options.outDir ?? `dist/${basename(appDir)}` },
|
||||
}
|
||||
|
||||
// Layer fragments: low → high so higher-priority layers override.
|
||||
for (const l of [...layers].reverse()) {
|
||||
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
|
||||
if (frag) vite = mergeConfig(vite, frag)
|
||||
}
|
||||
vite = dedupePlugins(vite)
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
layersResolver({ roots, ...options.resolver }),
|
||||
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
|
||||
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
|
||||
featuresRuntimePlugin(merged.features), // dev: supply __FEATURES__ at runtime (define is build-only here)
|
||||
]
|
||||
if (options.tsconfig !== false) {
|
||||
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
|
||||
// and `tsconfig:generate` sees the same handlers).
|
||||
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
|
||||
}
|
||||
|
||||
vite = mergeConfig(vite, {
|
||||
plugins,
|
||||
define: featureDefines(merged.features),
|
||||
})
|
||||
|
||||
if (options.vite) vite = mergeConfig(vite, options.vite)
|
||||
|
||||
// Final escape hatch: let hooks mutate (or replace) the assembled Vite config.
|
||||
const ctx = { config: vite, env, stack }
|
||||
await hooks.callHook('vite:config', ctx)
|
||||
return ctx.config
|
||||
})
|
||||
}
|
||||
|
||||
export { dedupePlugins }
|
||||
@@ -0,0 +1,54 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import sirv from 'sirv'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/** Recursively list files under a directory (absolute paths). */
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const abs = join(dir, name)
|
||||
if (statSync(abs).isDirectory()) walk(abs, out)
|
||||
else out.push(abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across
|
||||
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
|
||||
*
|
||||
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
|
||||
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
|
||||
* and emits the merged set into the build output (higher layers overwrite lower ones).
|
||||
*
|
||||
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
|
||||
*/
|
||||
export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
||||
const dirs = publicDirs.filter(existsSync) // high → low
|
||||
|
||||
return {
|
||||
name: 'vite-layers:public',
|
||||
config() {
|
||||
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
|
||||
if (dirs.length > 0) return { publicDir: false }
|
||||
},
|
||||
configureServer(server) {
|
||||
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
|
||||
for (const dir of dirs) {
|
||||
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
|
||||
}
|
||||
},
|
||||
generateBundle() {
|
||||
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority.
|
||||
const assets = new Map<string, string>()
|
||||
for (const dir of [...dirs].reverse()) {
|
||||
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
|
||||
}
|
||||
for (const [fileName, abs] of assets) {
|
||||
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
||||
|
||||
export interface LayersResolverOptions {
|
||||
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
|
||||
roots: string[]
|
||||
/** Import prefixes treated as layered. Default: `@/`, `~/`. */
|
||||
prefixes?: string[]
|
||||
/** Extensions probed when the id has no explicit, existing file. */
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const isFile = (p: string): boolean => {
|
||||
try {
|
||||
return statSync(p).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
|
||||
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
|
||||
* it probes each source root in priority order and returns the first match.
|
||||
*
|
||||
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
|
||||
* then `<path>/index<ext>`.
|
||||
*
|
||||
* Improvement over Nuxt: **self-skip** gives `super()` semantics. If the first match is the
|
||||
* importing file itself, resolution continues to the next (lower-priority) layer — so an
|
||||
* override at `@/components/Foo.vue` can import `@/components/Foo.vue` to reach the base file.
|
||||
*/
|
||||
export function layersResolver(options: LayersResolverOptions): Plugin {
|
||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
|
||||
|
||||
const probe = (root: string, sub: string): string | null => {
|
||||
const direct = resolve(root, sub)
|
||||
if (isFile(direct)) return direct
|
||||
for (const ext of extensions) {
|
||||
const p = direct + ext
|
||||
if (isFile(p)) return p
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const p = resolve(direct, `index${ext}`)
|
||||
if (isFile(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
|
||||
// (high→low priority). Saves the per-import `statSync` storm; self-skip stays correct because the
|
||||
// candidate list is importer-independent (we pick the first candidate that isn't the importer).
|
||||
const cache = new Map<string, string[]>()
|
||||
const candidates = (sub: string): string[] => {
|
||||
const cached = cache.get(sub)
|
||||
if (cached) return cached
|
||||
const list: string[] = []
|
||||
for (const root of roots) {
|
||||
const file = probe(root, sub)
|
||||
if (file) list.push(toPosix(file))
|
||||
}
|
||||
cache.set(sub, list)
|
||||
return list
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-layers:resolve',
|
||||
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
|
||||
configureServer(server) {
|
||||
// A new/removed file can change which layer wins → drop the cache in dev.
|
||||
const clear = () => cache.clear()
|
||||
server.watcher.on('add', clear)
|
||||
server.watcher.on('unlink', clear)
|
||||
server.watcher.on('unlinkDir', clear)
|
||||
},
|
||||
resolveId(id, importer) {
|
||||
const prefix = prefixes.find(p => id.startsWith(p))
|
||||
if (!prefix) return null
|
||||
|
||||
const q = id.indexOf('?')
|
||||
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
|
||||
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
|
||||
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
|
||||
|
||||
for (const file of candidates(sub)) {
|
||||
if (file === self) continue // self-skip → fall through to the base layer (super())
|
||||
return file + query
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { defu } from 'defu'
|
||||
import { type TSConfig, writeTSConfig } from 'pkg-types'
|
||||
import type { Plugin } from 'vite'
|
||||
import { resolveLayerStack } from './config'
|
||||
import { hooksFromStack, type LayerHookable } from './hooks'
|
||||
import type { LayerStack } from './types'
|
||||
|
||||
export type { TSConfig } from 'pkg-types'
|
||||
|
||||
export interface GenerateTsConfigOptions {
|
||||
/**
|
||||
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
|
||||
* wins). Does NOT override the generated `paths`, which always reflect the resolved layer stack.
|
||||
*/
|
||||
tsConfig?: TSConfig
|
||||
/** Extra tsconfig merged over the generated **node** config (for config files). */
|
||||
nodeTsConfig?: TSConfig
|
||||
/** Directory to write into, relative to `appDir`. Default: `.vite-layers`. */
|
||||
outDir?: string
|
||||
/** Reuse an already-resolved stack (avoids a second `resolveLayerStack` per build). Internal. */
|
||||
stack?: LayerStack
|
||||
/** Shared hooks instance; if absent, one is built from the stack's layer hooks. Internal. */
|
||||
hooks?: LayerHookable
|
||||
}
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
|
||||
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(/^([^.])/, './$1') || '.'
|
||||
|
||||
/** A property name that can be written unquoted in a TS type literal. */
|
||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
/** Render a JSON-ish value as a TS type literal (boolean/number/string → type, object → recurse). */
|
||||
function tsType(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'readonly unknown[]'
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
return 'boolean'
|
||||
case 'number':
|
||||
return 'number'
|
||||
case 'string':
|
||||
return 'string'
|
||||
case 'object': {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length === 0) return 'Record<string, never>'
|
||||
const body = entries
|
||||
.map(([k, v]) => `${IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)}: ${tsType(v)}`)
|
||||
.join('; ')
|
||||
return `{ ${body} }`
|
||||
}
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a `.d.ts` that types the `__FEATURES__` global from the merged feature flags, so a typo
|
||||
* (`__FEATURES__.biling`) is a compile error instead of a silently-falsy runtime value.
|
||||
*/
|
||||
export function featuresDts(features: Record<string, unknown> = {}): string {
|
||||
return [
|
||||
'// AUTO-GENERATED by vite-layers — do not edit.',
|
||||
'export {}',
|
||||
'declare global {',
|
||||
` const __FEATURES__: ${tsType(features)}`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
|
||||
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
||||
target: 'ESNext',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
verbatimModuleSyntax: true,
|
||||
strict: true,
|
||||
noUncheckedIndexedAccess: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
allowImportingTsExtensions: true,
|
||||
noEmit: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults for the node-environment config (`vite.config`/`app.config`): node-side, **no DOM lib**,
|
||||
* **no layered `paths`** (config files don't use `@/`). Mirrors Nuxt's `tsconfig.node.json`.
|
||||
*/
|
||||
const NODE_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
||||
...DEFAULT_COMPILER_OPTIONS,
|
||||
lib: ['ESNext'],
|
||||
paths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the auto-generated tsconfig for an app's layer stack — a framework-agnostic port of Nuxt's
|
||||
* `_generateTypes` (`@nuxt/kit` `packages/kit/src/template.ts`).
|
||||
*
|
||||
* The defining difference: because `@/` and `~/` are *layered* here (first-match across every
|
||||
* layer's `srcDir`, see {@link layersResolver}), `paths['@/*']` is the array of ALL layer srcDirs in
|
||||
* priority order. TypeScript resolves path arrays by first existing file, so `tsc` mirrors the
|
||||
* runtime resolver exactly. (No `baseUrl` — deprecated in TS 6; since TS 5.0 `paths` resolve
|
||||
* relative to the config that defines them, so a consuming tsconfig that `extends` this one
|
||||
* resolves the relative paths from here.)
|
||||
*
|
||||
* Customize via each layer's `app.config.ts` `tsConfig` field (merged across the stack, like Nuxt's
|
||||
* `typescript.tsConfig`) and/or `opts.tsConfig` (highest priority). Both are typed as pkg-types
|
||||
* {@link TSConfig}.
|
||||
*/
|
||||
export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOptions = {}) {
|
||||
const stack = opts.stack ?? (await resolveLayerStack(appDir))
|
||||
const { merged, layers } = stack
|
||||
const genDir = resolve(appDir, opts.outDir ?? '.vite-layers')
|
||||
|
||||
const srcStar = layers.map(l => `${rel(genDir, l.srcDir)}/*`) // [high … low]
|
||||
const projectRoot = layers[0]!.rootDir
|
||||
const paths: Record<string, string[]> = {
|
||||
'@/*': srcStar,
|
||||
'~/*': srcStar,
|
||||
'~~': [rel(genDir, projectRoot)],
|
||||
'@@': [rel(genDir, projectRoot)],
|
||||
'~~/*': [`${rel(genDir, projectRoot)}/*`],
|
||||
'@@/*': [`${rel(genDir, projectRoot)}/*`],
|
||||
}
|
||||
for (const l of layers) {
|
||||
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
||||
const star = `#layers/${l.name}/*`
|
||||
if (star in paths) continue
|
||||
paths[`#layers/${l.name}`] = [rel(genDir, l.rootDir)]
|
||||
paths[star] = [`${rel(genDir, l.rootDir)}/*`]
|
||||
}
|
||||
|
||||
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
|
||||
|
||||
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here —
|
||||
// they belong to the node config below.
|
||||
const base: TSConfig = {
|
||||
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
|
||||
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
|
||||
exclude,
|
||||
}
|
||||
|
||||
// Precedence (defu, first wins): opts.tsConfig → per-layer merged.tsConfig → generated defaults.
|
||||
// `paths` is applied last — it is generated, not overridable.
|
||||
const tsconfig = defu(opts.tsConfig, merged.tsConfig, base) as TSConfig
|
||||
tsconfig.compilerOptions = { ...tsconfig.compilerOptions, paths }
|
||||
|
||||
// Node config: `vite.config`/`app.config` of every layer, node-side typings, no DOM, no paths.
|
||||
const nodeBase: TSConfig = {
|
||||
compilerOptions: { ...NODE_COMPILER_OPTIONS },
|
||||
include: layers.flatMap((l) => {
|
||||
const r = rel(genDir, l.rootDir)
|
||||
return [`${r}/app.config.*`, `${r}/vite.config.*`]
|
||||
}),
|
||||
exclude,
|
||||
}
|
||||
const nodeTsconfig = defu(opts.nodeTsConfig, nodeBase) as TSConfig
|
||||
|
||||
// Escape hatch: let layer/programmatic hooks mutate the generated tsconfigs before they're written.
|
||||
const ctx = { appDir, tsconfig, nodeTsconfig, stack }
|
||||
await (opts.hooks ?? hooksFromStack(layers)).callHook('tsconfig:generate', ctx)
|
||||
|
||||
return {
|
||||
tsconfig: ctx.tsconfig, // a hook may have mutated or replaced it
|
||||
file: resolve(genDir, 'tsconfig.json'),
|
||||
nodeTsconfig: ctx.nodeTsconfig,
|
||||
nodeFile: resolve(genDir, 'tsconfig.node.json'),
|
||||
genDir,
|
||||
dts: featuresDts(merged.features),
|
||||
dtsFile: resolve(genDir, 'features.d.ts'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types
|
||||
* `writeTSConfig`). Returns the tsconfig path.
|
||||
*/
|
||||
export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise<string> {
|
||||
const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile } = await generateTsConfig(appDir, opts)
|
||||
await mkdir(genDir, { recursive: true })
|
||||
await Promise.all([
|
||||
writeTSConfig(file, tsconfig),
|
||||
writeTSConfig(nodeFile, nodeTsconfig),
|
||||
writeFile(dtsFile, dts),
|
||||
])
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that writes the generated tsconfig on `configResolved` (dev + build) — the
|
||||
* framework-agnostic analogue of Nuxt's automatic `prepare:types`.
|
||||
*/
|
||||
export function tsconfigPlugin(appDir: string, opts?: GenerateTsConfigOptions): Plugin {
|
||||
return {
|
||||
name: 'vite-layers:tsconfig',
|
||||
async configResolved() {
|
||||
await writeTsConfig(appDir, opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TSConfig } from 'pkg-types'
|
||||
import type { ConfigEnv, UserConfig } from 'vite'
|
||||
import type { LayerHooksConfig } from './hooks'
|
||||
|
||||
/**
|
||||
* A layer's declarative config, authored in `app.config.ts`.
|
||||
* Mirrors the subset of Nuxt's layer config relevant to a framework-agnostic build.
|
||||
*/
|
||||
export interface LayerConfig {
|
||||
/** Explicit layer name; used for the `#layers/<name>` alias. Falls back to the dir basename. */
|
||||
name?: string
|
||||
/** Absolute/relative root dir of the layer. Defaults to the layer's own directory. */
|
||||
rootDir?: string
|
||||
/** Source dir, resolved against `rootDir`. Default: `'src'`. */
|
||||
srcDir?: string
|
||||
/** Layers to extend: relative path, npm package, or git source (resolved by c12). */
|
||||
extends?: string | string[]
|
||||
/** Vite config fragment contributed by this layer (object or env-aware factory). */
|
||||
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
|
||||
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */
|
||||
features?: Record<string, unknown>
|
||||
/**
|
||||
* tsconfig overrides contributed by this layer, merged across the stack into the generated
|
||||
* `.vite-layers/tsconfig.json` (analogue of Nuxt's `typescript.tsConfig`). The generated
|
||||
* `paths` always win. Typed as pkg-types {@link TSConfig}.
|
||||
*/
|
||||
tsConfig?: TSConfig
|
||||
/**
|
||||
* Lifecycle hooks (hookable). Accumulated across layers (base-first), not deep-merged — so
|
||||
* same-name handlers from multiple layers all run. See {@link LayerHooks}.
|
||||
*/
|
||||
hooks?: LayerHooksConfig
|
||||
/** c12 layer metadata; `$meta.name` takes precedence when deriving the layer name. */
|
||||
$meta?: { name?: string }
|
||||
/** Overrides applied when the resolved env (Vite `mode`) is `development`. */
|
||||
$development?: Partial<LayerConfig>
|
||||
/** Overrides applied when the resolved env (Vite `mode`) is `production`. */
|
||||
$production?: Partial<LayerConfig>
|
||||
/** Overrides keyed by env name (Vite `mode`), e.g. `{ staging: { features: {…} } }`. */
|
||||
$env?: Record<string, Partial<LayerConfig>>
|
||||
}
|
||||
|
||||
/** A fully resolved layer in the stack. */
|
||||
export interface Layer {
|
||||
/** Absolute root directory of the layer. */
|
||||
rootDir: string
|
||||
/** Absolute source directory (`rootDir`/`srcDir`). */
|
||||
srcDir: string
|
||||
/** Resolved layer name. */
|
||||
name: string
|
||||
/** The layer's own (unmerged) config. */
|
||||
config: LayerConfig
|
||||
}
|
||||
|
||||
export interface LayerStack {
|
||||
/** Deep-merged config across the whole stack (defu, project wins). */
|
||||
merged: LayerConfig
|
||||
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
|
||||
layers: Layer[]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveLayerStack } from '../src/config'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
const names = (s: { layers: { name: string }[] }) => s.layers.map(l => l.name)
|
||||
|
||||
describe('resolveLayerStack', () => {
|
||||
it('orders the stack project-first (layers[0] = project), then extends depth-first', async () => {
|
||||
const stack = await resolveLayerStack(fixture('stack/app'))
|
||||
expect(names(stack)).toEqual(['app', 'base', 'core'])
|
||||
expect(stack.layers[0].name).toBe('app')
|
||||
})
|
||||
|
||||
it('merges configs with project winning on key collision (defu first-wins)', async () => {
|
||||
const { merged } = await resolveLayerStack(fixture('stack/app'))
|
||||
const features = merged.features as Record<string, unknown>
|
||||
expect(features.shared).toBe('app') // app overrides base overrides core
|
||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
||||
})
|
||||
|
||||
it('resolves srcDir per layer (default "src")', async () => {
|
||||
const stack = await resolveLayerStack(fixture('stack/app'))
|
||||
expect(stack.layers[0].srcDir).toBe(toPosix(resolve(fixture('stack/app'), 'src')))
|
||||
})
|
||||
|
||||
it('dedupes a diamond by rootDir (shared base appears once, first-wins position)', async () => {
|
||||
const stack = await resolveLayerStack(fixture('diamond/app'))
|
||||
expect(names(stack)).toEqual(['app', 'b', 'd', 'c'])
|
||||
expect(names(stack).filter(n => n === 'd')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('survives a cycle (A→B→A) without stack overflow [improvement over raw c12]', async () => {
|
||||
const stack = await resolveLayerStack(fixture('cycle/x'))
|
||||
expect(names(stack)).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
it('auto-scans layers/* with descending priority (Z > A / higher numeric prefix)', async () => {
|
||||
const stack = await resolveLayerStack(fixture('autoscan'))
|
||||
// project first, then 2.z-layer before 1.a-layer (descending sort)
|
||||
expect(names(stack)).toEqual(['root', '2.z-layer', '1.a-layer'])
|
||||
})
|
||||
|
||||
it('applies per-layer $production/$development overrides by Vite mode', async () => {
|
||||
const dev = await resolveLayerStack(fixture('env/app'), { mode: 'development' })
|
||||
const prod = await resolveLayerStack(fixture('env/app'), { mode: 'production' })
|
||||
expect((dev.merged.features as Record<string, unknown>).flag).toBe('dev') // no $development block
|
||||
expect((prod.merged.features as Record<string, unknown>).flag).toBe('prod') // $production wins
|
||||
expect((prod.merged.features as Record<string, unknown>).shared).toBe(true) // base flags preserved
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { configWatchPlugin, featuresRuntimePlugin } from '../src/dev'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
function mockServer() {
|
||||
const watcher = new EventEmitter() as EventEmitter & { add: (paths: string[]) => void }
|
||||
watcher.add = vi.fn()
|
||||
const restart = vi.fn()
|
||||
const server = { watcher, restart, config: { logger: { info: vi.fn() } } }
|
||||
return { server, watcher, restart }
|
||||
}
|
||||
|
||||
const callConfigureServer = (plugin: { configureServer?: unknown }, server: unknown) =>
|
||||
(plugin.configureServer as (s: unknown) => void)(server)
|
||||
|
||||
describe('configWatchPlugin', () => {
|
||||
it('applies only in serve mode', () => {
|
||||
expect(configWatchPlugin([]).apply).toBe('serve')
|
||||
})
|
||||
|
||||
it('watches layer config files and restarts on change', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app'), fixture('stack/base')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
expect(watcher.add).toHaveBeenCalled()
|
||||
watcher.emit('change', resolve(fixture('stack/base'), 'app.config.ts'))
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores unrelated file changes', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const runTransform = (
|
||||
plugin: { transform?: unknown },
|
||||
code: string,
|
||||
id = '/app/src/x.ts',
|
||||
): { code: unknown; map?: unknown } | null => {
|
||||
const t = plugin.transform as
|
||||
| ((this: unknown, c: string, i: string) => { code: unknown; map?: unknown } | null)
|
||||
| undefined
|
||||
return t ? t.call({}, code, id) : null
|
||||
}
|
||||
|
||||
describe('featuresRuntimePlugin', () => {
|
||||
it('applies only in serve mode', () => {
|
||||
expect(featuresRuntimePlugin({}).apply).toBe('serve')
|
||||
})
|
||||
|
||||
it('prepends a module-local __FEATURES__ with a rolldown-generated sourcemap', () => {
|
||||
const out = runTransform(featuresRuntimePlugin({ billing: true }), 'export const x = __FEATURES__.billing')
|
||||
const code = String(out?.code)
|
||||
expect(code).toContain('const __FEATURES__={"billing":true};')
|
||||
expect(code).toContain('export const x = __FEATURES__.billing')
|
||||
expect((out?.map as { mappings?: string })?.mappings).toBeTruthy() // real sourcemap
|
||||
})
|
||||
|
||||
it('ignores property access (_ctx.__FEATURES__) and node_modules', () => {
|
||||
const p = featuresRuntimePlugin({ billing: true })
|
||||
expect(runTransform(p, 'const a = _ctx.__FEATURES__.billing')).toBeNull()
|
||||
expect(runTransform(p, 'export const x = __FEATURES__.billing', '/x/node_modules/y.js')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'root' }
|
||||
@@ -0,0 +1 @@
|
||||
export default { features: { a: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { features: { z: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'x', extends: ['../y'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'y', extends: ['../x'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'app', extends: ['../b', '../c'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'b', extends: ['../d'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'c', extends: ['../d'] }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'd', features: { tags: ['d'] } }
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
features: { flag: 'dev', shared: true },
|
||||
$production: { features: { flag: 'prod' } },
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
features: {
|
||||
billing: false,
|
||||
nested: { enabled: false, deep: { on: true } },
|
||||
'kebab-flag': true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
HIGH_LOGO
|
||||
@@ -0,0 +1 @@
|
||||
LOW_ICON
|
||||
@@ -0,0 +1 @@
|
||||
LOW_LOGO
|
||||
@@ -0,0 +1 @@
|
||||
LOW_SHARED
|
||||
@@ -0,0 +1 @@
|
||||
<!-- base Footer -->
|
||||
@@ -0,0 +1 @@
|
||||
<!-- base Header -->
|
||||
@@ -0,0 +1 @@
|
||||
export const card = 'base'
|
||||
@@ -0,0 +1 @@
|
||||
<!-- brand Header (override) -->
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'app', extends: ['../base'], features: { shared: 'app', app: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'base', extends: ['../core'], features: { shared: 'base', base: true } }
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'core', features: { shared: 'core', core: true }, vite: { define: { LVL: '"core"' } } }
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
name: 'app',
|
||||
extends: ['../base'],
|
||||
tsConfig: { compilerOptions: { strict: false, lib: ['ESNext'] } },
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { name: 'base', tsConfig: { compilerOptions: { types: ['node'] } } }
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLayerHooks, registerLayerHooks } from '../src/hooks'
|
||||
import type { Layer, LayerStack } from '../src/types'
|
||||
|
||||
const fakeStack = (): LayerStack => ({ merged: {}, layers: [] })
|
||||
|
||||
describe('layer hooks', () => {
|
||||
it('accumulates layer hooks base-first, then programmatic, and runs serially', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const order: string[] = []
|
||||
// layers are high→low; registration is base-first (reversed), programmatic last.
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('high') } } },
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers, { 'layers:resolved': () => void order.push('prog') })
|
||||
await hooks.callHook('layers:resolved', fakeStack())
|
||||
expect(order).toEqual(['low', 'high', 'prog'])
|
||||
})
|
||||
|
||||
it('handlers mutate the shared argument (mutation-style)', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
{ config: { hooks: { 'layers:resolved': s => void ((s.merged.features ??= {}).x = 1) } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers)
|
||||
const stack = fakeStack()
|
||||
await hooks.callHook('layers:resolved', stack)
|
||||
expect((stack.merged.features as Record<string, unknown>).x).toBe(1)
|
||||
})
|
||||
|
||||
it('awaits async handlers serially', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
const order: string[] = []
|
||||
const layers: Pick<Layer, 'config'>[] = [
|
||||
// high layer (registered last): async, must still complete before callHook resolves
|
||||
{ config: { hooks: { 'layers:resolved': async () => { await Promise.resolve(); order.push('high') } } } },
|
||||
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
|
||||
]
|
||||
registerLayerHooks(hooks, layers)
|
||||
await hooks.callHook('layers:resolved', fakeStack())
|
||||
expect(order).toEqual(['low', 'high'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Plugin, UserConfig, UserConfigFnObject } from 'vite'
|
||||
import { buildViteConfig, dedupePlugins } from '../src/kit'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p).replace(/\\/g, '/')
|
||||
const env = { command: 'build', mode: 'production', isSsrBuild: false, isPreview: false } as const
|
||||
|
||||
async function build(appDir: string): Promise<UserConfig> {
|
||||
const fn = (await buildViteConfig(appDir)) as UserConfigFnObject
|
||||
return (await fn(env)) as UserConfig
|
||||
}
|
||||
|
||||
describe('buildViteConfig', () => {
|
||||
it('exposes merged features via __FEATURES__ define (for DCE)', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__)
|
||||
expect(features.shared).toBe('app')
|
||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
||||
})
|
||||
|
||||
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
// dotted entry is folded by esbuild to a literal → enables DCE of `__FEATURES__.x ? import() : []`
|
||||
expect(define['__FEATURES__.shared']).toBe('"app"')
|
||||
expect(define['__FEATURES__.app']).toBe('true')
|
||||
})
|
||||
|
||||
it('emits dotted defines at every nesting depth (so nested flags also DCE)', async () => {
|
||||
const cfg = await build(fixture('features/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
expect(define['__FEATURES__.billing']).toBe('false')
|
||||
expect(define['__FEATURES__.nested.enabled']).toBe('false') // deep leaf → foldable → DCE-able
|
||||
expect(define['__FEATURES__.nested.deep.on']).toBe('true')
|
||||
expect(define['__FEATURES__.nested']).toBe('{"enabled":false,"deep":{"on":true}}') // intermediate object too
|
||||
})
|
||||
|
||||
it('skips non-identifier feature keys in dotted defines (avoids INVALID_DEFINE_CONFIG crash)', async () => {
|
||||
const cfg = await build(fixture('features/app'))
|
||||
const define = cfg.define as Record<string, string>
|
||||
// a dotted define with `kebab-flag` would crash the build; it is skipped here…
|
||||
expect(define['__FEATURES__.kebab-flag']).toBeUndefined()
|
||||
// …but still readable at runtime via the whole-object define.
|
||||
expect(JSON.parse(define.__FEATURES__)['kebab-flag']).toBe(true)
|
||||
})
|
||||
|
||||
it('runs lifecycle hooks: layers:resolved mutates features (before define), vite:config mutates config', async () => {
|
||||
const fn = (await buildViteConfig(fixture('stack/app'), {
|
||||
hooks: {
|
||||
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
||||
'vite:config': ctx => void (ctx.config.define = { ...ctx.config.define, INJECTED: '"yes"' }),
|
||||
},
|
||||
})) as UserConfigFnObject
|
||||
const cfg = (await fn(env)) as UserConfig
|
||||
const define = cfg.define as Record<string, string>
|
||||
expect(define['__FEATURES__.injected']).toBe('true') // layers:resolved ran before featureDefines
|
||||
expect(define.INJECTED).toBe('"yes"') // vite:config ran at the very end
|
||||
})
|
||||
|
||||
it('registers the layers resolver plugin', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
|
||||
expect(plugins.some(p => p?.name === 'vite-layers:resolve')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets ~~/@@ to the project rootDir and #layers/<name> per layer', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
const alias = (cfg.resolve as { alias: Record<string, string> }).alias
|
||||
expect(alias['~~']).toBe(fixture('stack/app'))
|
||||
expect(alias['@@']).toBe(fixture('stack/app'))
|
||||
expect(alias['#layers/app']).toBe(fixture('stack/app'))
|
||||
expect(alias['#layers/base']).toBe(fixture('stack/base'))
|
||||
expect(alias['#layers/core']).toBe(fixture('stack/core'))
|
||||
})
|
||||
|
||||
it('defaults outDir to dist/<app>', async () => {
|
||||
const cfg = await build(fixture('stack/app'))
|
||||
expect((cfg.build as { outDir: string }).outDir).toBe('dist/app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupePlugins', () => {
|
||||
it('removes plugins sharing a name, keeping the later (higher-priority) instance in place', () => {
|
||||
const a: Plugin = { name: 'vue', apply: 'build' }
|
||||
const b: Plugin = { name: 'vue', apply: 'serve' }
|
||||
const other: Plugin = { name: 'other' }
|
||||
const out = dedupePlugins({ plugins: [a, other, b] }).plugins as Plugin[]
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[0]).toBe(b) // position of first 'vue', value of later one
|
||||
expect(out[1]).toBe(other)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { publicLayersPlugin } from '../src/public'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
|
||||
|
||||
function runGenerateBundle(p: { generateBundle?: unknown }): Record<string, string> {
|
||||
const emitted: Record<string, string> = {}
|
||||
const ctx = {
|
||||
emitFile: ({ fileName, source }: { fileName: string; source: Buffer | string }) => {
|
||||
emitted[fileName] = source.toString()
|
||||
},
|
||||
}
|
||||
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false)
|
||||
return emitted
|
||||
}
|
||||
|
||||
describe('publicLayersPlugin', () => {
|
||||
const high = fixture('public/high/public')
|
||||
const low = fixture('public/low/public')
|
||||
|
||||
it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
|
||||
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
|
||||
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
||||
const emitted = runGenerateBundle(publicLayersPlugin([high, low]))
|
||||
expect(emitted['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
||||
expect(emitted['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
|
||||
expect(emitted['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { layersResolver } from '../src/resolve'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
|
||||
|
||||
// roots ordered high→low priority: brand overrides base.
|
||||
const roots = [fixture('brand/src'), fixture('base/src')]
|
||||
const plugin = layersResolver({ roots })
|
||||
const resolveId = (id: string, importer?: string): string | null =>
|
||||
(plugin.resolveId as (id: string, importer?: string) => string | null)(id, importer)
|
||||
|
||||
describe('layersResolver', () => {
|
||||
it('ignores non-layered ids', () => {
|
||||
expect(resolveId('vue')).toBeNull()
|
||||
expect(resolveId('./relative')).toBeNull()
|
||||
expect(resolveId('#layers/base/x')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves @/ to the highest-priority layer that has the file', () => {
|
||||
expect(resolveId('@/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
|
||||
})
|
||||
|
||||
it('falls through to a lower layer when the higher one lacks the file', () => {
|
||||
expect(resolveId('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||
})
|
||||
|
||||
it('supports the ~/ prefix identically', () => {
|
||||
expect(resolveId('~/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
|
||||
})
|
||||
|
||||
it('probes <path>/index<ext> when no direct file exists', () => {
|
||||
expect(resolveId('@/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts'))
|
||||
})
|
||||
|
||||
it('self-skips: an override importing itself reaches the base layer (super())', () => {
|
||||
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||
const baseHeader = fixture('base/src/components/Header.vue')
|
||||
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('returns null when nothing matches across layers', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves query suffixes (?inline / ?raw / ?vue&type=…)', () => {
|
||||
expect(resolveId('@/components/Header.vue?vue&type=style&lang.css')).toBe(
|
||||
`${fixture('brand/src/components/Header.vue')}?vue&type=style&lang.css`,
|
||||
)
|
||||
})
|
||||
|
||||
it('honors custom prefixes and extensions', () => {
|
||||
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
|
||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
||||
expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only
|
||||
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
||||
})
|
||||
|
||||
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
||||
const p = layersResolver({ roots })
|
||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
||||
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
|
||||
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLayerHooks } from '../src/hooks'
|
||||
import { featuresDts, generateTsConfig } from '../src/tsconfig'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
|
||||
describe('generateTsConfig', () => {
|
||||
it('maps @/* and ~/* to every layer srcDir in priority order (first-match = runtime resolver)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
// genDir is <app>/.vite-layers, so each src is one level up + the layer path
|
||||
expect(paths['@/*']).toEqual([
|
||||
'../src/*', // stack/app/src
|
||||
'../../base/src/*', // stack/base/src
|
||||
'../../core/src/*', // stack/core/src
|
||||
])
|
||||
expect(paths['~/*']).toEqual(paths['@/*'])
|
||||
})
|
||||
|
||||
it('maps ~~/@@ to the project root (bare + wildcard)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(paths['~~']).toEqual(['..'])
|
||||
expect(paths['~~/*']).toEqual(['../*'])
|
||||
expect(paths['@@']).toEqual(paths['~~'])
|
||||
})
|
||||
|
||||
it('emits #layers/<name>/* per layer rootDir', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(paths['#layers/app/*']).toEqual(['../*'])
|
||||
expect(paths['#layers/base/*']).toEqual(['../../base/*'])
|
||||
expect(paths['#layers/core/*']).toEqual(['../../core/*'])
|
||||
})
|
||||
|
||||
it('includes every layer srcDir glob', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
expect(tsconfig.include).toEqual(
|
||||
expect.arrayContaining(['../src/**/*', '../../base/src/**/*', '../../core/src/**/*']),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets framework-neutral defaults with no Vue/JSX specifics', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const co = tsconfig.compilerOptions!
|
||||
expect(co.moduleResolution).toBe('Bundler')
|
||||
expect(co.strict).toBe(true)
|
||||
expect(co).not.toHaveProperty('baseUrl') // deprecated in TS 6; paths resolve relative to the file
|
||||
expect(co).not.toHaveProperty('jsx')
|
||||
expect(co).not.toHaveProperty('jsxImportSource')
|
||||
})
|
||||
|
||||
it('merges per-layer `tsConfig` from app.config.ts across the stack (like Nuxt typescript.tsConfig)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('tsconfig-cfg/app'))
|
||||
const co = tsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.strict).toBe(false) // app layer overrides the default `true`
|
||||
expect(co.lib).toContain('ESNext') // from the app layer
|
||||
expect(co.types).toContain('node') // inherited from the base layer
|
||||
expect(co.moduleResolution).toBe('Bundler') // untouched default
|
||||
expect((co.paths as Record<string, string[]>)['@/*']).toBeDefined()
|
||||
})
|
||||
|
||||
it('opts.tsConfig wins over per-layer tsConfig and defaults, but never the generated paths', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'), {
|
||||
tsConfig: { compilerOptions: { strict: false, jsx: 'preserve', paths: { evil: ['/hax'] } } },
|
||||
})
|
||||
const co = tsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.strict).toBe(false) // user wins over default
|
||||
expect(co.jsx).toBe('preserve') // user can add options
|
||||
const paths = co.paths as Record<string, string[]>
|
||||
expect(paths.evil).toBeUndefined() // generated paths are authoritative
|
||||
expect(paths['@/*']).toBeDefined()
|
||||
})
|
||||
|
||||
it('generates a separate node tsconfig for config files (node-side, no DOM, no paths)', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.nodeFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/tsconfig\.node\.json$/)
|
||||
const co = r.nodeTsconfig.compilerOptions as Record<string, unknown>
|
||||
expect(co.lib).toEqual(['ESNext']) // no DOM
|
||||
expect(co.paths).toEqual({}) // config files don't use @/
|
||||
expect(co.noEmit).toBe(true)
|
||||
// includes app.config / vite.config of each layer (app + base + core)
|
||||
expect(r.nodeTsconfig.include).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/app\.config\.\*$/),
|
||||
expect.stringMatching(/vite\.config\.\*$/),
|
||||
]),
|
||||
)
|
||||
// ...and the app config no longer pulls in config files
|
||||
expect((r.tsconfig.include ?? []).some(p => p.includes('app.config'))).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a tsconfig:generate hook mutate the node tsconfig', async () => {
|
||||
const hooks = createLayerHooks()
|
||||
hooks.hook('tsconfig:generate', ctx => void (ctx.nodeTsconfig.compilerOptions!.removeComments = true))
|
||||
const r = await generateTsConfig(fixture('stack/app'), { hooks })
|
||||
expect((r.nodeTsconfig.compilerOptions as Record<string, unknown>).removeComments).toBe(true)
|
||||
})
|
||||
|
||||
it('includes ./features.d.ts and returns its generated content + path', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.tsconfig.include).toContain('./features.d.ts')
|
||||
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
|
||||
expect(r.dts).toContain('const __FEATURES__:')
|
||||
})
|
||||
|
||||
it('reuses a provided stack instead of resolving again (O2)', async () => {
|
||||
const stack = {
|
||||
merged: { features: { onlyInFake: true } },
|
||||
layers: [
|
||||
{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'FAKELAYER', config: {} },
|
||||
],
|
||||
}
|
||||
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
||||
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
|
||||
expect(r.dts).toContain('onlyInFake: boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('featuresDts', () => {
|
||||
it('renders a typed __FEATURES__ global (nested, primitives, quoted non-identifier keys)', () => {
|
||||
const dts = featuresDts({ billing: true, nested: { enabled: false }, 'kebab-flag': true, count: 2 })
|
||||
expect(dts).toContain('declare global')
|
||||
expect(dts).toContain('const __FEATURES__:')
|
||||
expect(dts).toContain('billing: boolean')
|
||||
expect(dts).toContain('nested: { enabled: boolean }')
|
||||
expect(dts).toContain('"kebab-flag": boolean')
|
||||
expect(dts).toContain('count: number')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "probe", "**/fixtures/**"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user