Skip to content

Commit fdb7348

Browse files
committed
chore: migrate to pnpm, Tailwind v4, Next 16, add Vitest
- Replace npm/package-lock with pnpm; allowBuilds for sharp in pnpm-workspace.yaml; pin packageManager. - Upgrade Tailwind to v4 (CSS-first): drop tailwind.config.*, use @tailwindcss/postcss, @import "tailwindcss", @custom-variant dark for class-based dark mode. - Upgrade to Next 16 + eslint-config-next 16; migrate ESLint to flat config (eslint.config.mjs); replace next lint with eslint. - Add Vitest + Testing Library; mock swagger-ui-react; stub window.matchMedia for jsdom; cover version switching and dark-mode behavior. - Refactor app/page.tsx: derive system theme via useSyncExternalStore + userOverride to satisfy react-hooks/set-state-in-effect. - UI fixes: drop stray .swagger-ui class from outer wrapper (was bleeding Swagger's text colors onto controls); remove double-invert override on .scheme-container so "Servers" stays readable in dark mode; relabel selector to "phpMyFAQ Version"; match controls-header width to Swagger content.
1 parent 28cbdba commit fdb7348

16 files changed

Lines changed: 6590 additions & 8584 deletions

.eslintrc.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project
6+
7+
Static site that hosts the phpMyFAQ API documentation.
8+
It renders `swagger-ui-react` against OpenAPI specs fetched at runtime from the `thorsten/phpMyFAQ` GitHub repo (branches `4.0`, `4.1`, `main`).
9+
Deployed on Vercel — pushing to `main` triggers deployment.
10+
11+
## Commands
12+
13+
- `pnpm dev` — Next.js dev server on http://localhost:3000 (Turbopack)
14+
- `pnpm build` — production build
15+
- `pnpm start` — serve the production build
16+
- `pnpm lint` — ESLint flat config (`eslint.config.mjs`), composing `eslint-config-next/core-web-vitals` and `eslint-config-next/typescript`. Note: Next 16 removed `next lint`, so this script invokes `eslint` directly.
17+
- `pnpm test` — Vitest (jsdom + Testing Library). `pnpm test:watch` for watch mode. A single test: `pnpm test -- path/to/file.test.tsx -t "name"`. Test setup (`vitest.setup.ts`) stubs `window.matchMedia` (jsdom doesn't implement it) and resets the `dark` class on `<html>` between tests. `swagger-ui-react` is mocked in component tests to avoid pulling its CSS/runtime.
18+
19+
## Architecture
20+
21+
- Next.js 16 App Router (Turbopack default), React 19, TypeScript, Tailwind CSS 4 (CSS-first; no `tailwind.config.*` — configure via `@theme`/`@custom-variant` in `app/globals.css`). PostCSS uses `@tailwindcss/postcss`.
22+
- The entire UI is a single client component: `app/page.tsx`. It owns version selection, dark-mode toggle, and renders `<SwaggerUI url={...}/>`. New API versions are added by extending the `apiUrls` map and the corresponding `<option>` entries there.
23+
- `app/layout.tsx` and `app/globals.css` set up the global shell. Dark-mode styling for Swagger UI relies on the `.swagger-container-dark` / `.swagger-container-light` class wrappers plus the `dark` class on `<html>`.
24+
- Security headers (`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: origin-when-cross-origin`) are configured in `next.config.mjs` and applied to all routes.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The official phpMyFAQ API documentation is built with [Next.js](https://nextjs.o
88
First, run the development server:
99

1010
```bash
11-
npm run dev
11+
pnpm dev
1212
```
1313

1414
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

app/globals.css

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
@tailwind base;
2-
@tailwind components;
3-
@tailwind utilities;
1+
@import "tailwindcss";
2+
3+
@custom-variant dark (&:where(.dark, .dark *));
44

55
:root {
66
--foreground-rgb: 0, 0, 0;
@@ -89,14 +89,6 @@ body {
8989
color: #d1d5db !important;
9090
}
9191

92-
.swagger-container-dark .swagger-ui .scheme-container {
93-
background: #374151 !important;
94-
padding: 10px;
95-
border-radius: 4px;
96-
border: 1px solid #4b5563;
97-
filter: invert(1) hue-rotate(180deg);
98-
}
99-
10092
/* Fix for inverted elements in dark mode */
10193
.swagger-container-dark .swagger-ui img,
10294
.swagger-container-dark .swagger-ui svg {

app/page.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
vi.mock("swagger-ui-react", () => ({
6+
default: ({ url }: { url: string }) => (
7+
<div data-testid="swagger-ui" data-url={url} />
8+
),
9+
}));
10+
11+
vi.mock("swagger-ui-react/swagger-ui.css", () => ({}));
12+
13+
import Home from "./page";
14+
15+
describe("Home", () => {
16+
beforeEach(() => {
17+
document.documentElement.classList.remove("dark");
18+
});
19+
20+
it("renders SwaggerUI with the default 4.0 spec URL", () => {
21+
render(<Home />);
22+
const swagger = screen.getByTestId("swagger-ui");
23+
expect(swagger).toHaveAttribute(
24+
"data-url",
25+
"https://raw.githubusercontent.com/thorsten/phpMyFAQ/4.0/docs/openapi.json",
26+
);
27+
});
28+
29+
it("switches the SwaggerUI URL when a different version is selected", async () => {
30+
const user = userEvent.setup();
31+
render(<Home />);
32+
33+
await user.selectOptions(
34+
screen.getByLabelText(/select phpmyfaq version/i),
35+
"4.2",
36+
);
37+
38+
expect(screen.getByTestId("swagger-ui")).toHaveAttribute(
39+
"data-url",
40+
"https://raw.githubusercontent.com/thorsten/phpMyFAQ/main/docs/openapi.json",
41+
);
42+
});
43+
44+
it("offers all configured versions in the selector", () => {
45+
render(<Home />);
46+
const select = screen.getByLabelText(/select phpmyfaq version/i) as HTMLSelectElement;
47+
const values = Array.from(select.options).map((o) => o.value);
48+
expect(values).toEqual(["4.0", "4.1", "4.2"]);
49+
});
50+
51+
it("does not apply the dark class when system prefers light", () => {
52+
render(<Home />);
53+
expect(document.documentElement).not.toHaveClass("dark");
54+
});
55+
56+
it("toggles the dark class on the document element when the theme toggle is clicked", async () => {
57+
const user = userEvent.setup();
58+
render(<Home />);
59+
60+
const toggle = screen.getByRole("button", { name: /toggle dark mode/i });
61+
62+
await user.click(toggle);
63+
expect(document.documentElement).toHaveClass("dark");
64+
65+
await user.click(toggle);
66+
expect(document.documentElement).not.toHaveClass("dark");
67+
});
68+
69+
it("follows the system preference when prefers-color-scheme is dark", () => {
70+
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
71+
matches: true,
72+
media: query,
73+
addEventListener: vi.fn(),
74+
removeEventListener: vi.fn(),
75+
addListener: vi.fn(),
76+
removeListener: vi.fn(),
77+
dispatchEvent: vi.fn(() => false),
78+
onchange: null,
79+
}));
80+
81+
render(<Home />);
82+
expect(document.documentElement).toHaveClass("dark");
83+
84+
vi.mocked(window.matchMedia).mockReset();
85+
});
86+
});

app/page.tsx

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,50 @@
11
"use client";
22

3-
import React, { useState, useCallback, useEffect } from 'react';
3+
import React, { useState, useCallback, useEffect, useSyncExternalStore } from 'react';
44
import SwaggerUI from 'swagger-ui-react';
55
import 'swagger-ui-react/swagger-ui.css';
66

77
interface ApiUrls {
88
[key: string]: string;
99
}
1010

11+
const DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)';
12+
13+
function subscribePrefersDark(callback: () => void): () => void {
14+
const mql = window.matchMedia(DARK_MEDIA_QUERY);
15+
mql.addEventListener('change', callback);
16+
return () => mql.removeEventListener('change', callback);
17+
}
18+
19+
function getPrefersDarkSnapshot(): boolean {
20+
return window.matchMedia(DARK_MEDIA_QUERY).matches;
21+
}
22+
23+
function getPrefersDarkServerSnapshot(): boolean {
24+
return false;
25+
}
26+
1127
export default function Home(): React.ReactElement {
1228
const [version, setVersion] = useState<string>('4.0');
13-
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
29+
const [userOverride, setUserOverride] = useState<boolean | null>(null);
30+
const systemPrefersDark = useSyncExternalStore(
31+
subscribePrefersDark,
32+
getPrefersDarkSnapshot,
33+
getPrefersDarkServerSnapshot,
34+
);
35+
const isDarkMode = userOverride ?? systemPrefersDark;
1436

15-
// Initialize dark mode based on system preference
1637
useEffect(() => {
17-
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
18-
setIsDarkMode(prefersDark);
19-
20-
// Apply dark mode to the document
21-
if (prefersDark) {
22-
document.documentElement.classList.add('dark');
23-
}
24-
}, []);
38+
document.documentElement.classList.toggle('dark', isDarkMode);
39+
}, [isDarkMode]);
2540

2641
const handleVersionChange = useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
2742
setVersion(event.target.value);
2843
}, []);
2944

3045
const handleDarkModeToggle = useCallback(() => {
31-
setIsDarkMode(prev => {
32-
const newMode = !prev;
33-
if (newMode) {
34-
document.documentElement.classList.add('dark');
35-
} else {
36-
document.documentElement.classList.remove('dark');
37-
}
38-
return newMode;
39-
});
40-
}, []);
46+
setUserOverride(!isDarkMode);
47+
}, [isDarkMode]);
4148

4249
const apiUrls: ApiUrls = {
4350
'4.0': 'https://raw.githubusercontent.com/thorsten/phpMyFAQ/4.0/docs/openapi.json',
@@ -51,7 +58,7 @@ export default function Home(): React.ReactElement {
5158
? 'bg-gray-900'
5259
: 'bg-gray-50'
5360
}`}>
54-
<div className="swagger-ui mx-auto px-4 py-8">
61+
<div className="mx-auto px-4 py-8">
5562
{/* Main Swagger UI Container with integrated controls */}
5663
<div className={`${
5764
isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'
@@ -60,14 +67,14 @@ export default function Home(): React.ReactElement {
6067
{/* Controls Header inside the Swagger container */}
6168
<div className={`${
6269
isDarkMode ? 'bg-gray-700 border-gray-600' : 'bg-gray-50 border-gray-200'
63-
} mx-4 mt-4 mb-2 px-6 py-4 border rounded-lg flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4`}>
70+
} px-6 py-4 border-b flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4`}>
6471

6572
{/* Version Selector */}
6673
<div className="flex items-center space-x-3">
6774
<label htmlFor="api-version" className={`font-medium ${
6875
isDarkMode ? 'text-gray-200' : 'text-gray-700'
6976
}`}>
70-
API Version:
77+
phpMyFAQ Version:
7178
</label>
7279
<select
7380
id="api-version"
@@ -78,10 +85,10 @@ export default function Home(): React.ReactElement {
7885
? 'bg-gray-600 border-gray-500 text-white focus:border-blue-400'
7986
: 'bg-white border-gray-300 text-gray-900 focus:border-blue-500'
8087
} px-3 py-1.5 rounded-md border focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-colors text-sm`}
81-
aria-label="Select API version"
88+
aria-label="Select phpMyFAQ version"
8289
>
8390
<option value="4.0">Version 4.0</option>
84-
<option value="4.1">Version 4.1</option>
91+
<option value="4.1" selected>Version 4.1</option>
8592
<option value="4.2">Version 4.2</option>
8693
</select>
8794
</div>

eslint.config.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import coreWebVitals from "eslint-config-next/core-web-vitals";
2+
import typescript from "eslint-config-next/typescript";
3+
4+
const config = [
5+
...coreWebVitals,
6+
...typescript,
7+
{
8+
rules: {
9+
"@typescript-eslint/no-unused-vars": "warn",
10+
"@typescript-eslint/no-explicit-any": "warn",
11+
},
12+
},
13+
];
14+
15+
export default config;

0 commit comments

Comments
 (0)