diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 148e221..9d314eb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,12 @@ updates: minor-and-patch: patterns: ["*"] update-types: [minor, patch] + ignore: + # eslint-config-next depends on eslint-plugin-react, which caps its eslint + # peer at ^9. Under eslint 10 the plugin throws at rule-load time and + # linting cannot run. Drop this once the plugin supports eslint 10. + - dependency-name: eslint + update-types: [version-update:semver-major] open-pull-requests-limit: 5 labels: - dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f3185ae..b1d4761 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 714fdc8..5f0d440 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Dependency Review uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index 8a49c3b..9b1ac14 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -28,18 +28,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' cache: 'npm' - name: Restore Next.js cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx', '**.[jt]s') }} @@ -58,7 +58,7 @@ jobs: run: npx tsc --noEmit - name: Setup uv - uses: astral-sh/setup-uv@v8.2.0 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true @@ -90,7 +90,7 @@ jobs: echo "- **Output:** \`out/\`" >> $GITHUB_STEP_SUMMARY - name: Upload Pages artifact - if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + if: github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v5 with: path: out/ @@ -98,7 +98,9 @@ jobs: deploy: name: Deploy to GitHub Pages needs: build - if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + # Deploy only from main. workflow_dispatch on main still matches this ref; + # dispatching from any other branch builds but does not publish. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/app/docs/[...mdxPath]/page.tsx b/app/docs/[...mdxPath]/page.tsx index 2875c79..9a2bc19 100644 --- a/app/docs/[...mdxPath]/page.tsx +++ b/app/docs/[...mdxPath]/page.tsx @@ -1,6 +1,8 @@ import { importPage } from 'nextra/pages' import { getPageMap } from 'nextra/page-map' -import { useMDXComponents } from 'nextra-theme-docs' +// Aliased on import: this is a nextra factory, not a React hook. Under the +// use* name react-hooks/rules-of-hooks rejects it inside an async component. +import { useMDXComponents as getMDXComponents } from 'nextra-theme-docs' type PageMapItem = { route?: string @@ -40,7 +42,7 @@ export default async function Page(props: { const params = await props.params const result = await importPage(['docs', ...params.mdxPath]) const { default: MDXContent, toc, metadata, ...rest } = result - const { wrapper: Wrapper } = useMDXComponents() + const { wrapper: Wrapper } = getMDXComponents() return ( diff --git a/app/docs/page.tsx b/app/docs/page.tsx index 1494eb8..156c4b7 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -1,5 +1,7 @@ import { importPage } from 'nextra/pages' -import { useMDXComponents } from 'nextra-theme-docs' +// Aliased on import: this is a nextra factory, not a React hook. Under the +// use* name react-hooks/rules-of-hooks rejects it inside an async component. +import { useMDXComponents as getMDXComponents } from 'nextra-theme-docs' // importPage resolves from the content root, so ['docs'] → content/docs/index.mdx export async function generateMetadata() { @@ -10,7 +12,7 @@ export async function generateMetadata() { export default async function DocsPage() { const result = await importPage(['docs']) const { default: MDXContent, toc, metadata, ...rest } = result - const { wrapper: Wrapper } = useMDXComponents() + const { wrapper: Wrapper } = getMDXComponents() return ( diff --git a/app/page.tsx b/app/page.tsx index 8b2eef5..403a3ed 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,5 @@ +import Link from 'next/link'; + import { HeroLogo } from '@/components/hero-logo'; import { Terminal } from '@/components/terminal'; import { StandardsBand } from '@/components/standards-band'; @@ -17,7 +19,7 @@ export default function HomePage() { covering DevOps, platform engineering, and security.

- Browse Cheatsheets + Browse Cheatsheets { setMounted(true); }, []); + const mounted = useMounted(); const wrapperStyle: React.CSSProperties = { display: 'flex', diff --git a/components/hero-logo.tsx b/components/hero-logo.tsx index 7e9d405..c117286 100644 --- a/components/hero-logo.tsx +++ b/components/hero-logo.tsx @@ -2,13 +2,12 @@ import Image from 'next/image'; import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; + +import { useMounted } from '@/lib/hooks'; export function HeroLogo() { const { resolvedTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { setMounted(true); }, []); + const mounted = useMounted(); if (!mounted) { // Placeholder matches rendered size to avoid layout shift diff --git a/components/navbar-toggle.tsx b/components/navbar-toggle.tsx index be86b6c..17cd56b 100644 --- a/components/navbar-toggle.tsx +++ b/components/navbar-toggle.tsx @@ -1,17 +1,25 @@ 'use client'; import { Eye, EyeOff } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; -const STORAGE_KEY = 'docs-banner-hidden'; +import { + getBannerHidden, + getBannerHiddenOnServer, + setBannerHidden, + subscribeBannerHidden, +} from '@/lib/banner'; export function NavbarToggle() { - const [hidden, setHidden] = useState(false); + const hidden = useSyncExternalStore( + subscribeBannerHidden, + getBannerHidden, + getBannerHiddenOnServer, + ); + // Mirror the preference onto the DOM, which React does not own. useEffect(() => { - const stored = localStorage.getItem(STORAGE_KEY) === '1'; - setHidden(stored); - document.body.classList.toggle('banner-hidden', stored); + document.body.classList.toggle('banner-hidden', hidden); // The preload class on was set by an inline script before React // hydrated; hand control back to the body class now. document.documentElement.classList.remove('banner-hidden-preload'); @@ -22,30 +30,20 @@ export function NavbarToggle() { return () => { document.body.classList.remove('banner-hidden'); }; - }, []); + }, [hidden]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === '\\') { e.preventDefault(); - setHidden(prev => { - const next = !prev; - localStorage.setItem(STORAGE_KEY, next ? '1' : '0'); - document.body.classList.toggle('banner-hidden', next); - return next; - }); + setBannerHidden(!getBannerHidden()); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); - const toggle = () => { - const next = !hidden; - setHidden(next); - localStorage.setItem(STORAGE_KEY, next ? '1' : '0'); - document.body.classList.toggle('banner-hidden', next); - }; + const toggle = () => setBannerHidden(!hidden); return (
- + Explore documents - +

See also the{' '} - Azure Naming Convention - +

); diff --git a/components/terminal.tsx b/components/terminal.tsx index f685dbb..e3c1d06 100644 --- a/components/terminal.tsx +++ b/components/terminal.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; +import { useMounted, usePrefersReducedMotion } from '@/lib/hooks'; + const commands: { cmd: string; output: string }[] = [ { cmd: 'terraform plan -out=tfplan', output: 'Plan: 12 to add, 0 to change, 0 to destroy.' }, { cmd: 'trivy config ./terraform', output: 'Tests: 24 (SUCCESSES: 24, FAILURES: 0)' }, @@ -27,24 +29,20 @@ type Phase = 'typing' | 'output' | 'pause'; export function Terminal() { const ref = useRef(null); + const mounted = useMounted(); + const reducedMotion = usePrefersReducedMotion(); + const animate = mounted && !reducedMotion; + // Reduced motion gets a single fully-typed frame. Before hydration we still + // render the empty prompt the animation starts from, so nothing flashes. + const staticFrame = mounted && reducedMotion; + const [cmdIdx, setCmdIdx] = useState(0); const [typed, setTyped] = useState(''); const [showOutput, setShowOutput] = useState(false); const [phase, setPhase] = useState('typing'); const [cursorOn, setCursorOn] = useState(true); - const [animate, setAnimate] = useState(false); // motion allowed const [visible, setVisible] = useState(true); // intersecting viewport - // Respect reduced motion: show one static frame instead of animating. - useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - setTyped(commands[0].cmd); - setShowOutput(true); - return; - } - setAnimate(true); - }, []); - // Pause all work while the terminal is scrolled out of view. useEffect(() => { const el = ref.current; @@ -107,16 +105,16 @@ export function Terminal() {
$ - {typed} + {staticFrame ? commands[0].cmd : typed}
- {showOutput && ( + {(staticFrame || showOutput) && (
- {commands[cmdIdx].output} + {commands[staticFrame ? 0 : cmdIdx].output}
)} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index 034b595..1f70027 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -2,13 +2,12 @@ import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; + +import { useMounted } from '@/lib/hooks'; export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => setMounted(true), []); + const mounted = useMounted(); if (!mounted) { return