mirror of
https://github.com/Xevion/grain.git
synced 2025-12-05 23:15:08 -06:00
feat: migrate from React to Preact, add Vitest testing, and optimize bundle
Major framework and tooling changes: - Migrate from React 19 to Preact 10 with preact-iso for routing - Replace @mantine/hooks with custom hooks (useBooleanToggle, useViewportSize) - Switch from @heroicons/react to lucide-preact for icons - Replace chance.js with random-js for RNG Testing infrastructure: - Add Vitest with @testing-library/preact and happy-dom - Set up test configuration and initial App tests - Add Vitest UI for interactive test running Build optimizations: - Add cssnano for CSS minification - Configure aggressive bundle optimizations in Vite - Update to Tailwind CSS 4.1.17 Project structure: - Consolidate entry point from main.tsx to index.tsx - Reorganize CSS location (styles/index.css → index.css) - Add ESLint with preact config - Update TypeScript configuration for Preact
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
stats.html
|
||||
*.js
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
12
index.html
12
index.html
@@ -19,10 +19,7 @@
|
||||
property="article:published_time"
|
||||
content="2022-11-25T08:54:58.977Z "
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://grain.xevion.dev/bg.jpeg"
|
||||
/>
|
||||
<meta property="og:image" content="https://grain.xevion.dev/bg.jpeg" />
|
||||
<meta
|
||||
property="og:image:secure_url"
|
||||
content="https://grain.xevion.dev/bg.jpeg"
|
||||
@@ -34,10 +31,7 @@
|
||||
content="A simple gradient image generated with Grain."
|
||||
/>
|
||||
<meta property="og:image:type" content="jpeg" />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://grain.xevion.dev/bg.jpeg"
|
||||
/>
|
||||
<meta name="twitter:image" content="https://grain.xevion.dev/bg.jpeg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://grain.xevion.dev/" />
|
||||
<meta name="twitter:domain" content="https://grain.xevion.dev/" />
|
||||
@@ -49,6 +43,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script prerender type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
45
package.json
45
package.json
@@ -1,33 +1,42 @@
|
||||
{
|
||||
"name": "noise",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@use-it/event-listener": "^0.1.7",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"cssnano": "^7.1.0",
|
||||
"lucide-preact": "^0.468.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-iso": "^2.11.0",
|
||||
"preact-render-to-string": "^6.6.3",
|
||||
"random-js": "^2.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^4.1.11"
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/preact": "^3.2.4",
|
||||
"@types/node": "^24.10.0",
|
||||
"@vitest/ui": "4.0.7",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-preact": "^2.0.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.1",
|
||||
"vitest": "^4.0.7"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
|
||||
}
|
||||
|
||||
3231
pnpm-lock.yaml
generated
3231
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { Sparkles, ShieldAlert } from "lucide-preact";
|
||||
|
||||
const Post = () => {
|
||||
return (
|
||||
@@ -23,7 +22,7 @@ const Post = () => {
|
||||
target="_blank"
|
||||
className="hover:text-yellow-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon className="h-4 inline mb-2.5 m-2 " />
|
||||
<Sparkles className="h-4 inline mb-2.5 m-2 " />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -47,24 +46,24 @@ const Post = () => {
|
||||
</p>
|
||||
<p>
|
||||
By using a SVG with a{" "}
|
||||
<pre className="inline"><feTurbulence></pre> filter inside,
|
||||
stacked upon several <pre className="inline">radial-gradient</pre>{" "}
|
||||
<code><feTurbulence></code> filter inside,
|
||||
stacked upon several <code>radial-gradient</code>{" "}
|
||||
background images, the same effect can be created. Since SVGs do not
|
||||
naturally repeat internally, the SVG itself must be generated in
|
||||
such a way that the noise always displays the same way.
|
||||
</p>
|
||||
<p>
|
||||
React comes in handy here, allowing composition of an SVG, and then
|
||||
conversion to a <pre className="inline">base64</pre> encoded string.
|
||||
As a <pre className="inline">base64</pre> image, it can be fed into
|
||||
the <pre className="inline">background</pre> CSS property, allowing
|
||||
conversion to a <code>base64</code> encoded string.
|
||||
As a <code>base64</code> image, it can be fed into
|
||||
the <code>background</code> CSS property, allowing
|
||||
dynamic SVG generation.
|
||||
</p>
|
||||
<div className="pt-3">
|
||||
<a href="https://github.com/Xevion/grain">
|
||||
<div className="inline text-white text-medium drop-shadow-lg rounded border-2 shadow-xl border-zinc-600/75 m-2 p-2 bg-linear-to-r from-red-500 via-orange-500 to-orange-700">
|
||||
In Progress
|
||||
<ShieldExclamationIcon className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
|
||||
<ShieldAlert className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { useViewportSize, useToggle } from "@mantine/hooks";
|
||||
import useBackground from "@/utils/useBackground";
|
||||
import Post from "@/components/Post";
|
||||
import { hydrate, prerender as ssr } from "preact-iso";
|
||||
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { useMemo, useState } from "react";
|
||||
import "./index.css";
|
||||
|
||||
function App() {
|
||||
import { useViewportSize } from "./utils/useViewportSize";
|
||||
import { useBooleanToggle } from "./utils/useBooleanToggle";
|
||||
import useBackground from "./utils/useBackground";
|
||||
import Post from "./components/Post";
|
||||
import { RefreshCw, Eye, EyeOff } from "lucide-preact";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
export function App() {
|
||||
const { width, height } = useViewportSize();
|
||||
const { svg, backgrounds, regenerate } = useBackground({
|
||||
width,
|
||||
height,
|
||||
ratio: 0.4,
|
||||
});
|
||||
const [postHidden, toggleHidden] = useToggle([false, true]);
|
||||
|
||||
const [iconSpinning, toggleIconSpinning] = useToggle([false, true]);
|
||||
const [postHidden, toggleHidden] = useBooleanToggle(false);
|
||||
const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
@@ -42,7 +41,7 @@ function App() {
|
||||
setTimeout(() => toggleIconSpinning(false), 200);
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
<RefreshCw
|
||||
className={`transition-transform duration-200 ${
|
||||
iconSpinning ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
@@ -52,14 +51,13 @@ function App() {
|
||||
className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button"
|
||||
onClick={() => toggleHidden()}
|
||||
>
|
||||
{postHidden ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
{postHidden ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`h-screen transition-opacity ease-in-out duration-75 ${
|
||||
postHidden ? "opacity-0 pointer-events-none" : ""
|
||||
} flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`}
|
||||
|
||||
>
|
||||
<div className="bg-white overflow-y-auto">
|
||||
<Post />
|
||||
@@ -71,4 +69,10 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
if (typeof window !== "undefined") {
|
||||
hydrate(<App />, document.getElementById("root"));
|
||||
}
|
||||
|
||||
export async function prerender(data: any) {
|
||||
return await ssr(<App {...data} />);
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "@/components/App";
|
||||
import "@/styles/index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
48
src/test/App.test.tsx
Normal file
48
src/test/App.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { h } from "preact";
|
||||
import { render } from "@testing-library/preact";
|
||||
import type { FunctionComponent } from "preact";
|
||||
|
||||
describe("App", () => {
|
||||
let App: FunctionComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock window dimensions for useViewportSize hook
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1920,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1080,
|
||||
});
|
||||
|
||||
// Mock btoa for useBackground hook
|
||||
global.btoa = vi.fn((str) => Buffer.from(str).toString("base64"));
|
||||
|
||||
// Dynamically import App after mocks are set up
|
||||
const module = await import("../index");
|
||||
App = module.App;
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
expect(() => render(h(App, {}))).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders the Post component with main heading", () => {
|
||||
const { getAllByText } = render(h(App, {}));
|
||||
expect(getAllByText("Grain").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders author information", () => {
|
||||
const { getAllByText } = render(h(App, {}));
|
||||
expect(getAllByText("Ryan Walters").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders with gradient class", () => {
|
||||
const { container } = render(h(App, {}));
|
||||
expect(container.innerHTML).toContain("gradient");
|
||||
});
|
||||
});
|
||||
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "preact/debug";
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Random } from "random-js";
|
||||
import { useMemo, useState } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { getEdgePoint } from "@/utils/helpers";
|
||||
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { getEdgePoint } from "./helpers";
|
||||
interface useBackgroundProps {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -27,11 +25,7 @@ const generateBackground = (): string[] => {
|
||||
.fill(null)
|
||||
.map(() => random.pick(palette))
|
||||
.map((color) => {
|
||||
const [x, y] = getEdgePoint(
|
||||
random.integer(0, 400),
|
||||
100,
|
||||
100
|
||||
);
|
||||
const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100);
|
||||
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
|
||||
});
|
||||
};
|
||||
@@ -47,35 +41,20 @@ const useBackground = ({
|
||||
setBackground(generateBackground());
|
||||
};
|
||||
|
||||
const noise = useMemo(() => {
|
||||
const noise = (): string => {
|
||||
const svgWidth = Math.ceil((width ?? 1920) * ratio);
|
||||
const svgHeight = Math.ceil((height ?? 1080) * ratio);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="2.1"
|
||||
numOctaves="2"
|
||||
seed={random.integer(0, 1000000)}
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<g opacity={0.9}>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}, [width, height, ratio]);
|
||||
const seed = random.integer(0, 1000000);
|
||||
|
||||
return `<svg viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="2.1" numOctaves="2" seed="${seed}" stitchTiles="stitch"/></filter><g opacity="0.9"><rect width="100%" height="100%" filter="url(#noiseFilter)"/></g></svg>`;
|
||||
};
|
||||
|
||||
return {
|
||||
regenerate,
|
||||
svg: `data:image/svg+xml;base64,${window.btoa(
|
||||
ReactDOMServer.renderToString(noise)
|
||||
)}`,
|
||||
svg:
|
||||
typeof window !== "undefined"
|
||||
? `data:image/svg+xml;base64,${window.btoa(noise())}`
|
||||
: "",
|
||||
backgrounds: background,
|
||||
};
|
||||
};
|
||||
|
||||
18
src/utils/useBooleanToggle.ts
Normal file
18
src/utils/useBooleanToggle.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
|
||||
export function useBooleanToggle(
|
||||
initialValue: boolean = false
|
||||
): [boolean, (nextValue?: boolean) => void] {
|
||||
const [value, setValue] = useState<boolean>(initialValue);
|
||||
|
||||
const toggle = useCallback((nextValue?: boolean) => {
|
||||
if (typeof nextValue === "boolean") {
|
||||
setValue(nextValue);
|
||||
return;
|
||||
}
|
||||
setValue((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return [value, toggle];
|
||||
}
|
||||
|
||||
24
src/utils/useViewportSize.ts
Normal file
24
src/utils/useViewportSize.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useViewportSize(): ViewportSize {
|
||||
const [size, setSize] = useState<ViewportSize>({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Preact Config */
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./src/",
|
||||
"jsxImportSource": "preact",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"react": ["node_modules/preact/compat/"],
|
||||
"react-dom": ["node_modules/preact/compat/"],
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": ["node_modules/vite/client.d.ts", "src"]
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import preact from "@preact/preset-vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
import cssnano from "cssnano";
|
||||
@@ -12,11 +11,13 @@ export default ({ mode }) => {
|
||||
return defineConfig({
|
||||
base: "/",
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
preact({
|
||||
prerender: {
|
||||
enabled: true,
|
||||
renderTarget: "#root",
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
cssnano(),
|
||||
|
||||
visualizer({
|
||||
template: "treemap",
|
||||
open: true, // Automatically open the report in your browser after build
|
||||
@@ -25,17 +26,5 @@ export default ({ mode }) => {
|
||||
brotliSize: true, // Show brotli size
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
treeshake: {
|
||||
// Remove unused module exports
|
||||
moduleSideEffects: false,
|
||||
// Optimize property access
|
||||
propertyReadSideEffects: false,
|
||||
// Remove unused imports
|
||||
tryCatchDeoptimization: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
28
vitest.config.ts
Normal file
28
vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
"src/test/",
|
||||
"**/*.config.ts",
|
||||
"**/*.d.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"react": "preact/compat",
|
||||
"react-dom": "preact/compat",
|
||||
"react-dom/test-utils": "preact/test-utils",
|
||||
},
|
||||
dedupe: ["preact"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user