Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html data-theme>` 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 `<img>`. `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
`&quot;`/`&apos;`.
- 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 `<html data-theme>` 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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 14 additions & 8 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>`. 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 `<img>`: 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 `<html data-theme>` 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()`.

Expand Down
29 changes: 16 additions & 13 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,21 @@ const config = [
},
},
{
// STAGED ADOPTION (tech debt): these components render remote user-supplied or
// Gravatar images (`<img>`). Moving them to next/image first requires an
// `images.remotePatterns` policy, because the optimizer proxies those URLs.
// STAGED ADOPTION (tech debt): `<img>` 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: {
Expand All @@ -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 <html data-theme> 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",
Expand Down
6 changes: 5 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] : []),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/components/ArticleCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export default function ArticleCard({ post }: { post: ArticleCardPost }) {
{post.author?.name && (
<div className="mt-3 flex items-center gap-2 text-xs text-muted">
{post.author?.image && (
<img src={post.author.image} alt="" className="h-5 w-5 rounded-full" />
<Image
src={post.author.image}
alt=""
width={20}
height={20}
sizes="20px"
className="h-5 w-5 rounded-full"
/>
)}
<span className="font-medium text-fg">{post.author.name}</span>
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/components/Banner.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -23,10 +24,13 @@ export default async function Banner() {

return (
<div className="relative w-full h-64 overflow-hidden">
<img
<Image
src={bannerImageUrl}
alt={bannerAlt}
className="w-full h-full object-cover"
fill
priority
sizes="100vw"
className="object-cover"
style={{
objectPosition: `${bannerFocalX * 100}% ${bannerFocalY * 100}%`,
}}
Expand Down
33 changes: 27 additions & 6 deletions src/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,39 @@ interface CodeBlockProps {
showLineNumbers?: boolean;
}

/**
* The active theme lives on `<html data-theme>` 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 ||
Expand Down
8 changes: 6 additions & 2 deletions src/components/admin/users/UsersManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -281,9 +282,12 @@ export default function UsersManager({ initialData, filter, sort }: UsersManager
<td className="px-3 py-3">
<div className="flex items-center gap-3">
{user.image && (
<img
src={user.image}
<Image
src={user.image}
alt={user.name || "User avatar"}
width={32}
height={32}
sizes="32px"
className="w-8 h-8 rounded-full"
/>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/components/auth/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 (
<div className="inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-border bg-card text-sm font-semibold">
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : <span>{initials}</span>}
<div className="relative inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-border bg-card text-sm font-semibold">
{src ? <Image src={src} alt="" fill sizes="36px" className="object-cover" /> : <span>{initials}</span>}
</div>
);
}
Expand Down
34 changes: 34 additions & 0 deletions tests/components/CodeBlockTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** @vitest-environment jsdom */

// SPDX-License-Identifier: Apache-2.0
// The active theme is an attribute on <html> 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(
<CodeBlock language="typescript">{"const answer = 42;"}</CodeBlock>
);
const pre = container.querySelector("pre");
if (!pre) throw new Error("CodeBlock did not render a <pre>");

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)");
});
});
Loading
Loading