diff --git a/CHANGELOG.md b/CHANGELOG.md index dbed008..816401e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,116 @@ This file records notable project changes. It follows the ## [Unreleased] +## [1.0.4] - 2026-09-12 + ### Added +- ESLint is an enforced gate instead of a skipped one. `eslint.config.mjs` applies + `eslint-config-next` core-web-vitals plus the TypeScript rules to the whole + workspace, `pnpm lint` runs `eslint . --max-warnings=0` so warnings fail the + build, `pnpm lint:fix` applies autofixes, and `scripts/do-prechecks.py` runs the + gate between typecheck and the production build. Every relaxation is scoped to + named files and states why (`@next/next/no-img-element`, the `react-hooks/*` + families, `no-console` under `scripts/`, `no-require-imports` for the lazy jsdom + `require()`), and `docs/DEVELOPMENT.md` records the deferred work. +- Two dependency pins are documented as load-bearing in the config and the dev + guide: ESLint stays on 9.x because eslint-plugin-react and eslint-plugin-jsx-a11y + peer ranges stop at `^9` (ESLint 10 removed `context.getFilename`), and + TypeScript stays on 6.x because typescript-eslint hard-errors on the TypeScript 7 + native API. - `scripts/do-prechecks.py` runs the local quality gates in fail-fast order (`pnpm prechecks`, `pnpm prechecks:quick`), including static release-metadata, SPDX, migration-journal, and repository-hygiene checks that CI cannot catch before a push. `--install-hook` wires it to `git pre-commit`. - `scripts/test_do_prechecks.py` covers the precheck tooling itself and runs as one of its gates. +- `tests/components/CodeBlockTheme.test.tsx` asserts the highlighter palette + follows a `` flip with no navigation; it fails against the + previous mount-effect implementation. + +### Performance + +- The site banner and the fixed-size avatars now render through `next/image` + instead of ``. `Banner` uses `fill` + `priority` + `sizes="100vw"` inside its + existing `relative h-64 overflow-hidden` wrapper and keeps the configured focal + point through `objectPosition`; `ArticleCard`, `UserMenu` and `UsersManager` pass + explicit 20-36px dimensions plus `sizes`, so a small avatar no longer downloads + the full-size OAuth photo. +- `next.config.mjs` accepts `*.googleusercontent.com` in place of the single + `lh3.googleusercontent.com` host, so Google accounts served from another + `*.googleusercontent.com` host render instead of throwing while rendering the + post list. + +### Changed + +- Dependency refresh across the workspace (`pnpm update --latest`): Next 16.3.5 + with `@next/bundle-analyzer`/`eslint-config-next` 16.3.5, React 19.3 and matching + React types, Tiptap 3.31.3, Mermaid 12, Zod 4.6.2, `@simplewebauthn/browser` 14 + and `@simplewebauthn/server` 14.0.1, AWS SDK 3.1131, marked 18.0.12, + lucide-react 1.45, dompurify 3.4.15, jszip 3.10.2, and on the tooling side + Vitest 5 with `@vitest/coverage-v8` 5, Vite 8.3, Playwright 1.63 and + Puppeteer 25.10. +- `pnpm-workspace.yaml` peer rules track what is actually installed: + `@simplewebauthn/browser`/`@simplewebauthn/server` `>=14` (next-auth/`@auth/core` + still declare older peers and this app runs its own 2FA implementation) and + `eslint` `>=9 <10`. +- Internal anchors in the home page footer and the comment sign-in panel use + `next/link`, and 48 literal `"`/`'` characters in JSX text are escaped as + `"`/`'`. +- README stack badges follow `package.json` (Next.js 16.3, React 19.3), the README + gate table and `docs/DEVELOPMENT.md` describe the now-active ESLint gate, and + `docs/AGENTS.md` lists `pnpm lint` with the other standard commands. +- Tests follow the implementation: the `UserMenu` avatar assertion now expects the + optimizer URL carrying the original avatar in its `url` parameter and + `sizes="36px"`, and the remaining test edits drop unused bindings and + already-dead fixtures. ### Fixed +- Code blocks follow theme changes. `CodeBlock` sampled `` once in + a mount effect while `ThemeToggle` rewrites that attribute in place, so toggling + the theme left every already-rendered code block in the previous palette until a + reload. The palette now comes from a `useSyncExternalStore` subscription (a + `MutationObserver` on `data-theme` plus a + `matchMedia('(prefers-color-scheme: dark)')` listener), with the light palette as + the server snapshot so hydration output is unchanged and the `matchMedia` lookups + guarded for jsdom. +- Reaction anti-abuse fails closed: `ReactionButtons` records its mount timestamp + in an effect, keeping render pure, and reports "now" if a handler somehow runs + first, so the server-side minimum submit-time check rejects instead of passing. +- `src/app/api/rum/route.ts` exports `runtime = "nodejs"`; it had been a local + `const` that never configured the route. +- `PostForm`'s featured-image preview effect no longer reads the state it owns: the + object URL is created and revoked strictly from the selected file. - Removed the stray top-level `tiptap-markdown` field from `package.json`, which duplicated the `devDependencies` entry and was ignored by npm/pnpm. -- Synced the README version badge with `package.json` (1.0.3). - Added the missing `Apache-2.0` SPDX headers to `src/lib/rateLimit.ts` and `scripts/iframe-allowlist.ts`. +### Removed + +- Dead code the lint run exposed: the `Server-Timing` header computation on the post + page that was never attached to a response, the placeholder audit-log fetch in + `AuditLogSection` (now an explicit "no endpoint yet" state), the per-request + `performance.now()` in `proxy()`, the unused query id in + `createDatabaseInterceptor()`, and unused imports, variables and `catch` bindings + across `src/` and `tests/`. + +### Security + +- `lodash-es` is overridden to `>=4.17.24`, closing GHSA-f23m-r3pf-42rh (prototype + pollution) reached through `mermaid -> chevrotain`; the override stays inside the + range chevrotain declares and dedupes with the copy `dagre-d3-es` already used. +- `postcss` is pinned to 8.5.28 and `qs` overridden to `>=6.16.0` for the remaining + advisories; `qs` is reachable only through `@lhci/cli > express`, so it is + dev-only. `pnpm audit` now reports no known vulnerabilities in production and + development dependencies. + ### Chores - Ignore `__pycache__/` and `*.py[cod]` so the Python gate tooling leaves no noise in `git status`. +- Bumped the application version from `1.0.3` to `1.0.4`. ## [1.0.3] - 2026-08-30 diff --git a/README.md b/README.md index b9638c7..10b4e10 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ Narravo is a self-hostable blog engine built with Next.js App Router, React, Typ [![CI](https://github.com/sphildreth/narravo/actions/workflows/ci.yml/badge.svg)](https://github.com/sphildreth/narravo/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) -![Version](https://img.shields.io/badge/version-1.0.3-blue) +![Version](https://img.shields.io/badge/version-1.0.4-blue) ![Node](https://img.shields.io/badge/Node.js-22.x-339933?logo=node.js&logoColor=white) ![pnpm](https://img.shields.io/badge/pnpm-11.5.2-f69220?logo=pnpm&logoColor=white) -![Next.js](https://img.shields.io/badge/Next.js-16.2-black?logo=next.js&logoColor=white) -![React](https://img.shields.io/badge/React-19.2-61dafb?logo=react&logoColor=white) +![Next.js](https://img.shields.io/badge/Next.js-16.3-black?logo=next.js&logoColor=white) +![React](https://img.shields.io/badge/React-19.3-61dafb?logo=react&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-6.0-blue?logo=typescript&logoColor=white) Demo site: [Knowledge Tome](https://www.shildreth.com) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 53f6982..13f39b6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -68,19 +68,25 @@ Notes for specific gates: The gate is green today because four families of pre-existing patterns are exempted per file, not globally. Each needs a real change with its own review: -- `@next/next/no-img-element` – avatars (Gravatar), imported WordPress media and - admin previews use plain ``. Switching to `next/image` first needs an - `images.remotePatterns` allowlist in `next.config.mjs`, since the optimizer - proxies those remote URLs. +- `@next/next/no-img-element` – five call sites keep ``: the post featured + image and comment attachments (the uploads table stores no intrinsic width or + height, so `next/image` would need a fixed crop box instead of the natural-aspect + layout), the lightbox (deliberately the original bytes of arbitrary imported + media) and the two admin upload previews (`blob:` object URLs the optimizer cannot + proxy). Avatars and the site banner do use `next/image`; converting the featured + image properly starts with recording dimensions at upload time. - `react-hooks/rules-of-hooks`, `static-components`, `refs`, `purity`, `immutability` – `TiptapEditor` declares `EditorToolbar`/`ImageBubbleMenu` inside render, reads and writes refs in the render body, and builds its upload session id with `Date.now()`/`Math.random()`. Hoisting them changes editor initialisation order, so do it with the skipped Playwright specs enabled. -- `react-hooks/set-state-in-effect` – `CodeBlock`, `ImageLightbox`, - `RenderTimeBadge` and `PostForm` sync external state (theme attribute, portal - mount, `Server-Timing`, object-URL previews) with `setState` inside an effect. - `useSyncExternalStore` or event-driven resets are the intended replacements. +- `react-hooks/set-state-in-effect` – `ImageLightbox`, `RenderTimeBadge` and + `PostForm` sync external state (portal mount, `Server-Timing`, object-URL + previews) with `setState` inside an effect; `useSyncExternalStore` or event-driven + resets are the intended replacements. `CodeBlock` was the fourth case and is done: + it sampled `` once on mount, so toggling the theme left rendered + code in the old palette until reload (covered by + `tests/components/CodeBlockTheme.test.tsx`). - `react-hooks/purity` on the post page – it times its own server render with `performance.now()`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 065816c..ef8caaa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -67,18 +67,21 @@ const config = [ }, }, { - // STAGED ADOPTION (tech debt): these components render remote user-supplied or - // Gravatar images (``). Moving them to next/image first requires an - // `images.remotePatterns` policy, because the optimizer proxies those URLs. + // STAGED ADOPTION (tech debt): `` is deliberate in these five places, for + // different reasons: + // * Post featured image, comment attachments: uploads store no intrinsic + // dimensions, so next/image would need a fixed crop box instead of the + // natural-aspect layout these render with today. + // * ImageLightbox: shows the original bytes of arbitrary imported (WordPress) + // media; those hosts are not in images.remotePatterns on purpose. + // * PostForm / AppearanceManager previews: `blob:` object URLs of a file that + // has not been uploaded yet, which the optimizer cannot proxy. + // Avatars and the site banner already go through next/image; keep it that way. files: [ "src/app/\\(public\\)/\\[slug\\]/page.tsx", - "src/components/ArticleCard.tsx", - "src/components/Banner.tsx", "src/components/ImageLightbox.tsx", "src/components/admin/appearance/AppearanceManager.tsx", "src/components/admin/posts/PostForm.tsx", - "src/components/admin/users/UsersManager.tsx", - "src/components/auth/UserMenu.tsx", "src/components/comments/CommentNode.tsx", ], rules: { @@ -95,13 +98,13 @@ const config = [ }, }, { - // STAGED ADOPTION (tech debt): components that sync external state (theme - // attribute, portal mount, Server-Timing entries, upload preview) with setState - // inside an effect. React's compiler rules want useSyncExternalStore or - // event-driven resets here; each change alters rendering, so they are scheduled - // separately from this dependency refresh. + // STAGED ADOPTION (tech debt): components that sync external state (portal mount, + // Server-Timing entries, upload preview) with setState inside an effect. React's + // compiler rules want useSyncExternalStore or event-driven resets here; each + // change alters rendering, so they are scheduled separately. + // CodeBlock used to be here: it now subscribes to through + // useSyncExternalStore, which also fixed palettes not following ThemeToggle. files: [ - "src/components/CodeBlock.tsx", "src/components/ImageLightbox.tsx", "src/components/RenderTimeBadge.tsx", "src/components/admin/posts/PostForm.tsx", diff --git a/next.config.mjs b/next.config.mjs index a779522..b320aa3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -38,8 +38,12 @@ const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.unsplash.com' }, + // Avatars come from the only two OAuth providers (GitHub, Google) and render + // through next/image, so their hosts must be listed here. Google serves + // account photos from lh3..lh6.googleusercontent.com and ggpht mirrors, hence + // the wildcard instead of a single host. { protocol: 'https', hostname: 'avatars.githubusercontent.com' }, - { protocol: 'https', hostname: 'lh3.googleusercontent.com' }, + { protocol: 'https', hostname: '*.googleusercontent.com' }, { protocol: 'https', hostname: 'i.pravatar.cc' }, { protocol: 'https', hostname: 'stackoverflow.com' }, ...(s3Hostname ? [{ protocol: 'https', hostname: s3Hostname }] : []), diff --git a/package.json b/package.json index a635599..6389ffa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "narravo-next", "private": true, - "version": "1.0.3", + "version": "1.0.4", "license": "Apache-2.0", "engines": { "node": ">=22.13", diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index 155d358..cb84cd2 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -69,7 +69,14 @@ export default function ArticleCard({ post }: { post: ArticleCardPost }) { {post.author?.name && (
{post.author?.image && ( - + )} {post.author.name}
diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 9f75e5b..3912b03 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +import Image from "next/image"; import { ConfigServiceImpl } from "@/lib/config"; import { db } from "@/lib/db"; @@ -23,10 +24,13 @@ export default async function Banner() { return (
- {bannerAlt}` and ThemeToggle changes it in place, + * without a navigation, so the highlighter palette has to follow that attribute + * instead of sampling it once on mount. useSyncExternalStore keeps the read out of + * render and re-renders on attribute or OS-preference changes. + */ +function isDarkTheme(): boolean { + const attr = document.documentElement.getAttribute("data-theme"); + if (attr === "dark") return true; + if (attr === "light") return false; + // No explicit choice recorded: fall back to the OS preference. + return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function subscribeToTheme(onStoreChange: () => void): () => void { + const observer = new MutationObserver(onStoreChange); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + const media = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null; + media?.addEventListener("change", onStoreChange); + return () => { + observer.disconnect(); + media?.removeEventListener("change", onStoreChange); + }; +} + export default function CodeBlock({ children, className = "", language = "", showLineNumbers = false }: CodeBlockProps) { - const [isDark, setIsDark] = React.useState(false); - React.useEffect(() => { - const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || - (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && document.documentElement.getAttribute('data-theme') !== 'light'); - setIsDark(isDarkMode); - }, []); + // Server output always uses the light palette; the client store takes over at hydration. + const isDark = React.useSyncExternalStore(subscribeToTheme, isDarkTheme, () => false); // Extract language from className if provided (e.g., "lang-javascript" or "language-javascript") const detectedLanguage = language || diff --git a/src/components/admin/users/UsersManager.tsx b/src/components/admin/users/UsersManager.tsx index 416290b..f377866 100644 --- a/src/components/admin/users/UsersManager.tsx +++ b/src/components/admin/users/UsersManager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; +import Image from "next/image"; import { useDateFormat } from "@/lib/dateFormat.client"; import { formatDateSafe } from "@/lib/dateFormat"; import logger from '@/lib/logger'; @@ -281,9 +282,12 @@ export default function UsersManager({ initialData, filter, sort }: UsersManager
{user.image && ( - {user.name )} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 27df3ba..83102e6 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -1,6 +1,7 @@ "use client"; // SPDX-License-Identifier: Apache-2.0 import * as React from "react"; +import Image from "next/image"; import { signOut } from "next-auth/react"; type Props = { @@ -11,8 +12,8 @@ function Avatar({ user }: Props) { const src = user.image ?? undefined; const initials = (user.name || user.email || "?").trim().slice(0, 2).toUpperCase(); return ( -
- {src ? : {initials}} +
+ {src ? : {initials}}
); } diff --git a/tests/components/CodeBlockTheme.test.tsx b/tests/components/CodeBlockTheme.test.tsx new file mode 100644 index 0000000..b17a70e --- /dev/null +++ b/tests/components/CodeBlockTheme.test.tsx @@ -0,0 +1,34 @@ +/** @vitest-environment jsdom */ + +// SPDX-License-Identifier: Apache-2.0 +// The active theme is an attribute on that ThemeToggle rewrites in place, so +// the highlighter palette must follow it. It used to sample data-theme once on +// mount, which left already-rendered code blocks in the old palette until reload. +import "@testing-library/jest-dom/vitest"; +import React from "react"; +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import CodeBlock from "@/components/CodeBlock"; + +describe("CodeBlock palette follows the document theme", () => { + afterEach(() => { + cleanup(); + document.documentElement.removeAttribute("data-theme"); + }); + + it("re-tints when data-theme flips without a navigation", async () => { + document.documentElement.dataset.theme = "light"; + const { container } = render( + {"const answer = 42;"} + ); + const pre = container.querySelector("pre"); + if (!pre) throw new Error("CodeBlock did not render a
");
+
+    expect(pre.style.backgroundColor).toBe("white");
+
+    document.documentElement.dataset.theme = "dark";
+
+    // MutationObserver delivers on a microtask, so poll for the re-render.
+    await expect.poll(() => pre.style.backgroundColor).toBe("rgb(30, 30, 30)");
+  });
+});
diff --git a/tests/components/auth/UserMenu.test.tsx b/tests/components/auth/UserMenu.test.tsx
index 3399f21..3fd5f93 100644
--- a/tests/components/auth/UserMenu.test.tsx
+++ b/tests/components/auth/UserMenu.test.tsx
@@ -36,7 +36,11 @@ describe("UserMenu", () => {
 
       const img = screen.getByAltText("");
       expect(img).toBeInTheDocument();
-      expect(img).toHaveAttribute("src", "https://example.com/avatar.jpg");
+      // The avatar goes through next/image, so `src` is the optimizer URL and the
+      // original avatar lives in its `url` parameter; `sizes` is what keeps the
+      // fetched variant small for a 36px box.
+      expect(decodeURIComponent(img.getAttribute("src") ?? "")).toContain("https://example.com/avatar.jpg");
+      expect(img).toHaveAttribute("sizes", "36px");
     });
 
     it("should display initials when image is not provided", () => {