From a84b24c79c3f9ae49f5dce413720eb83e2082234 Mon Sep 17 00:00:00 2001 From: Dominic Idagu Date: Sat, 10 Oct 2026 05:09:50 +0200 Subject: [PATCH 1/2] feat(web): Vite + React scaffold Co-Authored-By: Claude Opus 5.5 --- apps/api/package.json | 2 +- apps/api/wrangler.jsonc | 7 + apps/web/index.html | 17 + apps/web/package.json | 28 ++ apps/web/public/favicon.svg | 1 + apps/web/src/App.test.tsx | 13 + apps/web/src/App.tsx | 17 + apps/web/src/components/Header.test.tsx | 17 + apps/web/src/components/Header.tsx | 16 + apps/web/src/main.tsx | 18 + apps/web/src/test/setup.ts | 7 + apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 16 + docs/learning/01-components-and-jsx.md | 66 +++ pnpm-lock.yaml | 586 +++++++++++++++++++++++- 15 files changed, 813 insertions(+), 7 deletions(-) create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/App.test.tsx create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/Header.test.tsx create mode 100644 apps/web/src/components/Header.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docs/learning/01-components-and-jsx.md diff --git a/apps/api/package.json b/apps/api/package.json index f7a1d5e..5224a5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "wrangler dev", + "dev": "node -e \"require('node:fs').mkdirSync('../web/dist', { recursive: true })\" && wrangler dev", "cf-typegen": "wrangler types", "postinstall": "wrangler types", "db:migrate:local": "wrangler d1 migrations apply cleanhashtags --local", diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 5535eff..61802e9 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -3,6 +3,13 @@ "name": "cleanhashtags", "main": "src/index.ts", "compatibility_date": "2026-10-10", + // The React app (apps/web) is built to ../web/dist and served as static assets. + // Asset requests are free on the Workers plan; only /api/* invokes the Worker. + "assets": { + "directory": "../web/dist", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*"], + }, "observability": { "enabled": true, }, diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..26b618a --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + CleanHashtags + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6a89486 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cleanhashtags/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "jsdom": "^30.1.2", + "vite": "^8.3.4", + "vitest": "^5.0.3" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..2d273d1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..8b56f5e --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { App } from "./App"; + +describe("App", () => { + it("renders the main heading", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "CleanHashtags" }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..f498589 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,17 @@ +import { Header } from "./components/Header"; + +// A component is a function that returns JSX. React calls it to find out what +// to show; the capitalised name is how JSX tells components from HTML tags. +export function App() { + return ( + <> +
+
+

Hashtag checking is coming soon.

+
+ + ); +} diff --git a/apps/web/src/components/Header.test.tsx b/apps/web/src/components/Header.test.tsx new file mode 100644 index 0000000..44ef7bb --- /dev/null +++ b/apps/web/src/components/Header.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Header } from "./Header"; + +describe("Header", () => { + it("renders the tagline when given", () => { + render(
); + + expect(screen.getByText("A tagline")).toBeInTheDocument(); + }); + + it("omits the tagline when not given", () => { + const { container } = render(
); + + expect(container.querySelector("p")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx new file mode 100644 index 0000000..e715bd5 --- /dev/null +++ b/apps/web/src/components/Header.tsx @@ -0,0 +1,16 @@ +// Props are a component's inputs. Declaring them as a TypeScript type means +//
without a title is a compile error, not a runtime surprise. +type HeaderProps = { + title: string; + tagline?: string; +}; + +export function Header({ title, tagline }: HeaderProps) { + return ( +
+

{title}

+ {/* `&&` renders the paragraph only when a tagline was passed. */} + {tagline &&

{tagline}

} +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..34f4772 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +// Entry point: Vite loads this file from index.html. createRoot takes over the +//
element and React renders the component tree inside it. +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Missing #root element in index.html"); +} + +// StrictMode only affects development: it double-invokes some functions to +// surface bugs such as side effects during rendering. +createRoot(rootElement).render( + + + , +); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..8ac5eef --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client", "@testing-library/jest-dom/vitest"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..76a8880 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + // `wrangler dev` (apps/api) listens on 8787. + "/api": "http://localhost:8787", + }, + }, + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + }, +}); diff --git a/docs/learning/01-components-and-jsx.md b/docs/learning/01-components-and-jsx.md new file mode 100644 index 0000000..4331c3b --- /dev/null +++ b/docs/learning/01-components-and-jsx.md @@ -0,0 +1,66 @@ +# 01 — Components, JSX and how Vite boots React + +Step 10 adds `apps/web`, a React + Vite app with one component and a smoke test. + +## How the page starts + +1. The browser loads `apps/web/index.html`. It has an empty `
` and one + `