diff --git a/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json b/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json
new file mode 100644
index 0000000..28d351b
--- /dev/null
+++ b/.changepacks/changepack_log_LFQOSLDfupkI-XCeGIEoj.json
@@ -0,0 +1,5 @@
+{
+ "changes": { "package.json": "Minor" },
+ "note": "Add bun-test-env-dom/next, a preload that stands in for every public next/* module and lets tests set the URL, params, headers and cookies and assert on navigation",
+ "date": "2026-09-23T12:32:26.385Z"
+}
diff --git a/README.md b/README.md
index 1c34522..ab239cc 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,57 @@ test('custom matchers', () => {
})
```
+## Next.js
+
+`bun-test-env-dom/next` replaces every public `next/*` module with a stand-in that needs no Next.js runtime, router or compiler - Next.js does not even have to be installed. Preload it after the DOM:
+
+```toml
+[test]
+preload = ["bun-test-env-dom", "bun-test-env-dom/next"]
+```
+
+Your code keeps importing from `next/*`. Tests set the page and check navigation through `bun-test-env-dom/next`:
+
+```tsx
+import { expect, test } from 'bun:test'
+import { render, screen, userEvent } from 'bun-test-env-dom'
+import { router, setParams, setUrl } from 'bun-test-env-dom/next'
+
+test('opens the next post', async () => {
+ setUrl('/posts/1?tab=comments')
+ setParams({ id: '1' })
+ render()
+ await userEvent.click(screen.getByRole('link', { name: 'Next' }))
+ expect(router.push).toHaveBeenCalledWith('/posts/2', { scroll: true })
+})
+```
+
+Everything below is reset before each test:
+
+| Export | What it controls |
+| --- | --- |
+| `router` | What `useRouter()` from `next/navigation` returns. Its methods are mocks, and `next/link` and `next/form` navigate through them. |
+| `setUrl(url)` | `usePathname()`, `useSearchParams()` and the Pages Router's URL. A relative URL resolves against the current one. |
+| `setParams(params)` | `useParams()` and the Pages Router's `query`. |
+| `setRoute(route)` | The Pages Router's `pathname` and `route`, like `/posts/[id]`. |
+| `setSegments(segments)` | `useSelectedLayoutSegments()`, which otherwise splits the path. |
+| `setHeaders(init)`, `setCookies(cookies)`, `setDraftMode(enabled)` | `headers()`, `cookies()` and `draftMode()` from `next/headers`. |
+| `setOffline(offline)` | `useOffline()` from `next/offline`. |
+| `setRootParams(params)` | The getters `next/root-params` exports. |
+| `resetNext()` | All of the above, and every mock. |
+
+What the stand-ins do:
+
+- Navigation is recorded, not performed: `router.push` changes neither the URL nor what is rendered. Clicking a `next/link`, or submitting a `next/form` with a string `action`, calls `router.push` (or `replace`) wherever Next.js would navigate client-side. Modified clicks, other targets, downloads and other origins are left to the browser.
+- `next/link`, `next/image`, `next/form` and `next/error` render the same markup as Next.js 16, so snapshots survive a change of framework underneath. `next/image` renders images as `unoptimized`: never through `/_next/image`.
+- `redirect`, `notFound`, `forbidden` and `unauthorized` throw errors with the `digest` Next.js gives them.
+- `next/script` renders nothing and never loads a script. `next/dynamic` is `React.lazy`: wait for it with `findBy*`.
+- `next/font` returns stable class names: `Inter()` has the class `__className_inter`.
+- `next/cache` functions are mocks, and `unstable_cache` caches nothing.
+- `NextRequest` and `NextResponse` from `next/server` keep the `cookie` and `set-cookie` headers that happy-dom's `Request` and `Response` drop.
+- `next/router` is the Pages Router, with mock methods. `next/compat/router` returns `null`, as in the App Router, until `setRoute` is called.
+- `next/root-params` exports the names given to `setRootParams`. A module's imports are linked when it loads, so for a module that imports them statically, call `setRootParams` in a preload.
+
## License
Apache-2.0
diff --git a/bun.lock b/bun.lock
index d91f188..23a89a2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,6 +18,9 @@
"husky": "^9.1.7",
"typescript": "latest",
},
+ "peerDependencies": {
+ "react": ">=18",
+ },
},
},
"packages": {
diff --git a/bunfig.toml b/bunfig.toml
index b3489ab..52ceaef 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,5 +1,5 @@
[test]
-preload = ["./src/index.ts"]
+preload = ["./src/index.ts", "./src/next/index.ts"]
coverage = true
coverageSkipTestFiles = true
coverageThreshold = 1.0
diff --git a/package.json b/package.json
index 643d7af..07f7443 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,16 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
+ },
+ "./next": {
+ "import": {
+ "types": "./dist/next/index.d.ts",
+ "default": "./dist/next.mjs"
+ },
+ "require": {
+ "types": "./dist/next/index.d.ts",
+ "default": "./dist/next.cjs"
+ }
}
},
"types": "./dist/index.d.ts",
@@ -45,11 +55,14 @@
"@testing-library/react": ">=16.3",
"@testing-library/user-event": ">=14.6"
},
+ "peerDependencies": {
+ "react": ">=18"
+ },
"scripts": {
"lint:fix": "biome check --write .",
"lint": "biome check .",
"test": "bun test",
- "build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun scripts/write-esm-entry.ts",
+ "build": "tsc && bun scripts/clean-dts.ts && bun build --target node src/index.ts --production --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/next/index.ts --production --outfile dist/next.cjs --format cjs --packages external && bun scripts/write-esm-entry.ts",
"prepare": "bun run husky"
}
}
diff --git a/scripts/generate-google-fonts.ts b/scripts/generate-google-fonts.ts
new file mode 100644
index 0000000..5be5033
--- /dev/null
+++ b/scripts/generate-google-fonts.ts
@@ -0,0 +1,30 @@
+/**
+ * Regenerates `src/next/google-fonts.ts`, the export names of `next/font/google`.
+ *
+ * Next.js compiles `next/font/google` calls away at build time, so the module
+ * has no runtime exports to copy. Its declarations are the only complete list
+ * of font functions, and a named import only links when the name is listed.
+ *
+ * Usage: bun scripts/generate-google-fonts.ts [next version, default latest]
+ */
+const version = process.argv[2] ?? 'latest'
+const response = await fetch(
+ `https://unpkg.com/next@${version}/dist/compiled/@next/font/dist/google/index.d.ts`,
+)
+if (!response.ok) {
+ throw new Error(`Could not download the font list: HTTP ${response.status}`)
+}
+
+const names = [
+ ...(await response.text()).matchAll(/export declare function (\w+)/g),
+].map(([, name]) => name)
+if (names.length === 0) {
+ throw new Error(`No font functions in ${response.url}`)
+}
+
+const resolved = response.url.match(/next@([^/]+)/)?.[1] ?? version
+await Bun.write(
+ `${import.meta.dirname}/../src/next/google-fonts.ts`,
+ `// Generated by scripts/generate-google-fonts.ts from next@${resolved}. Do not edit.\nexport const GOOGLE_FONTS = [\n${names.map((name) => ` '${name}',`).join('\n')}\n]\n`,
+)
+console.info(`${names.length} fonts from next@${resolved}`)
diff --git a/scripts/write-esm-entry.ts b/scripts/write-esm-entry.ts
index c155777..1cd3a0a 100644
--- a/scripts/write-esm-entry.ts
+++ b/scripts/write-esm-entry.ts
@@ -1,5 +1,6 @@
/**
- * Emits `dist/index.mjs` as a re-export of `dist/index.cjs`.
+ * Emits `dist/index.mjs` and `dist/next.mjs` as re-exports of their CJS
+ * builds.
*
* The ESM entry cannot be bundled from `src/index.ts` on its own. bun evaluates
* the CJS dependencies of a module graph while it is being linked, before any
@@ -10,6 +11,8 @@
*
* Re-exporting the CJS build keeps a real ESM entry point while leaving the
* ordering to `require`, which is an ordinary runtime call and therefore
- * happens in source order.
+ * happens in source order. It also means a preload and a test that import
+ * the same entry share one copy of it.
*/
await Bun.write('dist/index.mjs', "export * from './index.cjs'\n")
+await Bun.write('dist/next.mjs', "export * from './next.cjs'\n")
diff --git a/src/__tests__/dist-preload.test.ts b/src/__tests__/dist-preload.test.ts
index aebe1ec..13c084e 100644
--- a/src/__tests__/dist-preload.test.ts
+++ b/src/__tests__/dist-preload.test.ts
@@ -26,27 +26,35 @@ const spec = (path: string) => JSON.stringify(path.replaceAll('\\', '/'))
* came out as user-event's whole module namespace (1.0.4), where
* `userEvent.type` is undefined. So the fixture also types through the
* `userEvent` it imports from the built entry.
+ *
+ * `bun-test-env-dom/next` is checked the same way, from a directory without
+ * Next.js: the stand-ins have to resolve `next/*` on their own.
*/
function buildArtifacts() {
- const bundle = Bun.spawnSync(
- [
- 'bun',
- 'build',
- '--target',
- 'node',
- 'src/index.ts',
- '--production',
- '--outfile',
- 'dist/index.cjs',
- '--format',
- 'cjs',
- '--packages',
- 'external',
- ],
- { cwd: root, stdout: 'pipe', stderr: 'pipe' },
- )
- expect(bundle.exitCode).toBe(0)
+ for (const [source, outfile] of [
+ ['src/index.ts', 'dist/index.cjs'],
+ ['src/next/index.ts', 'dist/next.cjs'],
+ ]) {
+ const bundle = Bun.spawnSync(
+ [
+ 'bun',
+ 'build',
+ '--target',
+ 'node',
+ source as string,
+ '--production',
+ '--outfile',
+ outfile as string,
+ '--format',
+ 'cjs',
+ '--packages',
+ 'external',
+ ],
+ { cwd: root, stdout: 'pipe', stderr: 'pipe' },
+ )
+ expect(bundle.exitCode).toBe(0)
+ }
const esmEntry = Bun.spawnSync(['bun', 'scripts/write-esm-entry.ts'], {
cwd: root,
@@ -56,26 +64,52 @@ function buildArtifacts() {
expect(esmEntry.exitCode).toBe(0)
}
-function preloadAndType(entry: string) {
+/**
+ * Runs `source` as a test file that preloads `preload`. The fixture lives
+ * outside the package so this suite never collects it, which rules out bare
+ * specifiers of installed packages and JSX - both resolve from the file's own
+ * directory. Absolute specifiers and `createElement` keep it portable.
+ */
+function runFixture(preload: string[], source: string) {
const dir = mkdtempSync(join(tmpdir(), 'bun-test-env-dom-'))
try {
- // The fixture lives outside the package so this suite never collects it,
- // which rules out bare specifiers and JSX - both resolve from the file's
- // own directory. Absolute specifiers and `createElement` keep it portable.
- const reactPath = Bun.resolveSync('react', root)
- const rtlPath = Bun.resolveSync('@testing-library/react', root)
- const entryPath = resolve(root, entry)
-
writeFileSync(
join(dir, 'bunfig.toml'),
- `[test]\npreload = [${spec(entryPath)}]\ncoverage = false\n`,
+ `[test]\npreload = [${preload.map((entry) => spec(resolve(root, entry))).join(', ')}]\ncoverage = false\n`,
)
- writeFileSync(
- join(dir, 'order.test.ts'),
- `import { expect, mock, test } from 'bun:test'
-import { fireEvent, render } from ${spec(rtlPath)}
-import { createElement, useState } from ${spec(reactPath)}
-import { userEvent } from ${spec(entryPath)}
+ writeFileSync(join(dir, 'fixture.test.ts'), source)
+ const run = Bun.spawnSync(
+ [
+ 'bun',
+ `--config=${join(dir, 'bunfig.toml')}`,
+ 'test',
+ join(dir, 'fixture.test.ts'),
+ ],
+ { cwd: root, stdout: 'pipe', stderr: 'pipe' },
+ )
+ return {
+ output: decode(run.stderr) + decode(run.stdout),
+ exitCode: run.exitCode,
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+}
+
+const reactPath = spec(Bun.resolveSync('react', root))
+const rtlPath = spec(Bun.resolveSync('@testing-library/react', root))
+
+test.each([
+ 'dist/index.cjs',
+ 'dist/index.mjs',
+])('%s registers a DOM first and re-exports a usable userEvent', (entry) => {
+ buildArtifacts()
+ const { output, exitCode } = runFixture(
+ [entry],
+ `import { expect, mock, test } from 'bun:test'
+import { fireEvent, render } from ${rtlPath}
+import { createElement, useState } from ${reactPath}
+import { userEvent } from ${spec(resolve(root, entry))}
function Field({ onSubmit }: { onSubmit: (value: string) => void }) {
const [value, setValue] = useState('')
@@ -114,32 +148,40 @@ test('the re-exported userEvent types into the React field', async () => {
expect(onSubmit).toHaveBeenCalledWith('typed')
})
`,
- )
-
- const run = Bun.spawnSync(
- [
- 'bun',
- `--config=${join(dir, 'bunfig.toml')}`,
- 'test',
- join(dir, 'order.test.ts'),
- ],
- { cwd: root, stdout: 'pipe', stderr: 'pipe' },
- )
- return {
- output: decode(run.stderr) + decode(run.stdout),
- exitCode: run.exitCode,
- }
- } finally {
- rmSync(dir, { recursive: true, force: true })
- }
-}
+ )
+ expect(output).toContain('2 pass')
+ expect(output).toContain('0 fail')
+ expect(exitCode).toBe(0)
+})
test.each([
- 'dist/index.cjs',
- 'dist/index.mjs',
-])('%s registers a DOM first and re-exports a usable userEvent', (entry) => {
+ 'dist/next.cjs',
+ 'dist/next.mjs',
+])('%s stands in for next/* where Next.js is not installed', (entry) => {
buildArtifacts()
- const { output, exitCode } = preloadAndType(entry)
+ const { output, exitCode } = runFixture(
+ ['dist/index.cjs', entry],
+ `import { expect, test } from 'bun:test'
+import { fireEvent, render } from ${rtlPath}
+import { createElement } from ${reactPath}
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { router, setUrl } from ${spec(resolve(root, entry))}
+
+test('next/link navigates through the router a test imports', () => {
+ const view = render(createElement(Link, { href: '/about' }, 'About'))
+ fireEvent.click(view.getByText('About'))
+ expect(router.push).toHaveBeenCalledWith('/about', { scroll: true })
+ expect(useRouter()).toBe(router)
+})
+
+test('next/navigation reads the URL a test sets, from a clean state', () => {
+ expect(router.push).not.toHaveBeenCalled()
+ setUrl('/posts?page=2')
+ expect(usePathname()).toBe('/posts')
+})
+`,
+ )
expect(output).toContain('2 pass')
expect(output).toContain('0 fail')
expect(exitCode).toBe(0)
diff --git a/src/__tests__/next-components.test.tsx b/src/__tests__/next-components.test.tsx
new file mode 100644
index 0000000..ef9647b
--- /dev/null
+++ b/src/__tests__/next-components.test.tsx
@@ -0,0 +1,472 @@
+import { describe, expect, mock, spyOn, test } from 'bun:test'
+import { Component, type ReactNode } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { fireEvent, render, renderHook, waitFor } from '../index.ts'
+import App from '../next/app.tsx'
+import Document, { Html } from '../next/document.tsx'
+import dynamic, { noSSR } from '../next/dynamic.tsx'
+import ErrorPage, { catchError } from '../next/error.tsx'
+import localFont, { googleFonts } from '../next/font.ts'
+import Form from '../next/form.tsx'
+import Head, { defaultHead } from '../next/head.tsx'
+import { router, setOffline, setUrl } from '../next/index.ts'
+import { notFound } from '../next/navigation.ts'
+import {
+ dispatchOfflineChange,
+ OfflineProvider,
+ useOffline,
+} from '../next/offline.ts'
+import Script, {
+ handleClientScriptLoad,
+ initScriptLoader,
+} from '../next/script.ts'
+import { useReportWebVitals } from '../next/web-vitals.ts'
+
+describe('next/script', () => {
+ test('renders nothing and never loads', () => {
+ const onLoad = mock(() => {})
+ const { container } = render(
+ ,
+ )
+ expect(container.innerHTML).toBe('')
+ handleClientScriptLoad({ src: '/a.js' })
+ initScriptLoader([{ src: '/a.js' }])
+ expect(onLoad).not.toHaveBeenCalled()
+ })
+})
+
+describe('next/head', () => {
+ test('puts its children in the document head', () => {
+ const { container } = render(
+
+ Title
+
+ ,
+ )
+ expect(container.innerHTML).toBe('')
+ expect(document.title).toBe('Title')
+ expect(
+ document.head.querySelector('meta[name="description"]'),
+ ).not.toBeNull()
+ })
+
+ test('defaultHead is the charset and viewport', () => {
+ expect(defaultHead().map((element) => element.props)).toEqual([
+ { charSet: 'utf-8' },
+ { content: 'width=device-width', name: 'viewport' },
+ ])
+ })
+})
+
+describe('next/dynamic', () => {
+ function Hello({ name }: { name: string }) {
+ return Hello {name}
+ }
+
+ test.each([
+ ['a loader of a module', () => dynamic(async () => ({ default: Hello }))],
+ ['a loader of a component', () => dynamic(async () => Hello)],
+ ['a promise', () => dynamic(Promise.resolve({ default: Hello }))],
+ ['options', () => noSSR({ loader: async () => Hello, ssr: false })],
+ ])('renders the component from %s', async (_, create) => {
+ const Lazy = create()
+ const view = render()
+ expect(await view.findByText('Hello there')).toBeTruthy()
+ })
+
+ test('shows `loading` until the module resolves', async () => {
+ const Lazy = dynamic(async () => Hello, {
+ loading: ({ isLoading }) => {isLoading ? 'Loading' : ''}
,
+ })
+ const view = render()
+ expect(view.getByText('Loading')).toBeTruthy()
+ expect(await view.findByText('Hello there')).toBeTruthy()
+ })
+})
+
+describe('next/form', () => {
+ // Submits `form` and reports whether the submit was handled in the page;
+ // the browser's own submission is cancelled, so happy-dom stays put.
+ function submit(form: HTMLFormElement, submitter?: HTMLElement) {
+ let prevented: boolean | undefined
+ const listener = (event: Event) => {
+ prevented = event.defaultPrevented
+ event.preventDefault()
+ }
+ document.addEventListener('submit', listener)
+ fireEvent(
+ form,
+ new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter }),
+ )
+ document.removeEventListener('submit', listener)
+ return prevented
+ }
+
+ function Search(props: Partial[0]>) {
+ return (
+
+ )
+ }
+
+ test('renders a form without method, encType or target', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.innerHTML).toBe(
+ '',
+ )
+ })
+
+ test('navigates to the action with the fields as its query', () => {
+ const view = render()
+ const form = view.container.querySelector('form') as HTMLFormElement
+ expect(submit(form)).toBe(true)
+ expect(router.push).toHaveBeenCalledWith('/search?q=a+b', { scroll: true })
+ })
+
+ test('replaces, keeps the scroll position, and drops the query', () => {
+ const view = render(
+ ,
+ )
+ submit(view.container.querySelector('form') as HTMLFormElement)
+ expect(router.replace).toHaveBeenCalledWith('/search?q=x#hash', {
+ scroll: false,
+ })
+ })
+
+ test('follows the formAction of the submit button', () => {
+ const view = render()
+ const form = view.container.querySelector('form') as HTMLFormElement
+ submit(form, view.getByText('go'))
+ submit(form, view.getByText('other'))
+ expect(router.push.mock.calls).toEqual([
+ ['/search?q=a+b', { scroll: true }],
+ ['/other?q=a+b', { scroll: true }],
+ ])
+ })
+
+ test('leaves other submit buttons, and onSubmit cancels, to the page', () => {
+ const view = render(
+
+ event.currentTarget.dataset.cancel && event.preventDefault()
+ }
+ />,
+ )
+ const form = view.container.querySelector('form') as HTMLFormElement
+ expect(submit(form, view.getByText('post'))).toBe(false)
+ expect(submit(form, view.getByText('script'))).toBe(false)
+ form.dataset.cancel = 'yes'
+ expect(submit(form)).toBe(true)
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test('navigates to another origin with the full URL, files by name', () => {
+ const view = render(
+ ,
+ )
+ const files = new DataTransfer()
+ files.items.add(new File(['x'], 'a.txt'))
+ const input = view.container.querySelector('input') as HTMLInputElement
+ input.files = files.files
+ submit(view.container.querySelector('form') as HTMLFormElement)
+ expect(router.push).toHaveBeenCalledWith(
+ 'https://example.com/upload?file=a.txt',
+ { scroll: true },
+ )
+ })
+
+ test('leaves a function action to React', async () => {
+ const action = mock(async (_formData: FormData) => {})
+ const view = render(
+ ,
+ )
+ submit(view.container.querySelector('form') as HTMLFormElement)
+ await waitFor(() => expect(action).toHaveBeenCalledTimes(1))
+ expect(action.mock.calls[0]?.[0].get('q')).toBe('x')
+ expect(router.push).not.toHaveBeenCalled()
+ })
+})
+
+describe('next/font', () => {
+ test('gives a Google font stable names', () => {
+ expect(
+ googleFonts.Roboto_Mono?.({
+ weight: '700',
+ style: 'italic',
+ variable: '--font-mono',
+ }),
+ ).toEqual({
+ className: '__className_roboto_mono',
+ style: {
+ fontFamily: "'Roboto Mono', 'Roboto Mono Fallback'",
+ fontWeight: 700,
+ fontStyle: 'italic',
+ },
+ variable: '__variable_roboto_mono',
+ })
+ })
+
+ test('sets a weight or style only when exactly one is loaded', () => {
+ expect(googleFonts.Inter?.({ weight: ['400', '700'] }).style).toEqual({
+ fontFamily: "'Inter', 'Inter Fallback'",
+ })
+ expect(googleFonts.Inter?.({ weight: 'variable' }).style).toEqual({
+ fontFamily: "'Inter', 'Inter Fallback'",
+ })
+ expect(googleFonts.Inter?.({ weight: ['500'] }).style.fontWeight).toBe(500)
+ })
+
+ test('names a local font after its first file', () => {
+ expect(localFont({ src: './fonts/My-Font.woff2' }).className).toBe(
+ '__className_my_font',
+ )
+ expect(
+ localFont({ src: [{ path: '../a/Brand.ttf' }, { path: './b.ttf' }] })
+ .style,
+ ).toEqual({ fontFamily: "'Brand', 'Brand Fallback'" })
+ expect(localFont({ src: [] }).className).toBe('__className_local')
+ })
+})
+
+describe('next/error', () => {
+ test('renders the status page of Next.js', () => {
+ const view = render()
+ expect(view.getByRole('heading', { level: 1 }).textContent).toBe('404')
+ expect(view.getByRole('heading', { level: 2 }).textContent).toBe(
+ 'This page could not be found.',
+ )
+ expect(document.title).toBe('404: This page could not be found')
+ expect(view.container.querySelector('style')?.textContent).toContain(
+ 'prefers-color-scheme:dark',
+ )
+ })
+
+ test('takes a title, and can leave dark mode out', () => {
+ const view = render(
+ ,
+ )
+ expect(view.getByRole('heading', { level: 2 }).textContent).toBe('Teapot.')
+ expect(view.container.querySelector('style')?.textContent).not.toContain(
+ 'prefers-color-scheme',
+ )
+ })
+
+ test('without a status code, reports a client-side exception', () => {
+ const view = render(
+ // @ts-expect-error - the client-side error page has no status code
+ ,
+ )
+ expect(view.getByRole('heading', { level: 2 }).textContent).toBe(
+ 'Application error: a client-side exception has occurred while loading localhost (see the browser console for more information).',
+ )
+ expect(view.queryByRole('heading', { level: 1 })).toBeNull()
+ view.unmount()
+
+ // @ts-expect-error - the client-side error page has no status code
+ const other = render()
+ expect(other.getByRole('heading', { level: 2 }).textContent).toBe(
+ 'Application error: a client-side exception has occurred (see the browser console for more information).',
+ )
+ })
+
+ test('getInitialProps takes the status of the response or the error', () => {
+ expect(ErrorPage.getInitialProps({ res: { statusCode: 500 } })).toEqual({
+ statusCode: 500,
+ hostname: 'localhost',
+ })
+ expect(
+ ErrorPage.getInitialProps({ err: { statusCode: 403 } }).statusCode,
+ ).toBe(403)
+ expect(ErrorPage.getInitialProps({}).statusCode).toBe(404)
+ expect(ErrorPage.origGetInitialProps).toBe(ErrorPage.getInitialProps)
+ })
+})
+
+describe('catchError', () => {
+ let shouldThrow = true
+ function Bomb() {
+ if (shouldThrow) throw new Error('boom')
+ return fine
+ }
+
+ const Boundary = catchError((props, { error, reset, retry }) => (
+
+
+ {String(props.label)}: {(error as Error).message}
+
+
+
+
+ ))
+
+ // React reports every error a boundary catches.
+ const quiet = () => spyOn(console, 'error').mockImplementation(() => {})
+
+ test('renders the fallback, which reset and retry leave', () => {
+ const consoleError = quiet()
+ shouldThrow = true
+ const view = render(
+
+
+ ,
+ )
+ expect(view.getByText('Oops: boom')).toBeTruthy()
+
+ shouldThrow = false
+ fireEvent.click(view.getByText('reset'))
+ expect(view.getByText('fine')).toBeTruthy()
+ expect(router.refresh).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+
+ test('retry refreshes the router first', () => {
+ const consoleError = quiet()
+ shouldThrow = true
+ const view = render(
+
+
+ ,
+ )
+ shouldThrow = false
+ fireEvent.click(view.getByText('retry'))
+ expect(router.refresh).toHaveBeenCalledTimes(1)
+ expect(view.getByText('fine')).toBeTruthy()
+ consoleError.mockRestore()
+ })
+
+ test('clears the error on another path', () => {
+ const consoleError = quiet()
+ shouldThrow = true
+ const view = render(
+
+
+ ,
+ )
+ shouldThrow = false
+ view.rerender(
+
+
+ ,
+ )
+ expect(view.getByText('Oops: boom')).toBeTruthy()
+
+ setUrl('/elsewhere')
+ view.rerender(
+
+
+ ,
+ )
+ expect(view.getByText('fine')).toBeTruthy()
+ consoleError.mockRestore()
+ })
+
+ test('passes notFound and redirects on', () => {
+ const consoleError = quiet()
+ class Outer extends Component<
+ { children: ReactNode },
+ { digest?: string }
+ > {
+ override state: { digest?: string } = {}
+ static getDerivedStateFromError(error: { digest: string }) {
+ return { digest: error.digest }
+ }
+ override render() {
+ return this.state.digest ?? this.props.children
+ }
+ }
+ function Missing(): ReactNode {
+ return notFound()
+ }
+ const view = render(
+
+
+
+
+ ,
+ )
+ expect(view.container.textContent).toBe('NEXT_HTTP_ERROR_FALLBACK;404')
+ consoleError.mockRestore()
+ })
+})
+
+describe('next/app and next/document', () => {
+ test('App renders the page with its props', async () => {
+ function Page({ title }: { title?: unknown }) {
+ return {String(title)}
+ }
+ const view = render()
+ expect(view.getByText('Hi')).toBeTruthy()
+
+ expect(
+ await App.getInitialProps({
+ Component: { getInitialProps: async () => ({ title: 'Hi' }) },
+ ctx: {},
+ }),
+ ).toEqual({ pageProps: { title: 'Hi' } })
+ expect(await App.origGetInitialProps({ Component: {}, ctx: {} })).toEqual({
+ pageProps: {},
+ })
+ })
+
+ test('Document renders the HTML shell', () => {
+ expect(renderToStaticMarkup()).toBe(
+ '',
+ )
+ // React adds the missing head.
+ expect(renderToStaticMarkup()).toBe(
+ '',
+ )
+ expect(
+ Document.getInitialProps({
+ defaultGetInitialProps: () => ({ html: '' }),
+ }),
+ ).toEqual({ html: '' })
+ })
+})
+
+describe('next/offline and next/web-vitals', () => {
+ test('useOffline reads what a test sets', () => {
+ expect(renderHook(() => useOffline()).result.current).toBe(false)
+ setOffline(true)
+ expect(renderHook(() => useOffline()).result.current).toBe(true)
+ dispatchOfflineChange(false)
+ expect(useOffline()).toBe(false)
+ const view = render(
+
+ online
+ ,
+ )
+ expect(view.getByText('online')).toBeTruthy()
+ })
+
+ test('useReportWebVitals never reports', () => {
+ const report = mock(() => {})
+ renderHook(() => useReportWebVitals(report))
+ expect(report).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/__tests__/next-image.test.tsx b/src/__tests__/next-image.test.tsx
new file mode 100644
index 0000000..1819bf0
--- /dev/null
+++ b/src/__tests__/next-image.test.tsx
@@ -0,0 +1,107 @@
+import { describe, expect, test } from 'bun:test'
+import { render } from '../index.ts'
+import Image, { getImageProps, LegacyImage } from '../next/image.tsx'
+
+const html = (element: Parameters[0]) =>
+ render(element).container.innerHTML
+
+// The markup next/image renders for an image it does not optimize, checked
+// against next@16.3.6 in happy-dom.
+describe('Image renders the markup of next/image', () => {
+ test('sized', () => {
+ expect(html()).toBe(
+ '
',
+ )
+ })
+
+ test('with a class, and eager when preloaded', () => {
+ expect(
+ html(
+ ,
+ ),
+ ).toBe(
+ '
',
+ )
+ expect(html()).not.toContain(
+ 'loading',
+ )
+ })
+
+ test('fill', () => {
+ expect(html()).toBe(
+ '
',
+ )
+ })
+
+ test('with a blur placeholder', () => {
+ expect(
+ html(
+ ,
+ ),
+ ).toContain(
+ 'style="color: transparent; background-size: cover; background-position: 50% 50%; background-repeat: no-repeat;"',
+ )
+ })
+
+ test('from a static import, or with overrideSrc', () => {
+ const data = { src: '/static/a.png', width: 30, height: 40 }
+ expect(html()).toBe(
+ '
',
+ )
+ expect(html()).toContain(
+ 'width="5" height="40"',
+ )
+ expect(html()).toContain(
+ 'src="/b.png"',
+ )
+ })
+})
+
+test('getImageProps gives the props without data-nimg', () => {
+ const { props } = getImageProps({
+ src: '/a.png',
+ alt: 'A',
+ width: 10,
+ height: 20,
+ quality: 75,
+ sizes: '100vw',
+ unoptimized: true,
+ })
+ expect(props).toEqual({
+ alt: 'A',
+ loading: 'lazy',
+ width: 10,
+ height: 20,
+ decoding: 'async',
+ className: undefined,
+ style: { color: 'transparent' },
+ src: '/a.png',
+ })
+})
+
+// Without the wrapper spans next/legacy/image renders around it.
+test('LegacyImage names its layout in data-nimg', () => {
+ const markup = html(
+ ,
+ )
+ expect(markup).toStartWith('
)).toContain(
+ 'data-nimg="fill"',
+ )
+})
diff --git a/src/__tests__/next-link.test.tsx b/src/__tests__/next-link.test.tsx
new file mode 100644
index 0000000..8ad8c40
--- /dev/null
+++ b/src/__tests__/next-link.test.tsx
@@ -0,0 +1,250 @@
+import { describe, expect, mock, test } from 'bun:test'
+import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'
+import { fireEvent, render } from '../index.ts'
+import { router, setUrl } from '../next/index.ts'
+import Link, { formatUrl, useLinkStatus } from '../next/link.tsx'
+
+/**
+ * Clicks `element` and reports whether the click was handled in the page
+ * (`defaultPrevented`). The browser's own navigation is cancelled afterwards,
+ * so happy-dom never leaves the page.
+ */
+function click(element: Element, init?: MouseEventInit) {
+ let prevented: boolean | undefined
+ const listener = (event: Event) => {
+ prevented = event.defaultPrevented
+ event.preventDefault()
+ }
+ document.addEventListener('click', listener)
+ fireEvent.click(element, init)
+ document.removeEventListener('click', listener)
+ return prevented
+}
+
+describe('markup', () => {
+ test('matches next/link: props, then href', () => {
+ const { container } = render(
+
+ About
+ ,
+ )
+ expect(container.innerHTML).toBe('About')
+ })
+
+ test('formats a url object, and prefers `as`', () => {
+ const view = render(
+ <>
+
+ search
+
+
+ post
+
+ >,
+ )
+ expect(view.getByText('search').getAttribute('href')).toBe(
+ '/search?q=a+b&tag=x&tag=y#top',
+ )
+ expect(view.getByText('post').getAttribute('href')).toBe('/posts/1')
+ })
+
+ test('useLinkStatus is never pending', () => {
+ expect(useLinkStatus()).toEqual({ pending: false })
+ })
+})
+
+describe('a click', () => {
+ test('navigates through router.push', () => {
+ const view = render(About)
+ expect(click(view.getByText('About'))).toBe(true)
+ expect(router.push).toHaveBeenCalledWith('/about', { scroll: true })
+ })
+
+ test('replaces, and keeps the scroll position, when asked', () => {
+ const view = render(
+
+ About
+ ,
+ )
+ click(view.getByText('About'))
+ expect(router.replace).toHaveBeenCalledWith('/about', { scroll: false })
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test('calls onClick, which can cancel the navigation', () => {
+ const onClick = mock((event: { preventDefault(): void }) => {
+ event.preventDefault()
+ })
+ const view = render(
+
+ About
+ ,
+ )
+ click(view.getByText('About'))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test('onNavigate can cancel the navigation, but not the default', () => {
+ const view = render(
+ event.preventDefault()}>
+ About
+ ,
+ )
+ expect(click(view.getByText('About'))).toBe(true)
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test.each([
+ ['ctrl', { ctrlKey: true }],
+ ['meta', { metaKey: true }],
+ ['shift', { shiftKey: true }],
+ ['alt', { altKey: true }],
+ ['middle button', { button: 1 }],
+ ])('with %s is left to the browser', (_, init) => {
+ const view = render(About)
+ expect(click(view.getByText('About'), init)).toBe(false)
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test.each([
+ ['another window', { target: '_blank' }],
+ ['a download', { download: 'file' }],
+ ])('on a link to %s is left to the browser', (_, props) => {
+ const view = render(
+
+ link
+ ,
+ )
+ expect(click(view.getByText('link'))).toBe(false)
+ expect(router.push).not.toHaveBeenCalled()
+ })
+
+ test('on a link to this window navigates', () => {
+ const view = render(
+
+ About
+ ,
+ )
+ click(view.getByText('About'))
+ expect(router.push).toHaveBeenCalledWith('/about', { scroll: true })
+ })
+
+ test('on a URL of this origin navigates; any other is the browser', () => {
+ setUrl('/current')
+ const view = render(
+ <>
+ same
+ other
+ broken
+ >,
+ )
+ click(view.getByText('same'))
+ expect(router.push).toHaveBeenCalledWith('http://localhost/same', {
+ scroll: true,
+ })
+ expect(click(view.getByText('other'))).toBe(false)
+ expect(click(view.getByText('broken'))).toBe(false)
+ expect(router.push).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('legacyBehavior', () => {
+ function Button(props: ButtonHTMLAttributes) {
+ return
+ }
+
+ function Anchor(props: AnchorHTMLAttributes) {
+ return anchor
+ }
+
+ test('wraps text in an anchor that takes the href', () => {
+ const { container } = render(
+
+ About
+ ,
+ )
+ expect(container.innerHTML).toBe('About')
+ })
+
+ test('gives the child the click handler, and calls its own', () => {
+ const onClick = mock(() => {})
+ const view = render(
+
+
+ ,
+ )
+ const button = view.getByText('go')
+ expect(button.hasAttribute('href')).toBe(false)
+ click(button)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ expect(router.push).toHaveBeenCalledWith('/about', { scroll: true })
+ })
+
+ test('passes the href on with passHref, or for an absolute URL', () => {
+ const view = render(
+ <>
+
+
+
+
+
+
+ >,
+ )
+ expect(view.getByText('anchor').getAttribute('href')).toBe('/about')
+ expect(view.getByText('external').getAttribute('href')).toBe(
+ 'https://example.com/',
+ )
+ })
+})
+
+describe('formatUrl, as Next.js writes url objects', () => {
+ test.each([
+ ['/plain', '/plain'],
+ [{}, ''],
+ [{ pathname: '/a', query: {} }, '/a'],
+ [{ pathname: '/a', search: '?s=1', query: { q: '2' } }, '/a?s=1'],
+ [{ pathname: '/a', search: 's=1' }, '/a?s=1'],
+ [{ pathname: '/a', query: 'q=1' }, '/a?q=1'],
+ [
+ {
+ pathname: '/a',
+ query: { s: 's', n: 1, nan: Number.NaN, b: false, u: undefined },
+ },
+ '/a?s=s&n=1&nan=&b=false&u=',
+ ],
+ [{ pathname: '/a', hash: 'h' }, '/a#h'],
+ [{ pathname: '/a?b#c', search: '?d#e' }, '/a%3Fb%23c?d%23e'],
+ [
+ { protocol: 'https', hostname: 'example.com', port: 8080, pathname: 'p' },
+ 'https://example.com:8080/p',
+ ],
+ [
+ { protocol: 'http:', host: 'h:1', auth: 'user:pass' },
+ 'http://user:pass@h:1',
+ ],
+ [{ protocol: 'http', hostname: '::1' }, 'http://[::1]'],
+ [{ protocol: 'mailto', pathname: 'a@b.c' }, 'mailto:a@b.c'],
+ // A quirk Next.js has too: `slashes` with no host.
+ [{ slashes: true, pathname: 'p' }, '///p'],
+ ])('%p', (url, href) => {
+ expect(formatUrl(url)).toBe(href)
+ })
+})
diff --git a/src/__tests__/next-modules.test.ts b/src/__tests__/next-modules.test.ts
new file mode 100644
index 0000000..f2c5481
--- /dev/null
+++ b/src/__tests__/next-modules.test.ts
@@ -0,0 +1,102 @@
+import { expect, test } from 'bun:test'
+import { GOOGLE_FONTS } from '../next/google-fonts.ts'
+
+// `Object.keys(await import(specifier))` against next@16.3.6, without the
+// `default` that CJS interop adds to modules that have none. A named import
+// links only when its name is listed, so the lists have to be complete.
+const EXPORTS: Record = {
+ 'next/navigation': [
+ 'ReadonlyURLSearchParams',
+ 'RedirectType',
+ 'ServerInsertedHTMLContext',
+ 'forbidden',
+ 'notFound',
+ 'permanentRedirect',
+ 'redirect',
+ 'unauthorized',
+ 'unstable_isUnrecognizedActionError',
+ 'unstable_rethrow',
+ 'useParams',
+ 'usePathname',
+ 'useRouter',
+ 'useSearchParams',
+ 'useSelectedLayoutSegment',
+ 'useSelectedLayoutSegments',
+ 'useServerInsertedHTML',
+ ],
+ 'next/link': ['default', 'useLinkStatus'],
+ 'next/image': ['default', 'getImageProps'],
+ 'next/legacy/image': ['default'],
+ 'next/script': ['default', 'handleClientScriptLoad', 'initScriptLoader'],
+ 'next/head': ['default', 'defaultHead'],
+ 'next/dynamic': ['default', 'noSSR'],
+ 'next/form': ['default'],
+ 'next/headers': ['cookies', 'draftMode', 'headers'],
+ 'next/cache': [
+ 'cacheLife',
+ 'cacheTag',
+ 'io',
+ 'refresh',
+ 'revalidatePath',
+ 'revalidateTag',
+ 'unstable_cache',
+ 'unstable_cacheLife',
+ 'unstable_cacheTag',
+ 'unstable_noStore',
+ 'updateTag',
+ ],
+ 'next/server': [
+ 'ImageResponse',
+ 'NextRequest',
+ 'NextResponse',
+ 'URLPattern',
+ 'after',
+ 'connection',
+ 'userAgent',
+ 'userAgentFromString',
+ ],
+ 'next/og': ['ImageResponse'],
+ 'next/font/local': ['default'],
+ 'next/router': [
+ 'Router',
+ 'back',
+ 'beforePopState',
+ 'createRouter',
+ 'default',
+ 'makePublicRouterInstance',
+ 'prefetch',
+ 'push',
+ 'ready',
+ 'readyCallbacks',
+ 'reload',
+ 'replace',
+ 'router',
+ 'useRouter',
+ 'withRouter',
+ ],
+ 'next/compat/router': ['useRouter'],
+ 'next/document': ['Head', 'Html', 'Main', 'NextScript', 'default'],
+ 'next/app': ['default', 'getInitialProps', 'origGetInitialProps'],
+ 'next/error': [
+ 'catchError',
+ 'default',
+ 'displayName',
+ 'getInitialProps',
+ 'origGetInitialProps',
+ ],
+ 'next/offline': ['OfflineProvider', 'dispatchOfflineChange', 'useOffline'],
+ 'next/web-vitals': ['useReportWebVitals'],
+}
+
+test.each(
+ Object.entries(EXPORTS),
+)('%s has the exports of Next.js', async (specifier, names) => {
+ const module = await import(specifier)
+ expect(Object.keys(module).sort()).toEqual(names)
+})
+
+test('next/font/google has a function for every Google font', async () => {
+ const google = await import('next/font/google')
+ expect(Object.keys(google).sort()).toEqual([...GOOGLE_FONTS].sort())
+ expect(google.Inter().className).toBe('__className_inter')
+})
diff --git a/src/__tests__/next-navigation.test.tsx b/src/__tests__/next-navigation.test.tsx
new file mode 100644
index 0000000..b7b20f4
--- /dev/null
+++ b/src/__tests__/next-navigation.test.tsx
@@ -0,0 +1,158 @@
+import { describe, expect, test } from 'bun:test'
+import { useContext } from 'react'
+import { renderHook } from '../index.ts'
+import {
+ resetNext,
+ router,
+ setParams,
+ setSegments,
+ setUrl,
+} from '../next/index.ts'
+import {
+ forbidden,
+ notFound,
+ permanentRedirect,
+ ReadonlyURLSearchParams,
+ RedirectType,
+ redirect,
+ ServerInsertedHTMLContext,
+ unauthorized,
+ unstable_isUnrecognizedActionError,
+ unstable_rethrow,
+ useParams,
+ usePathname,
+ useRouter,
+ useSearchParams,
+ useSelectedLayoutSegment,
+ useSelectedLayoutSegments,
+ useServerInsertedHTML,
+} from '../next/navigation.ts'
+
+const thrown = (fn: () => unknown) => {
+ try {
+ fn()
+ } catch (error) {
+ return error
+ }
+}
+
+describe('hooks', () => {
+ test('read the URL, params and segments a test sets', () => {
+ setUrl('/posts/1?tab=a&tab=b')
+ setParams({ id: '1' })
+ expect(usePathname()).toBe('/posts/1')
+ expect(useSearchParams().getAll('tab')).toEqual(['a', 'b'])
+ expect(useParams()).toEqual({ id: '1' })
+ expect(useSelectedLayoutSegments()).toEqual(['posts', '1'])
+ expect(useSelectedLayoutSegment()).toBe('posts')
+
+ setSegments(['(shop)', 'cart'])
+ expect(useSelectedLayoutSegments('modal')).toEqual(['(shop)', 'cart'])
+ })
+
+ test('resolve a relative URL against the current one', () => {
+ setUrl('/posts/1')
+ setUrl('2?page=3')
+ expect(usePathname()).toBe('/posts/2')
+ expect(useSearchParams().get('page')).toBe('3')
+ })
+
+ test('start every test at the root', () => {
+ expect(usePathname()).toBe('/')
+ expect(useSearchParams().size).toBe(0)
+ expect(useParams()).toEqual({})
+ expect(useSelectedLayoutSegment()).toBeNull()
+ })
+
+ test('useRouter returns the router a test asserts on', () => {
+ useRouter().push('/a', { scroll: false })
+ expect(router.push).toHaveBeenCalledWith('/a', { scroll: false })
+ })
+
+ test('useServerInsertedHTML does nothing in the browser', () => {
+ const { result } = renderHook(() => {
+ useServerInsertedHTML(() => )
+ return useContext(ServerInsertedHTMLContext)
+ })
+ expect(result.current).toBeNull()
+ })
+})
+
+test('resetNext restores the URL and every mock', () => {
+ setUrl('/a')
+ router.push.mockImplementation(() => {
+ throw new Error('stubbed')
+ })
+ expect(() => router.push('/b')).toThrow('stubbed')
+
+ resetNext()
+ expect(usePathname()).toBe('/')
+ expect(router.push).not.toHaveBeenCalled()
+ expect(() => router.push('/c')).not.toThrow()
+})
+
+test('search params are read-only', () => {
+ const params = new ReadonlyURLSearchParams('a=1')
+ for (const method of ['append', 'delete', 'set', 'sort'] as const) {
+ expect(() => (params[method] as () => void)()).toThrow(
+ 'Method unavailable on `ReadonlyURLSearchParams`',
+ )
+ }
+ expect(params.get('a')).toBe('1')
+})
+
+describe('errors carry the digest of Next.js', () => {
+ test.each([
+ [() => redirect('/a'), 'NEXT_REDIRECT', 'NEXT_REDIRECT;replace;/a;307;'],
+ [
+ () => redirect('/a', RedirectType.push),
+ 'NEXT_REDIRECT',
+ 'NEXT_REDIRECT;push;/a;307;',
+ ],
+ [
+ () => permanentRedirect('/b'),
+ 'NEXT_REDIRECT',
+ 'NEXT_REDIRECT;replace;/b;308;',
+ ],
+ [
+ () => notFound(),
+ 'NEXT_HTTP_ERROR_FALLBACK;404',
+ 'NEXT_HTTP_ERROR_FALLBACK;404',
+ ],
+ [
+ () => forbidden(),
+ 'NEXT_HTTP_ERROR_FALLBACK;403',
+ 'NEXT_HTTP_ERROR_FALLBACK;403',
+ ],
+ [
+ () => unauthorized(),
+ 'NEXT_HTTP_ERROR_FALLBACK;401',
+ 'NEXT_HTTP_ERROR_FALLBACK;401',
+ ],
+ ])('%#', (fn, message, digest) => {
+ expect(thrown(fn)).toMatchObject({ message, digest })
+ })
+
+ test('unstable_rethrow passes on control flow, even wrapped', () => {
+ const redirectError = thrown(() => redirect('/a'))
+ const bailout = Object.assign(new Error('bailout'), {
+ digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
+ })
+ expect(thrown(() => unstable_rethrow(redirectError))).toBe(redirectError)
+ expect(thrown(() => unstable_rethrow(bailout))).toBe(bailout)
+ expect(
+ thrown(() =>
+ unstable_rethrow(new Error('wrapped', { cause: redirectError })),
+ ),
+ ).toBe(redirectError)
+ expect(thrown(() => unstable_rethrow(new Error('own')))).toBeUndefined()
+ expect(thrown(() => unstable_rethrow('text'))).toBeUndefined()
+ })
+
+ test('unstable_isUnrecognizedActionError checks the name', () => {
+ const error = new Error('gone')
+ error.name = 'UnrecognizedActionError'
+ expect(unstable_isUnrecognizedActionError(error)).toBe(true)
+ expect(unstable_isUnrecognizedActionError(new Error('other'))).toBe(false)
+ })
+})
diff --git a/src/__tests__/next-root-params.test.ts b/src/__tests__/next-root-params.test.ts
new file mode 100644
index 0000000..c5782c5
--- /dev/null
+++ b/src/__tests__/next-root-params.test.ts
@@ -0,0 +1,17 @@
+import { expect, test } from 'bun:test'
+import { resetNext, setRootParams } from '../next/index.ts'
+
+// A module's exports are fixed once it has loaded, so the names have to be
+// set first. This is the only test file that loads `next/root-params`.
+test('next/root-params exports a getter for each param set before it loads', async () => {
+ setRootParams({ lang: 'ko', region: 'kr' })
+ const params = await import('next/root-params')
+ expect(Object.keys(params).sort()).toEqual(['lang', 'region'])
+ expect(await params.lang()).toBe('ko')
+ expect(await params.region()).toBe('kr')
+
+ resetNext()
+ expect(await params.lang()).toBeUndefined()
+ setRootParams({ lang: 'en' })
+ expect(await params.lang()).toBe('en')
+})
diff --git a/src/__tests__/next-router.test.tsx b/src/__tests__/next-router.test.tsx
new file mode 100644
index 0000000..22115a4
--- /dev/null
+++ b/src/__tests__/next-router.test.tsx
@@ -0,0 +1,134 @@
+import { describe, expect, mock, test } from 'bun:test'
+import { render } from '../index.ts'
+import {
+ resetNext,
+ setDraftMode,
+ setParams,
+ setRoute,
+ setUrl,
+} from '../next/index.ts'
+import {
+ createRouter,
+ makePublicRouterInstance,
+ pagesRouter,
+ Router,
+ useCompatRouter,
+ useRouter,
+ withRouter,
+} from '../next/router.tsx'
+
+describe('the Pages Router', () => {
+ test('starts at the root, ready', () => {
+ const router = useRouter()
+ expect(router).toBe(pagesRouter)
+ expect(router.pathname).toBe('/')
+ expect(router.route).toBe('/')
+ expect(router.asPath).toBe('/')
+ expect(router.query).toEqual({})
+ expect(router.isReady).toBe(true)
+ expect(router.isPreview).toBe(false)
+ })
+
+ test('reads the route, URL and params a test sets', () => {
+ setUrl('/posts/1?tab=a&tab=b&x=1#c')
+ setRoute('/posts/[id]')
+ setParams({ id: '1' })
+ const router = useRouter()
+ expect(router.pathname).toBe('/posts/[id]')
+ expect(router.route).toBe('/posts/[id]')
+ expect(router.asPath).toBe('/posts/1?tab=a&tab=b&x=1#c')
+ expect(router.query).toEqual({ tab: ['a', 'b'], x: '1', id: '1' })
+ setDraftMode(true)
+ expect(router.isPreview).toBe(true)
+ })
+
+ test('keeps the same query object until the URL or params change', () => {
+ setUrl('/?a=1')
+ const query = useRouter().query
+ expect(useRouter().query).toBe(query)
+ setParams({ id: '2' })
+ expect(useRouter().query).not.toBe(query)
+ expect(useRouter().query).toEqual({ a: '1', id: '2' })
+ })
+
+ test('records navigation, which resolves like Next.js', async () => {
+ const router = useRouter()
+ expect(await router.push('/a', undefined, { shallow: true })).toBe(true)
+ expect(await router.replace({ pathname: '/b' })).toBe(true)
+ await router.prefetch('/c')
+ router.reload()
+ router.back()
+ router.forward()
+ router.beforePopState(() => true)
+ expect(router.push).toHaveBeenCalledWith('/a', undefined, { shallow: true })
+ for (const method of [
+ router.replace,
+ router.prefetch,
+ router.reload,
+ router.back,
+ router.forward,
+ router.beforePopState,
+ ]) {
+ expect(method).toHaveBeenCalledTimes(1)
+ }
+ })
+
+ test('the singleton is the router, ready at once', () => {
+ const ready = mock(() => {})
+ pagesRouter.ready(ready)
+ expect(ready).toHaveBeenCalledTimes(1)
+ expect(pagesRouter.router).toBe(pagesRouter)
+ expect(pagesRouter.readyCallbacks).toEqual([])
+ expect(createRouter('/', {}, '/', {})).toBe(pagesRouter)
+ })
+
+ test('events are an emitter that is emptied between tests', () => {
+ const handler = mock((_url: unknown) => {})
+ expect(Router.events).toBe(pagesRouter.events)
+ Router.events.on('routeChangeStart', handler)
+ Router.events.emit('routeChangeStart', '/a')
+ Router.events.off('routeChangeStart', handler)
+ Router.events.off('routeChangeComplete', handler)
+ Router.events.emit('routeChangeStart', '/b')
+ expect(handler.mock.calls).toEqual([['/a']])
+
+ const leftOver = mock(() => {})
+ Router.events.on('routeChangeStart', leftOver)
+ resetNext()
+ Router.events.emit('routeChangeStart', '/c')
+ expect(leftOver).not.toHaveBeenCalled()
+ })
+
+ test('makePublicRouterInstance copies the state, and calls the router', () => {
+ setUrl('/?a=1')
+ const instance = makePublicRouterInstance(pagesRouter)
+ expect(instance.query).toEqual({ a: '1' })
+ expect(instance.query).not.toBe(pagesRouter.query)
+ expect(instance.pathname).toBe('/')
+ expect(instance.events).toBe(Router.events)
+ ;(instance.push as (url: string) => unknown)('/x')
+ expect(pagesRouter.push).toHaveBeenCalledWith('/x')
+ expect(makePublicRouterInstance({ locales: ['en'] }).locales).toEqual([
+ 'en',
+ ])
+ })
+
+ test('withRouter hands the router to a component', () => {
+ function Page({ router }: { router?: typeof pagesRouter }) {
+ return {router?.pathname}
+ }
+ Page.getInitialProps = () => ({})
+ const Wrapped = withRouter(Page)
+ expect(Wrapped.displayName).toBe('withRouter(Page)')
+ expect(Wrapped.getInitialProps).toBe(Page.getInitialProps)
+ setUrl('/wrapped')
+ expect(render().getByText('/wrapped')).toBeTruthy()
+ expect(withRouter(() => null).displayName).toBe('withRouter(Unknown)')
+ })
+})
+
+test('next/compat/router is null in the App Router, until setRoute', () => {
+ expect(useCompatRouter()).toBeNull()
+ setRoute('/posts/[id]')
+ expect(useCompatRouter()).toBe(pagesRouter)
+})
diff --git a/src/__tests__/next-server.test.tsx b/src/__tests__/next-server.test.tsx
new file mode 100644
index 0000000..5a404a4
--- /dev/null
+++ b/src/__tests__/next-server.test.tsx
@@ -0,0 +1,293 @@
+import { describe, expect, mock, test } from 'bun:test'
+import {
+ cacheLife,
+ cacheTag,
+ io,
+ refresh,
+ revalidatePath,
+ revalidateTag,
+ unstable_cache,
+ unstable_cacheLife,
+ unstable_cacheTag,
+ unstable_noStore,
+ updateTag,
+} from '../next/cache.ts'
+import { stringifyCookie } from '../next/cookies.ts'
+import { cookies, draftMode, headers } from '../next/headers.ts'
+import {
+ resetNext,
+ setCookies,
+ setDraftMode,
+ setHeaders,
+} from '../next/index.ts'
+import {
+ after,
+ connection,
+ ImageResponse,
+ NextRequest,
+ NextResponse,
+ URLPattern,
+ userAgent,
+ userAgentFromString,
+} from '../next/server.ts'
+
+describe('next/headers', () => {
+ test('headers() are the ones a test sets, read-only', async () => {
+ setHeaders({ 'x-a': '1' })
+ const list = await headers()
+ expect(list.get('x-a')).toBe('1')
+ expect(() => list.set('x-a', '2')).toThrow('Headers cannot be modified')
+ expect(() => list.append('x-b', '2')).toThrow('Headers cannot be modified')
+ expect(() => list.delete('x-a')).toThrow('Headers cannot be modified')
+ })
+
+ test('cookies() are the ones a test sets, and keep what is written', async () => {
+ setCookies({ theme: 'dark', lang: 'ko' })
+ const jar = await cookies()
+ expect(jar.get('theme')).toEqual({ name: 'theme', value: 'dark' })
+ expect(jar.get({ name: 'lang', value: '' })?.value).toBe('ko')
+ expect(jar.getAll()).toHaveLength(2)
+ expect(jar.size).toBe(2)
+
+ jar.set('a', '1').set({ name: 'b', value: '2' })
+ expect(jar.delete('lang')).toBe(true)
+ expect(jar.delete(['a', 'missing'])).toEqual([true, false])
+ const next = await cookies()
+ expect(next.has('b')).toBe(true)
+ expect(next.has('lang')).toBe(false)
+ expect([...next]).toEqual([
+ ['theme', { name: 'theme', value: 'dark' }],
+ ['b', { name: 'b', value: '2' }],
+ ])
+ expect(next.toString()).toBe('theme=dark; b=2')
+ next.clear()
+ expect((await cookies()).size).toBe(0)
+ })
+
+ test('draftMode() is off until enabled', async () => {
+ const mode = await draftMode()
+ expect(mode.isEnabled).toBe(false)
+ mode.enable()
+ expect((await draftMode()).isEnabled).toBe(true)
+ mode.disable()
+ expect(mode.isEnabled).toBe(false)
+ setDraftMode(true)
+ expect(mode.isEnabled).toBe(true)
+ })
+})
+
+describe('next/cache', () => {
+ test('records what is revalidated', async () => {
+ revalidatePath('/posts', 'page')
+ revalidateTag('posts', 'max')
+ updateTag('posts')
+ refresh()
+ unstable_noStore()
+ cacheLife('hours')
+ cacheTag('a', 'b')
+ await io()
+ expect(revalidatePath).toHaveBeenCalledWith('/posts', 'page')
+ expect(revalidateTag).toHaveBeenCalledWith('posts', 'max')
+ expect(unstable_cacheLife).toBe(cacheLife)
+ expect(unstable_cacheTag).toBe(cacheTag)
+ for (const fn of [updateTag, refresh, unstable_noStore, cacheTag, io]) {
+ expect(fn).toHaveBeenCalledTimes(1)
+ }
+
+ resetNext()
+ expect(revalidatePath).not.toHaveBeenCalled()
+ })
+
+ test('unstable_cache caches nothing', async () => {
+ const load = mock(async (id: number) => id * 2)
+ const cached = unstable_cache(load, ['key'], { tags: ['t'] })
+ expect(await cached(1)).toBe(2)
+ expect(await cached(1)).toBe(2)
+ expect(load).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('NextRequest', () => {
+ test('parses its URL and cookies, and writes cookies back', () => {
+ const request = new NextRequest('http://localhost/a?b=1', {
+ headers: { cookie: 'x=1; y=a%20b; broken' },
+ })
+ expect(request.nextUrl.pathname).toBe('/a')
+ expect(request.nextUrl.searchParams.get('b')).toBe('1')
+ const copy = request.nextUrl.clone()
+ copy.pathname = '/c'
+ expect(request.nextUrl.pathname).toBe('/a')
+
+ expect(request.cookies.get('y')?.value).toBe('a b')
+ request.cookies.set('z', 'c d')
+ expect(request.headers.get('cookie')).toBe('x=1; y=a%20b; z=c%20d')
+ expect(new NextRequest(request).cookies.get('z')?.value).toBe('c d')
+ request.cookies.clear()
+ expect(request.headers.get('cookie')).toBe('')
+ })
+
+ test('without a cookie header has no cookies', () => {
+ expect(new NextRequest('http://localhost/').cookies.size).toBe(0)
+ expect(new NextRequest(new Request('http://localhost/')).cookies.size).toBe(
+ 0,
+ )
+ })
+})
+
+describe('NextResponse', () => {
+ test('json', async () => {
+ const response = NextResponse.json({ a: 1 }, { status: 201 })
+ expect(response).toBeInstanceOf(NextResponse)
+ expect(response.status).toBe(201)
+ expect(await response.json()).toEqual({ a: 1 })
+ })
+
+ test('redirect to an absolute URL with a redirect status', () => {
+ const temporary = NextResponse.redirect('http://localhost/b')
+ expect(temporary.status).toBe(307)
+ expect(temporary.headers.get('location')).toBe('http://localhost/b')
+ expect(
+ NextResponse.redirect(new URL('http://localhost/c'), 308).status,
+ ).toBe(308)
+ const withInit = NextResponse.redirect('http://localhost/d', {
+ status: 301,
+ headers: { 'x-a': '1' },
+ })
+ expect(withInit.status).toBe(301)
+ expect(withInit.headers.get('x-a')).toBe('1')
+
+ expect(() => NextResponse.redirect('http://localhost/', 200)).toThrow(
+ 'Failed to execute "redirect" on "response": Invalid status code',
+ )
+ expect(() => NextResponse.redirect('/relative')).toThrow(
+ 'URL is malformed "/relative"',
+ )
+ })
+
+ test('rewrite and next hand request headers on', () => {
+ const rewrite = NextResponse.rewrite('http://localhost/r', {
+ request: { headers: new Headers({ 'x-user': 'a' }) },
+ })
+ expect(rewrite.headers.get('x-middleware-rewrite')).toBe(
+ 'http://localhost/r',
+ )
+ expect(rewrite.headers.get('x-middleware-request-x-user')).toBe('a')
+ expect(rewrite.headers.get('x-middleware-override-headers')).toBe('x-user')
+ const next = NextResponse.next({ headers: { 'x-b': '1' } })
+ expect(next.headers.get('x-middleware-next')).toBe('1')
+ expect(next.headers.get('x-b')).toBe('1')
+ expect(next.headers.has('x-middleware-override-headers')).toBe(false)
+ })
+
+ test('keeps the set-cookie headers it is given', () => {
+ const response = new NextResponse(null, {
+ headers: { 'set-cookie': 'a=1' },
+ })
+ expect(response.headers.get('set-cookie')).toBe('a=1')
+ })
+
+ test('cookies are written to set-cookie', () => {
+ const response = new NextResponse()
+ response.cookies
+ .set('a', '1', { httpOnly: true, maxAge: 60 })
+ .set({ name: 'b', value: '2', path: '/p' })
+ expect(response.cookies.get('a')?.expires).toBeInstanceOf(Date)
+ expect(
+ new NextResponse().cookies
+ .set({ name: 'c', value: '3', expires: 0 })
+ .get('c')?.expires,
+ ).toEqual(new Date(0))
+ expect(response.cookies.get('a')?.path).toBe('/')
+ expect(response.cookies.get({ name: 'b', value: '' })?.path).toBe('/p')
+ expect(response.cookies.has('a')).toBe(true)
+ expect(response.cookies.getAll()).toHaveLength(2)
+ expect(response.headers.getSetCookie()).toEqual([
+ expect.stringMatching(/^a=1; Path=\/; Expires=.+; Max-Age=60; HttpOnly$/),
+ 'b=2; Path=/p',
+ ])
+
+ response.cookies.delete('a').delete({ name: 'b', path: '/p' })
+ expect(response.cookies.toString()).toBe(
+ 'a=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; b=; Path=/p; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
+ )
+ })
+
+ test('stringifyCookie writes every attribute', () => {
+ expect(
+ stringifyCookie({
+ name: 'n',
+ value: 'v w',
+ path: '/',
+ expires: 0,
+ maxAge: 1,
+ domain: 'example.com',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax',
+ partitioned: true,
+ priority: 'high',
+ }),
+ ).toBe(
+ 'n=v%20w; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=1; Domain=example.com; Secure; HttpOnly; SameSite=lax; Partitioned; Priority=high',
+ )
+ })
+})
+
+describe('next/server helpers', () => {
+ test('userAgent tells bots apart', () => {
+ expect(
+ userAgentFromString('Mozilla/5.0 (compatible; Googlebot/2.1)').isBot,
+ ).toBe(true)
+ expect(userAgentFromString('Mozilla/5.0').isBot).toBe(false)
+ expect(userAgentFromString(undefined)).toEqual({
+ ua: '',
+ isBot: false,
+ browser: {},
+ engine: {},
+ os: {},
+ device: {},
+ cpu: {},
+ })
+ expect(userAgent({ headers: new Headers({ 'user-agent': 'x' }) }).ua).toBe(
+ 'x',
+ )
+ expect(userAgent({ headers: new Headers() }).ua).toBe('')
+ })
+
+ test('after runs its task after the current work', async () => {
+ const task = mock(() => {})
+ after(task)
+ after(Promise.resolve())
+ expect(task).not.toHaveBeenCalled()
+ await Promise.resolve()
+ expect(task).toHaveBeenCalledTimes(1)
+ })
+
+ test('connection resolves, and URLPattern is the global one', async () => {
+ expect(await connection()).toBeUndefined()
+ expect(URLPattern).toBe(
+ (globalThis as unknown as { URLPattern: unknown }).URLPattern,
+ )
+ })
+
+ test('ImageResponse is an empty PNG response with the headers of Next.js', () => {
+ const response = new ImageResponse(, {
+ status: 201,
+ headers: { 'x-a': '1', 'cache-control': 'no-store' },
+ })
+ expect(response.status).toBe(201)
+ expect(response.headers.get('content-type')).toBe('image/png')
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ expect(response.headers.get('x-a')).toBe('1')
+ expect(new ImageResponse().headers.get('cache-control')).toBe(
+ 'public, immutable, no-transform, max-age=31536000',
+ )
+
+ const env = process.env.NODE_ENV
+ process.env.NODE_ENV = 'development'
+ expect(new ImageResponse().headers.get('cache-control')).toBe(
+ 'no-cache, no-store',
+ )
+ process.env.NODE_ENV = env
+ })
+})
diff --git a/src/next/app.tsx b/src/next/app.tsx
new file mode 100644
index 0000000..51e0b94
--- /dev/null
+++ b/src/next/app.tsx
@@ -0,0 +1,28 @@
+import { Component, type ComponentType } from 'react'
+
+type PageProps = Record
+
+interface AppContext {
+ Component: { getInitialProps?: (context: unknown) => unknown }
+ ctx: unknown
+}
+
+async function getInitialProps({ Component: Page, ctx }: AppContext) {
+ return {
+ pageProps: Page.getInitialProps ? await Page.getInitialProps(ctx) : {},
+ }
+}
+
+/** `next/app`: renders the page with its props, as the default `App` does. */
+export default class App extends Component<{
+ Component: ComponentType
+ pageProps: PageProps
+}> {
+ static getInitialProps = getInitialProps
+ static origGetInitialProps = getInitialProps
+
+ override render() {
+ const { Component: Page, pageProps } = this.props
+ return
+ }
+}
diff --git a/src/next/cache.ts b/src/next/cache.ts
new file mode 100644
index 0000000..74bc400
--- /dev/null
+++ b/src/next/cache.ts
@@ -0,0 +1,29 @@
+import { resettableMock } from './state.ts'
+
+// Caching and revalidation have no effect in a test. Each function is a mock,
+// so a test can assert what was revalidated:
+// expect(revalidatePath).toHaveBeenCalledWith('/posts')
+
+export const revalidatePath =
+ resettableMock<(path: string, type?: 'layout' | 'page') => void>()
+export const revalidateTag =
+ resettableMock<
+ (tag: string, profile?: string | { expire?: number }) => void
+ >()
+export const updateTag = resettableMock<(tag: string) => void>()
+export const refresh = resettableMock<() => void>()
+export const unstable_noStore = resettableMock<() => void>()
+export const cacheLife = resettableMock<(profile: string | object) => void>()
+export const cacheTag = resettableMock<(...tags: string[]) => void>()
+export const unstable_cacheLife = cacheLife
+export const unstable_cacheTag = cacheTag
+export const io = resettableMock(async () => {})
+
+/** Returns the function itself: nothing is cached between calls. */
+export const unstable_cache = resettableMock(
+ unknown>(
+ fn: T,
+ _keyParts?: string[],
+ _options?: { revalidate?: number | false; tags?: string[] },
+ ) => fn,
+)
diff --git a/src/next/cookies.ts b/src/next/cookies.ts
new file mode 100644
index 0000000..021a77f
--- /dev/null
+++ b/src/next/cookies.ts
@@ -0,0 +1,186 @@
+export interface RequestCookie {
+ name: string
+ value: string
+}
+
+export interface ResponseCookie extends RequestCookie {
+ domain?: string
+ expires?: Date | number
+ httpOnly?: boolean
+ maxAge?: number
+ partitioned?: boolean
+ path?: string
+ priority?: 'low' | 'medium' | 'high'
+ sameSite?: boolean | 'lax' | 'strict' | 'none'
+ secure?: boolean
+}
+
+type NameOrCookie = [name: string] | [cookie: RequestCookie]
+
+const nameOf = (args: NameOrCookie | []) =>
+ typeof args[0] === 'string' ? args[0] : args[0]?.name
+
+export function parseCookieHeader(header: string | null) {
+ const jar = new Map()
+ for (const pair of header?.split(/;\s*/) ?? []) {
+ const index = pair.indexOf('=')
+ if (index > 0) {
+ jar.set(pair.slice(0, index), decodeURIComponent(pair.slice(index + 1)))
+ }
+ }
+ return jar
+}
+
+export const serializeCookies = (jar: Map) =>
+ [...jar]
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
+ .join('; ')
+
+/**
+ * The `RequestCookies` API of `@edge-runtime/cookies`, over a name-value map.
+ * `onChange` lets a request keep its `cookie` header in step.
+ */
+export class RequestCookies {
+ constructor(
+ private readonly jar: Map,
+ private readonly onChange: () => void = () => {},
+ ) {}
+
+ get size() {
+ return this.jar.size
+ }
+
+ get(...args: NameOrCookie): RequestCookie | undefined {
+ return this.getAll(...args)[0]
+ }
+
+ getAll(...args: NameOrCookie | []): RequestCookie[] {
+ const name = nameOf(args)
+ return [...this.jar]
+ .filter(([key]) => name === undefined || key === name)
+ .map(([key, value]) => ({ name: key, value }))
+ }
+
+ has(name: string) {
+ return this.jar.has(name)
+ }
+
+ set(...args: [name: string, value: string] | [cookie: RequestCookie]) {
+ const [name, value] =
+ typeof args[0] === 'string'
+ ? [args[0], args[1] as string]
+ : [args[0].name, args[0].value]
+ this.jar.set(name, value)
+ this.onChange()
+ return this
+ }
+
+ delete(names: string | string[]) {
+ const deleted = [names].flat().map((name) => this.jar.delete(name))
+ this.onChange()
+ return Array.isArray(names) ? deleted : (deleted[0] as boolean)
+ }
+
+ clear() {
+ this.jar.clear()
+ this.onChange()
+ return this
+ }
+
+ *[Symbol.iterator](): IterableIterator<[string, RequestCookie]> {
+ for (const cookie of this.getAll()) yield [cookie.name, cookie]
+ }
+
+ toString() {
+ return serializeCookies(this.jar)
+ }
+}
+
+export function stringifyCookie(cookie: ResponseCookie) {
+ const attributes = [
+ cookie.path && `Path=${cookie.path}`,
+ cookie.expires !== undefined &&
+ `Expires=${new Date(cookie.expires).toUTCString()}`,
+ cookie.maxAge !== undefined && `Max-Age=${cookie.maxAge}`,
+ cookie.domain && `Domain=${cookie.domain}`,
+ cookie.secure && 'Secure',
+ cookie.httpOnly && 'HttpOnly',
+ cookie.sameSite && `SameSite=${cookie.sameSite}`,
+ cookie.partitioned && 'Partitioned',
+ cookie.priority && `Priority=${cookie.priority}`,
+ ].filter(Boolean)
+ return [
+ `${cookie.name}=${encodeURIComponent(cookie.value)}`,
+ ...attributes,
+ ].join('; ')
+}
+
+/**
+ * The `ResponseCookies` API of `@edge-runtime/cookies`: every change is
+ * written back to the response's `set-cookie` headers.
+ *
+ * ponytail: `set-cookie` headers given to the response up front are not read
+ * into it, and the first write replaces them; parse them in the constructor
+ * if a test mixes the two.
+ */
+export class ResponseCookies {
+ private readonly jar = new Map()
+
+ constructor(private readonly headers: Headers) {}
+
+ get(...args: NameOrCookie): ResponseCookie | undefined {
+ return this.getAll(...args)[0]
+ }
+
+ getAll(...args: NameOrCookie | []): ResponseCookie[] {
+ const name = nameOf(args)
+ return [...this.jar.values()].filter(
+ (cookie) => name === undefined || cookie.name === name,
+ )
+ }
+
+ has(name: string) {
+ return this.jar.has(name)
+ }
+
+ set(
+ ...args:
+ | [name: string, value: string, options?: Partial]
+ | [cookie: ResponseCookie]
+ ) {
+ const cookie =
+ typeof args[0] === 'string'
+ ? { ...args[2], name: args[0], value: args[1] as string }
+ : args[0]
+ // Normalised like `@edge-runtime/cookies`: the expiry is a date, a max
+ // age sets it, and the path defaults to the root.
+ this.jar.set(cookie.name, {
+ ...cookie,
+ ...(typeof cookie.expires === 'number'
+ ? { expires: new Date(cookie.expires) }
+ : {}),
+ ...(cookie.maxAge
+ ? { expires: new Date(Date.now() + cookie.maxAge * 1000) }
+ : {}),
+ path: cookie.path ?? '/',
+ })
+ this.write()
+ return this
+ }
+
+ delete(...args: [name: string] | [cookie: Omit]) {
+ const cookie = typeof args[0] === 'string' ? { name: args[0] } : args[0]
+ return this.set({ ...cookie, value: '', expires: new Date(0) })
+ }
+
+ toString() {
+ return this.getAll().map(stringifyCookie).join('; ')
+ }
+
+ private write() {
+ this.headers.delete('set-cookie')
+ for (const cookie of this.jar.values()) {
+ this.headers.append('set-cookie', stringifyCookie(cookie))
+ }
+ }
+}
diff --git a/src/next/document.tsx b/src/next/document.tsx
new file mode 100644
index 0000000..a27e985
--- /dev/null
+++ b/src/next/document.tsx
@@ -0,0 +1,44 @@
+import { Component, createElement, type HTMLAttributes } from 'react'
+
+// `next/document` builds the HTML shell on the server. Its components render
+// the same elements here, without the tags Next.js injects into them.
+
+export function Html(props: HTMLAttributes) {
+ return
+}
+
+export function Head(props: HTMLAttributes) {
+ return
+}
+
+/** Where Next.js renders the page. */
+export function Main() {
+ return createElement('next-js-internal-body-render-target')
+}
+
+/** The page's scripts, which a test never loads. */
+export function NextScript(_props: { nonce?: string }) {
+ return null
+}
+
+interface DocumentContext {
+ defaultGetInitialProps(context: DocumentContext): unknown
+}
+
+export default class Document extends Component<{ nonce?: string }> {
+ static getInitialProps(context: DocumentContext) {
+ return context.defaultGetInitialProps(context)
+ }
+
+ override render() {
+ return (
+
+
+
+
+
+
+
+ )
+ }
+}
diff --git a/src/next/dynamic.tsx b/src/next/dynamic.tsx
new file mode 100644
index 0000000..b20feb2
--- /dev/null
+++ b/src/next/dynamic.tsx
@@ -0,0 +1,48 @@
+import { type ComponentType, lazy, type ReactNode, Suspense } from 'react'
+
+// biome-ignore lint/suspicious/noExplicitAny: components of any props
+type AnyComponent = ComponentType
+type Loaded = AnyComponent | { default: AnyComponent }
+type Loader = (() => Promise) | Promise
+
+interface DynamicOptions {
+ loader: Loader
+ loading?: (props: {
+ isLoading: boolean
+ pastDelay: boolean
+ error: Error | null
+ }) => ReactNode
+ ssr?: boolean
+}
+
+/**
+ * `next/dynamic` as React.lazy: the component appears once its module
+ * resolves, with `loading` shown until then. Use `findBy*` queries to wait.
+ * Accepts the same forms as Next.js: a loader, a promise, or options.
+ */
+export default function dynamic(
+ loaderOrOptions: Loader | DynamicOptions,
+ options: Partial = {},
+) {
+ const { loader, loading: Loading } =
+ typeof loaderOrOptions === 'function' || loaderOrOptions instanceof Promise
+ ? { ...options, loader: loaderOrOptions }
+ : { ...options, ...loaderOrOptions }
+
+ const Lazy = lazy(async () => {
+ const loaded = await (typeof loader === 'function' ? loader() : loader)
+ return { default: 'default' in loaded ? loaded.default : loaded }
+ })
+
+ return function DynamicComponent(props: object) {
+ return (
+ : null}
+ >
+
+
+ )
+ }
+}
+
+export const noSSR = dynamic
diff --git a/src/next/error.tsx b/src/next/error.tsx
new file mode 100644
index 0000000..8a2ffb9
--- /dev/null
+++ b/src/next/error.tsx
@@ -0,0 +1,205 @@
+import { Component, type ReactNode, startTransition } from 'react'
+
+import Head from './head.tsx'
+import { isNextRouterError } from './navigation.ts'
+import { appRouter, state } from './state.ts'
+
+const statusCodes: Record = {
+ 400: 'Bad Request',
+ 404: 'This page could not be found',
+ 405: 'Method Not Allowed',
+ 500: 'Internal Server Error',
+}
+
+const styles = {
+ error: {
+ fontFamily:
+ 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
+ height: '100vh',
+ textAlign: 'center',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ desc: { lineHeight: '48px' },
+ h1: {
+ display: 'inline-block',
+ margin: '0 20px 0 0',
+ paddingRight: 23,
+ fontSize: 24,
+ fontWeight: 500,
+ verticalAlign: 'top',
+ },
+ h2: { fontSize: 14, fontWeight: 400, lineHeight: '28px' },
+ wrap: { display: 'inline-block' },
+} as const
+
+interface ErrorContext {
+ res?: { statusCode?: number } | null
+ err?: { statusCode?: number } | null
+}
+
+function getInitialProps({ res, err }: ErrorContext) {
+ const statusCode = res?.statusCode
+ ? res.statusCode
+ : err
+ ? err.statusCode
+ : 404
+ return { statusCode, hostname: state.url.hostname }
+}
+
+interface ErrorProps {
+ statusCode: number
+ title?: string
+ hostname?: string
+ withDarkMode?: boolean
+}
+
+/** The default error page of `next/error`, with the markup Next.js renders. */
+export default class ErrorPage extends Component {
+ static displayName = 'ErrorPage'
+ static getInitialProps = getInitialProps
+ static origGetInitialProps = getInitialProps
+
+ override render() {
+ const { statusCode, withDarkMode = true, hostname } = this.props
+ const title =
+ this.props.title ||
+ statusCodes[statusCode] ||
+ 'An unexpected error has occurred'
+ return (
+
+
+
+ {statusCode
+ ? `${statusCode}: ${title}`
+ : 'Application error: a client-side exception has occurred'}
+
+
+
+
+ {statusCode ? (
+
+ {statusCode}
+
+ ) : null}
+
+
+ {this.props.title || statusCode ? (
+ title
+ ) : (
+ <>
+ Application error: a client-side exception has occurred{' '}
+ {Boolean(hostname) && <>while loading {hostname}>} (see the
+ browser console for more information)
+ >
+ )}
+ .
+
+
+
+
+ )
+ }
+}
+
+export interface ErrorInfo {
+ error: unknown
+ reset: () => void
+ retry: () => void
+}
+
+interface CatchErrorProps {
+ pathname: string
+ renderFallback: (errorInfo: ErrorInfo) => ReactNode
+ children?: ReactNode
+}
+
+interface CatchErrorState {
+ error: { thrownValue: unknown } | null
+ previousPathname: string
+}
+
+// The error boundary `catchError` renders, as the App Router's: redirects and
+// `notFound` pass through, and navigating to another path clears the error.
+class CatchError extends Component {
+ static displayName = 'catchError(Next.CatchError)'
+
+ override state: CatchErrorState = {
+ error: null,
+ previousPathname: this.props.pathname,
+ }
+
+ static getDerivedStateFromError(thrownValue: unknown) {
+ if (isNextRouterError(thrownValue)) throw thrownValue
+ return { error: { thrownValue } }
+ }
+
+ static getDerivedStateFromProps(
+ props: CatchErrorProps,
+ { error, previousPathname }: CatchErrorState,
+ ) {
+ return {
+ error: props.pathname === previousPathname ? error : null,
+ previousPathname: props.pathname,
+ }
+ }
+
+ reset = () => {
+ this.setState({ error: null })
+ }
+
+ retry = () => {
+ startTransition(() => {
+ appRouter.refresh()
+ this.reset()
+ })
+ }
+
+ override render() {
+ const { error } = this.state
+ if (!error) return this.props.children
+ return this.props.renderFallback({
+ error: error.thrownValue,
+ reset: this.reset,
+ retry: this.retry,
+ })
+ }
+}
+
+type Props = Record
+
+export function catchError(
+ fallback: (props: Props, errorInfo: ErrorInfo) => ReactNode,
+) {
+ // A component of its own, so the fallback can use hooks.
+ function Fallback({
+ props,
+ errorInfo,
+ }: {
+ props: Props
+ errorInfo: ErrorInfo
+ }) {
+ return fallback(props, errorInfo)
+ }
+ Fallback.displayName = fallback.name || 'CatchErrorFallback'
+
+ return function CatchErrorBoundary({
+ children,
+ ...props
+ }: Props & { children?: ReactNode }) {
+ return (
+ (
+
+ )}
+ >
+ {children}
+
+ )
+ }
+}
diff --git a/src/next/font.ts b/src/next/font.ts
new file mode 100644
index 0000000..22eb0bb
--- /dev/null
+++ b/src/next/font.ts
@@ -0,0 +1,54 @@
+import { GOOGLE_FONTS } from './google-fonts.ts'
+
+interface FontOptions {
+ weight?: string | string[]
+ style?: string | string[]
+ variable?: string
+}
+
+interface LocalFontOptions extends FontOptions {
+ src: string | { path: string }[]
+}
+
+// Next.js sets a weight or style on the font only when exactly one is loaded.
+const single = (value: string | string[] | undefined) => {
+ const values = [value ?? []].flat()
+ return values.length === 1 ? values[0] : undefined
+}
+
+/**
+ * The object `next/font` gives a component, with stable class names in place
+ * of the hashed ones the compiler generates.
+ */
+function font(family: string, options: FontOptions = {}) {
+ const id = family.toLowerCase().replace(/\W+/g, '_')
+ const weight = single(options.weight)
+ const fontStyle = single(options.style)
+ return {
+ className: `__className_${id}`,
+ style: {
+ fontFamily: `'${family}', '${family} Fallback'`,
+ ...(weight && weight !== 'variable'
+ ? { fontWeight: Number(weight) }
+ : {}),
+ ...(fontStyle ? { fontStyle } : {}),
+ },
+ ...(options.variable ? { variable: `__variable_${id}` } : {}),
+ }
+}
+
+/** `next/font/google`: one function per font, named as Next.js exports them. */
+export const googleFonts = Object.fromEntries(
+ GOOGLE_FONTS.map((name) => [
+ name,
+ (options?: FontOptions) => font(name.replaceAll('_', ' '), options),
+ ]),
+)
+
+/** `next/font/local`: the family is named after the first font file. */
+export default function localFont(options: LocalFontOptions) {
+ const [first] = typeof options.src === 'string' ? [options.src] : options.src
+ const path = typeof first === 'string' ? first : (first?.path ?? '')
+ const file = path.split('/').pop() ?? ''
+ return font(file.replace(/\.\w+$/, '') || 'local', options)
+}
diff --git a/src/next/form.tsx b/src/next/form.tsx
new file mode 100644
index 0000000..a8edbfe
--- /dev/null
+++ b/src/next/form.tsx
@@ -0,0 +1,77 @@
+import { type FormEvent, type FormHTMLAttributes, forwardRef } from 'react'
+
+import { appRouter, state } from './state.ts'
+
+type FormProps = Omit, 'action'> & {
+ action: string | ((formData: FormData) => void | Promise)
+ replace?: boolean
+ scroll?: boolean
+ prefetch?: false | null
+}
+
+// A submit button can override how its form submits; Next.js leaves anything
+// but a same-window GET of url-encoded fields to the browser.
+function isUnsupportedSubmitter(submitter: HTMLElement) {
+ const encType = submitter.getAttribute('formEncType')
+ const method = submitter.getAttribute('formMethod')
+ const target = submitter.getAttribute('formTarget')
+ return (
+ (encType !== null && encType !== 'application/x-www-form-urlencoded') ||
+ (method !== null && method !== 'get') ||
+ (target !== null && target !== '_self') ||
+ /\s*javascript:/i.test(submitter.getAttribute('formAction') ?? '')
+ )
+}
+
+/**
+ * `next/form`. With a string `action`, submitting navigates client-side: it
+ * calls `router.push` (or `replace`) with the action and the fields as its
+ * query, like a GET form. A function `action` is left to React. `method`,
+ * `encType` and `target` are dropped, as Next.js drops them.
+ */
+const Form = forwardRef(function Form(
+ {
+ replace,
+ scroll,
+ prefetch: _prefetch,
+ method: _method,
+ encType: _encType,
+ target: _target,
+ ...props
+ },
+ ref,
+) {
+ const { action, onSubmit } = props
+ if (typeof action !== 'string') return
+
+ const handleSubmit = (event: FormEvent) => {
+ onSubmit?.(event)
+ if (event.defaultPrevented) return
+ const { submitter } = event.nativeEvent as SubmitEvent
+ if (submitter && isUnsupportedSubmitter(submitter)) return
+ const url = new URL(
+ submitter?.getAttribute('formAction') ?? action,
+ state.url,
+ )
+ // Like a browser, the fields replace any query the action has.
+ url.search = ''
+ for (const [name, value] of new FormData(event.currentTarget)) {
+ url.searchParams.append(
+ name,
+ typeof value === 'string' ? value : value.name,
+ )
+ }
+ event.preventDefault()
+ const navigate = replace ? appRouter.replace : appRouter.push
+ navigate(
+ url.origin === state.url.origin
+ ? `${url.pathname}${url.search}${url.hash}`
+ : url.href,
+ { scroll: scroll ?? true },
+ )
+ }
+
+ return
+})
+
+export default Form
diff --git a/src/next/google-fonts.ts b/src/next/google-fonts.ts
new file mode 100644
index 0000000..55fb4bf
--- /dev/null
+++ b/src/next/google-fonts.ts
@@ -0,0 +1,1945 @@
+// Generated by scripts/generate-google-fonts.ts from next@16.3.6. Do not edit.
+export const GOOGLE_FONTS = [
+ 'ABeeZee',
+ 'ADLaM_Display',
+ 'AR_One_Sans',
+ 'Abel',
+ 'Abhaya_Libre',
+ 'Aboreto',
+ 'Abril_Fatface',
+ 'Abyssinica_SIL',
+ 'Aclonica',
+ 'Acme',
+ 'Actor',
+ 'Adamina',
+ 'Advent_Pro',
+ 'Afacad',
+ 'Afacad_Flux',
+ 'Agbalumo',
+ 'Agdasima',
+ 'Agu_Display',
+ 'Aguafina_Script',
+ 'Akatab',
+ 'Akaya_Kanadaka',
+ 'Akaya_Telivigala',
+ 'Akronim',
+ 'Akshar',
+ 'Akt',
+ 'Aladin',
+ 'Alan_Sans',
+ 'Alata',
+ 'Alatsi',
+ 'Albert_Sans',
+ 'Aldrich',
+ 'Alef',
+ 'Alegreya',
+ 'Alegreya_SC',
+ 'Alegreya_Sans',
+ 'Alegreya_Sans_SC',
+ 'Aleo',
+ 'Alex_Brush',
+ 'Alexandria',
+ 'Alfa_Slab_One',
+ 'Alice',
+ 'Alien_Block',
+ 'Alike',
+ 'Alike_Angular',
+ 'Alkalami',
+ 'Alkatra',
+ 'Allan',
+ 'Allerta',
+ 'Allerta_Stencil',
+ 'Allison',
+ 'Allkin',
+ 'Allura',
+ 'Almarai',
+ 'Almendra',
+ 'Almendra_Display',
+ 'Almendra_SC',
+ 'Alumni_Sans',
+ 'Alumni_Sans_Collegiate_One',
+ 'Alumni_Sans_Inline_One',
+ 'Alumni_Sans_Pinstripe',
+ 'Alumni_Sans_SC',
+ 'Alyamama',
+ 'Amarante',
+ 'Amaranth',
+ 'Amarna',
+ 'Amatic_SC',
+ 'Amethysta',
+ 'Amiko',
+ 'Amiri',
+ 'Amiri_Quran',
+ 'Amita',
+ 'Anaheim',
+ 'Ancizar_Sans',
+ 'Ancizar_Serif',
+ 'Andada_Pro',
+ 'Andika',
+ 'Anek_Bangla',
+ 'Anek_Devanagari',
+ 'Anek_Gujarati',
+ 'Anek_Gurmukhi',
+ 'Anek_Kannada',
+ 'Anek_Latin',
+ 'Anek_Malayalam',
+ 'Anek_Odia',
+ 'Anek_Tamil',
+ 'Anek_Telugu',
+ 'Angkor',
+ 'Annapurna_SIL',
+ 'Annie_Use_Your_Telescope',
+ 'Anonymous_Pro',
+ 'Anta',
+ 'Antic',
+ 'Antic_Didone',
+ 'Antic_Slab',
+ 'Anton',
+ 'Anton_SC',
+ 'Antonio',
+ 'Anuphan',
+ 'Anybody',
+ 'Aoboshi_One',
+ 'Arapey',
+ 'Arbutus',
+ 'Arbutus_Slab',
+ 'Architects_Daughter',
+ 'Archivo',
+ 'Archivo_Black',
+ 'Archivo_Narrow',
+ 'Are_You_Serious',
+ 'Aref_Ruqaa',
+ 'Aref_Ruqaa_Ink',
+ 'Arima',
+ 'Arimo',
+ 'Arizonia',
+ 'Armata',
+ 'Arsenal',
+ 'Arsenal_SC',
+ 'Artifika',
+ 'Arvo',
+ 'Arya',
+ 'Asap',
+ 'Asap_Condensed',
+ 'Asar',
+ 'Asimovian',
+ 'Asset',
+ 'Assistant',
+ 'Asta_Sans',
+ 'Astloch',
+ 'Asul',
+ 'Athiti',
+ 'Atkinson_Hyperlegible',
+ 'Atkinson_Hyperlegible_Mono',
+ 'Atkinson_Hyperlegible_Next',
+ 'Atma',
+ 'Atomic_Age',
+ 'Aubrey',
+ 'Audiowide',
+ 'Autour_One',
+ 'Average',
+ 'Average_Sans',
+ 'Averia_Gruesa_Libre',
+ 'Averia_Libre',
+ 'Averia_Sans_Libre',
+ 'Averia_Serif_Libre',
+ 'Azeret_Mono',
+ 'B612',
+ 'B612_Mono',
+ 'BBH_Bartle',
+ 'BBH_Bogle',
+ 'BBH_Hegarty',
+ 'BIZ_UDGothic',
+ 'BIZ_UDMincho',
+ 'BIZ_UDPGothic',
+ 'BIZ_UDPMincho',
+ 'BJCree',
+ 'Babylonica',
+ 'Bacasime_Antique',
+ 'Bad_Script',
+ 'Badeen_Display',
+ 'Bagel_Fat_One',
+ 'Bahiana',
+ 'Bahianita',
+ 'Bai_Jamjuree',
+ 'Bakbak_One',
+ 'Ballet',
+ 'Baloo_2',
+ 'Baloo_Bhai_2',
+ 'Baloo_Bhaijaan_2',
+ 'Baloo_Bhaina_2',
+ 'Baloo_Chettan_2',
+ 'Baloo_Da_2',
+ 'Baloo_Paaji_2',
+ 'Baloo_Tamma_2',
+ 'Baloo_Tammudu_2',
+ 'Baloo_Thambi_2',
+ 'Balsamiq_Sans',
+ 'Balthazar',
+ 'Bangers',
+ 'Barlow',
+ 'Barlow_Condensed',
+ 'Barlow_Semi_Condensed',
+ 'Barriecito',
+ 'Barrio',
+ 'Basic',
+ 'Baskervville',
+ 'Baskervville_SC',
+ 'Battambang',
+ 'Baumans',
+ 'Bayon',
+ 'Be_Vietnam_Pro',
+ 'Beau_Rivage',
+ 'Bebas_Neue',
+ 'Beiruti',
+ 'Belanosima',
+ 'Belgrano',
+ 'Bellefair',
+ 'Belleza',
+ 'Bellota',
+ 'Bellota_Text',
+ 'BenchNine',
+ 'Benne',
+ 'Bentham',
+ 'Berkshire_Swash',
+ 'Besley',
+ 'Betania_Patmos',
+ 'Betania_Patmos_GDL',
+ 'Betania_Patmos_In',
+ 'Betania_Patmos_In_GDL',
+ 'Beth_Ellen',
+ 'Bevan',
+ 'BhuTuka_Expanded_One',
+ 'Big_Shoulders',
+ 'Big_Shoulders_Inline',
+ 'Big_Shoulders_Stencil',
+ 'Bigelow_Rules',
+ 'Bigshot_One',
+ 'Bilbo',
+ 'Bilbo_Swash_Caps',
+ 'BioRhyme',
+ 'BioRhyme_Expanded',
+ 'Birthstone',
+ 'Birthstone_Bounce',
+ 'Biryani',
+ 'Bitcount',
+ 'Bitcount_Grid_Double',
+ 'Bitcount_Grid_Double_Ink',
+ 'Bitcount_Grid_Single',
+ 'Bitcount_Grid_Single_Ink',
+ 'Bitcount_Ink',
+ 'Bitcount_Prop_Double',
+ 'Bitcount_Prop_Double_Ink',
+ 'Bitcount_Prop_Single',
+ 'Bitcount_Prop_Single_Ink',
+ 'Bitcount_Single',
+ 'Bitcount_Single_Ink',
+ 'Bitter',
+ 'Black_And_White_Picture',
+ 'Black_Han_Sans',
+ 'Black_Ops_One',
+ 'Blaka',
+ 'Blaka_Hollow',
+ 'Blaka_Ink',
+ 'Blinker',
+ 'Bodoni_Moda',
+ 'Bodoni_Moda_SC',
+ 'Bokor',
+ 'Boldonse',
+ 'Bona_Nova',
+ 'Bona_Nova_SC',
+ 'Bonbon',
+ 'Bonheur_Royale',
+ 'Boogaloo',
+ 'Borel',
+ 'Bowlby_One',
+ 'Bowlby_One_SC',
+ 'Bpmf_Huninn',
+ 'Bpmf_Iansui',
+ 'Bpmf_Zihi_Kai_Std',
+ 'Braah_One',
+ 'Brawler',
+ 'Bree_Serif',
+ 'Bricolage_Grotesque',
+ 'Bruno_Ace',
+ 'Bruno_Ace_SC',
+ 'Brygada_1918',
+ 'Bubblegum_Sans',
+ 'Bubbler_One',
+ 'Buda',
+ 'Buenard',
+ 'Bungee',
+ 'Bungee_Hairline',
+ 'Bungee_Inline',
+ 'Bungee_Outline',
+ 'Bungee_Shade',
+ 'Bungee_Spice',
+ 'Bungee_Tint',
+ 'Butcherman',
+ 'Butterfly_Kids',
+ 'Bytesized',
+ 'Cabin',
+ 'Cabin_Condensed',
+ 'Cabin_Sketch',
+ 'Cactus_Classical_Serif',
+ 'Caesar_Dressing',
+ 'Cagliostro',
+ 'Cairo',
+ 'Cairo_Play',
+ 'Cal_Sans',
+ 'Caladea',
+ 'Calistoga',
+ 'Calligraffitti',
+ 'Cambay',
+ 'Cambo',
+ 'Candal',
+ 'Cantarell',
+ 'Cantata_One',
+ 'Cantora_One',
+ 'Caprasimo',
+ 'Capriola',
+ 'Caramel',
+ 'Carattere',
+ 'Cardo',
+ 'Carlito',
+ 'Carme',
+ 'Carrois_Gothic',
+ 'Carrois_Gothic_SC',
+ 'Carter_One',
+ 'Cascadia_Code',
+ 'Cascadia_Mono',
+ 'Castoro',
+ 'Castoro_Titling',
+ 'Catamaran',
+ 'Caudex',
+ 'Cause',
+ 'Caveat',
+ 'Caveat_Brush',
+ 'Cedarville_Cursive',
+ 'Ceviche_One',
+ 'Chakra_Petch',
+ 'Changa',
+ 'Changa_One',
+ 'Chango',
+ 'Charis_SIL',
+ 'Charm',
+ 'Charmonman',
+ 'Chathura',
+ 'Chau_Philomene_One',
+ 'Chela_One',
+ 'Chelsea_Market',
+ 'Chenla',
+ 'Cherish',
+ 'Cherry_Bomb_One',
+ 'Cherry_Cream_Soda',
+ 'Cherry_Swash',
+ 'Chewy',
+ 'Chicle',
+ 'Chilanka',
+ 'Chiron_GoRound_TC',
+ 'Chiron_Hei_HK',
+ 'Chiron_Sung_HK',
+ 'Chivo',
+ 'Chivo_Mono',
+ 'Chocolate_Classical_Sans',
+ 'Chokokutai',
+ 'Chonburi',
+ 'Cinzel',
+ 'Cinzel_Decorative',
+ 'Clicker_Script',
+ 'Climate_Crisis',
+ 'Coda',
+ 'Codystar',
+ 'Coiny',
+ 'Combo',
+ 'Comfortaa',
+ 'Comforter',
+ 'Comforter_Brush',
+ 'Comic_Neue',
+ 'Comic_Relief',
+ 'Coming_Soon',
+ 'Comme',
+ 'Commissioner',
+ 'Concert_One',
+ 'Condiment',
+ 'Content',
+ 'Contrail_One',
+ 'Convergence',
+ 'Cookie',
+ 'Copse',
+ 'Coral_Pixels',
+ 'Corben',
+ 'Corinthia',
+ 'Cormorant',
+ 'Cormorant_Garamond',
+ 'Cormorant_Infant',
+ 'Cormorant_SC',
+ 'Cormorant_Unicase',
+ 'Cormorant_Upright',
+ 'Cossette_Texte',
+ 'Cossette_Titre',
+ 'Courgette',
+ 'Courier_Prime',
+ 'Cousine',
+ 'Coustard',
+ 'Covered_By_Your_Grace',
+ 'Crafty_Girls',
+ 'Creepster',
+ 'Crete_Round',
+ 'Crimson_Pro',
+ 'Crimson_Text',
+ 'Croissant_One',
+ 'Crushed',
+ 'Cuprum',
+ 'Cute_Font',
+ 'Cutive',
+ 'Cutive_Mono',
+ 'DM_Mono',
+ 'DM_Sans',
+ 'DM_Serif_Display',
+ 'DM_Serif_Text',
+ 'Dai_Banna_SIL',
+ 'Damion',
+ 'Dancing_Script',
+ 'Danfo',
+ 'Dangrek',
+ 'Darker_Grotesque',
+ 'Darumadrop_One',
+ 'Datatype',
+ 'David_Libre',
+ 'Dawning_of_a_New_Day',
+ 'Days_One',
+ 'Dekko',
+ 'Dela_Gothic_One',
+ 'Delicious_Handrawn',
+ 'Delius',
+ 'Delius_Swash_Caps',
+ 'Delius_Unicase',
+ 'Della_Respira',
+ 'Denk_One',
+ 'Devonshire',
+ 'Dhurjati',
+ 'Didact_Gothic',
+ 'Diphylleia',
+ 'Diplomata',
+ 'Diplomata_SC',
+ 'Do_Hyeon',
+ 'Dokdo',
+ 'Domine',
+ 'Donegal_One',
+ 'Dongle',
+ 'Doppio_One',
+ 'Dorsa',
+ 'Dosis',
+ 'DotGothic16',
+ 'Doto',
+ 'Dr_Sugiyama',
+ 'Duru_Sans',
+ 'DynaPuff',
+ 'Dynalight',
+ 'EB_Garamond',
+ 'Eagle_Lake',
+ 'East_Sea_Dokdo',
+ 'Eater',
+ 'Economica',
+ 'Eczar',
+ 'Edu_AU_VIC_WA_NT_Arrows',
+ 'Edu_AU_VIC_WA_NT_Dots',
+ 'Edu_AU_VIC_WA_NT_Guides',
+ 'Edu_AU_VIC_WA_NT_Hand',
+ 'Edu_AU_VIC_WA_NT_Pre',
+ 'Edu_NSW_ACT_Cursive',
+ 'Edu_NSW_ACT_Foundation',
+ 'Edu_NSW_ACT_Hand_Pre',
+ 'Edu_QLD_Beginner',
+ 'Edu_QLD_Hand',
+ 'Edu_SA_Beginner',
+ 'Edu_SA_Hand',
+ 'Edu_TAS_Beginner',
+ 'Edu_VIC_WA_NT_Beginner',
+ 'Edu_VIC_WA_NT_Hand',
+ 'Edu_VIC_WA_NT_Hand_Pre',
+ 'El_Messiri',
+ 'Electrolize',
+ 'Elms_Sans',
+ 'Elsie',
+ 'Elsie_Swash_Caps',
+ 'Emblema_One',
+ 'Emilys_Candy',
+ 'Encode_Sans',
+ 'Encode_Sans_Condensed',
+ 'Encode_Sans_Expanded',
+ 'Encode_Sans_SC',
+ 'Encode_Sans_Semi_Condensed',
+ 'Encode_Sans_Semi_Expanded',
+ 'Engagement',
+ 'Englebert',
+ 'Enriqueta',
+ 'Ephesis',
+ 'Epilogue',
+ 'Epunda_Sans',
+ 'Epunda_Slab',
+ 'Erica_One',
+ 'Esteban',
+ 'Estedad',
+ 'Estonia',
+ 'Euphoria_Script',
+ 'Ewert',
+ 'Exile',
+ 'Exo',
+ 'Exo_2',
+ 'Expletus_Sans',
+ 'Explora',
+ 'Faculty_Glyphic',
+ 'Fahkwang',
+ 'Familjen_Grotesk',
+ 'Fanwood_Text',
+ 'Farro',
+ 'Farsan',
+ 'Fascinate',
+ 'Fascinate_Inline',
+ 'Faster_One',
+ 'Fasthand',
+ 'Fauna_One',
+ 'Faustina',
+ 'Federant',
+ 'Federo',
+ 'Felipa',
+ 'Fenix',
+ 'Festive',
+ 'Figtree',
+ 'Finger_Paint',
+ 'Finlandica_Headline',
+ 'Finlandica_Text',
+ 'Fira_Code',
+ 'Fira_Mono',
+ 'Fira_Sans',
+ 'Fira_Sans_Condensed',
+ 'Fira_Sans_Extra_Condensed',
+ 'Fjalla_One',
+ 'Fjord_One',
+ 'Flamenco',
+ 'Flavors',
+ 'Fleur_De_Leah',
+ 'Flow_Block',
+ 'Flow_Circular',
+ 'Flow_Rounded',
+ 'Foldit',
+ 'Fondamento',
+ 'Fontdiner_Swanky',
+ 'Forum',
+ 'Fragment_Mono',
+ 'Francois_One',
+ 'Frank_Ruhl_Libre',
+ 'Fraunces',
+ 'Freckle_Face',
+ 'Fredericka_the_Great',
+ 'Fredoka',
+ 'Freehand',
+ 'Freeman',
+ 'Fresca',
+ 'Frijole',
+ 'Fruktur',
+ 'Fugaz_One',
+ 'Fuggles',
+ 'Funnel_Display',
+ 'Funnel_Sans',
+ 'Fustat',
+ 'Fuzzy_Bubbles',
+ 'GFS_Didot',
+ 'GFS_Neohellenic',
+ 'Ga_Maamli',
+ 'Gabarito',
+ 'Gabriela',
+ 'Gaegu',
+ 'Gafata',
+ 'Gajraj_One',
+ 'Galada',
+ 'Galdeano',
+ 'Galindo',
+ 'Gamja_Flower',
+ 'Gantari',
+ 'Gasoek_One',
+ 'Gayathri',
+ 'Geist',
+ 'Geist_Mono',
+ 'Geist_Pixel',
+ 'Gelasio',
+ 'Gemunu_Libre',
+ 'Genos',
+ 'Gentium_Book_Plus',
+ 'Gentium_Plus',
+ 'Geo',
+ 'Geologica',
+ 'Geom',
+ 'Geomini',
+ 'Georama',
+ 'Geostar',
+ 'Geostar_Fill',
+ 'Germania_One',
+ 'Gideon_Roman',
+ 'Gidole',
+ 'Gidugu',
+ 'Gilda_Display',
+ 'Girassol',
+ 'Give_You_Glory',
+ 'Glass_Antiqua',
+ 'Glegoo',
+ 'Gloock',
+ 'Gloria_Hallelujah',
+ 'Glory',
+ 'Gluten',
+ 'Goblin_One',
+ 'Gochi_Hand',
+ 'Goldman',
+ 'Golos_Text',
+ 'Google_Sans',
+ 'Google_Sans_Code',
+ 'Google_Sans_Flex',
+ 'Gorditas',
+ 'Gothic_A1',
+ 'Gotu',
+ 'Goudy_Bookletter_1911',
+ 'Gowun_Batang',
+ 'Gowun_Dodum',
+ 'Graduate',
+ 'Grand_Hotel',
+ 'Grandiflora_One',
+ 'Grandstander',
+ 'Grape_Nuts',
+ 'Gravitas_One',
+ 'Great_Vibes',
+ 'Grechen_Fuemen',
+ 'Grenze',
+ 'Grenze_Gotisch',
+ 'Grey_Qo',
+ 'Griffy',
+ 'Gruppo',
+ 'Gudea',
+ 'Gugi',
+ 'Gulzar',
+ 'Gupter',
+ 'Gurajada',
+ 'Gveret_Levin',
+ 'Gwendolyn',
+ 'Habibi',
+ 'Hachi_Maru_Pop',
+ 'Hahmlet',
+ 'Halant',
+ 'Hammersmith_One',
+ 'Hanalei',
+ 'Hanalei_Fill',
+ 'Handjet',
+ 'Handlee',
+ 'Hanken_Grotesk',
+ 'Hanuman',
+ 'Happy_Monkey',
+ 'Harmattan',
+ 'Headland_One',
+ 'Hedvig_Letters_Sans',
+ 'Hedvig_Letters_Serif',
+ 'Heebo',
+ 'Henny_Penny',
+ 'Hepta_Slab',
+ 'Herr_Von_Muellerhoff',
+ 'Hi_Melody',
+ 'Hibur_Mono',
+ 'Hina_Mincho',
+ 'Hind',
+ 'Hind_Guntur',
+ 'Hind_Madurai',
+ 'Hind_Mysuru',
+ 'Hind_Siliguri',
+ 'Hind_Vadodara',
+ 'Holtwood_One_SC',
+ 'Homemade_Apple',
+ 'Homenaje',
+ 'Honk',
+ 'Host_Grotesk',
+ 'Hubballi',
+ 'Hubot_Sans',
+ 'Huninn',
+ 'Hurricane',
+ 'IBM_Plex_Mono',
+ 'IBM_Plex_Sans',
+ 'IBM_Plex_Sans_Arabic',
+ 'IBM_Plex_Sans_Condensed',
+ 'IBM_Plex_Sans_Devanagari',
+ 'IBM_Plex_Sans_Hebrew',
+ 'IBM_Plex_Sans_JP',
+ 'IBM_Plex_Sans_KR',
+ 'IBM_Plex_Sans_Thai',
+ 'IBM_Plex_Sans_Thai_Looped',
+ 'IBM_Plex_Serif',
+ 'IM_Fell_DW_Pica',
+ 'IM_Fell_DW_Pica_SC',
+ 'IM_Fell_Double_Pica',
+ 'IM_Fell_Double_Pica_SC',
+ 'IM_Fell_English',
+ 'IM_Fell_English_SC',
+ 'IM_Fell_French_Canon',
+ 'IM_Fell_French_Canon_SC',
+ 'IM_Fell_Great_Primer',
+ 'IM_Fell_Great_Primer_SC',
+ 'Iansui',
+ 'Ibarra_Real_Nova',
+ 'Iceberg',
+ 'Iceland',
+ 'Idiqlat',
+ 'Imbue',
+ 'Imperial_Script',
+ 'Imprima',
+ 'Inclusive_Sans',
+ 'Inconsolata',
+ 'Inder',
+ 'Indie_Flower',
+ 'Ingrid_Darling',
+ 'Inika',
+ 'Inknut_Antiqua',
+ 'Inria_Sans',
+ 'Inria_Serif',
+ 'Inspiration',
+ 'Instrument_Sans',
+ 'Instrument_Serif',
+ 'Intel_One_Mono',
+ 'Inter',
+ 'Inter_Tight',
+ 'Iosevka_Charon',
+ 'Iosevka_Charon_Mono',
+ 'Irish_Grover',
+ 'Island_Moments',
+ 'Istok_Web',
+ 'Italiana',
+ 'Italianno',
+ 'Itim',
+ 'Jacquard_12',
+ 'Jacquard_12_Charted',
+ 'Jacquard_24',
+ 'Jacquard_24_Charted',
+ 'Jacquarda_Bastarda_9',
+ 'Jacquarda_Bastarda_9_Charted',
+ 'Jacques_Francois',
+ 'Jacques_Francois_Shadow',
+ 'Jaini',
+ 'Jaini_Purva',
+ 'Jaldi',
+ 'Jaro',
+ 'Jersey_10',
+ 'Jersey_10_Charted',
+ 'Jersey_15',
+ 'Jersey_15_Charted',
+ 'Jersey_20',
+ 'Jersey_20_Charted',
+ 'Jersey_25',
+ 'Jersey_25_Charted',
+ 'JetBrains_Mono',
+ 'Jim_Nightshade',
+ 'Joan',
+ 'Jockey_One',
+ 'Jolly_Lodger',
+ 'Jomhuria',
+ 'Jomolhari',
+ 'Josefin_Sans',
+ 'Josefin_Slab',
+ 'Jost',
+ 'Joti_One',
+ 'Jua',
+ 'Judson',
+ 'Julee',
+ 'Julius_Sans_One',
+ 'Junge',
+ 'Jura',
+ 'Just_Another_Hand',
+ 'Just_Me_Again_Down_Here',
+ 'K2D',
+ 'Kablammo',
+ 'Kadwa',
+ 'Kaisei_Decol',
+ 'Kaisei_HarunoUmi',
+ 'Kaisei_Opti',
+ 'Kaisei_Tokumin',
+ 'Kalam',
+ 'Kalnia',
+ 'Kalnia_Glaze',
+ 'Kameron',
+ 'Kanchenjunga',
+ 'Kanit',
+ 'Kantumruy_Pro',
+ 'Kapakana',
+ 'Karantina',
+ 'Karla',
+ 'Karla_Tamil_Inclined',
+ 'Karla_Tamil_Upright',
+ 'Karma',
+ 'Katibeh',
+ 'Kaushan_Script',
+ 'Kavivanar',
+ 'Kavoon',
+ 'Kay_Pho_Du',
+ 'Kdam_Thmor_Pro',
+ 'Keania_One',
+ 'Kedebideri',
+ 'Kelly_Slab',
+ 'Kenia',
+ 'Khand',
+ 'Khmer',
+ 'Khula',
+ 'Kings',
+ 'Kirang_Haerang',
+ 'Kite_One',
+ 'Kiwi_Maru',
+ 'Klee_One',
+ 'Knewave',
+ 'KoHo',
+ 'Kodchasan',
+ 'Kode_Mono',
+ 'Koh_Santepheap',
+ 'Kolker_Brush',
+ 'Konkhmer_Sleokchher',
+ 'Kosugi',
+ 'Kosugi_Maru',
+ 'Kotta_One',
+ 'Koulen',
+ 'Kranky',
+ 'Kreon',
+ 'Kristi',
+ 'Krona_One',
+ 'Krub',
+ 'Kufam',
+ 'Kulim_Park',
+ 'Kumar_One',
+ 'Kumar_One_Outline',
+ 'Kumbh_Sans',
+ 'Kurale',
+ 'LINE_Seed_JP',
+ 'LXGW_Marker_Gothic',
+ 'LXGW_WenKai_Mono_TC',
+ 'LXGW_WenKai_TC',
+ 'La_Belle_Aurore',
+ 'Labrada',
+ 'Lacquer',
+ 'Laila',
+ 'Lakki_Reddy',
+ 'Lalezar',
+ 'Lancelot',
+ 'Langar',
+ 'Lateef',
+ 'Lato',
+ 'Lavishly_Yours',
+ 'League_Gothic',
+ 'League_Script',
+ 'League_Spartan',
+ 'Leckerli_One',
+ 'Ledger',
+ 'Lekton',
+ 'Lemon',
+ 'Lemonada',
+ 'Lexend',
+ 'Lexend_Deca',
+ 'Lexend_Exa',
+ 'Lexend_Giga',
+ 'Lexend_Mega',
+ 'Lexend_Peta',
+ 'Lexend_Tera',
+ 'Lexend_Zetta',
+ 'Libertinus_Keyboard',
+ 'Libertinus_Math',
+ 'Libertinus_Mono',
+ 'Libertinus_Sans',
+ 'Libertinus_Serif',
+ 'Libertinus_Serif_Display',
+ 'Libre_Barcode_128',
+ 'Libre_Barcode_128_Text',
+ 'Libre_Barcode_39',
+ 'Libre_Barcode_39_Extended',
+ 'Libre_Barcode_39_Extended_Text',
+ 'Libre_Barcode_39_Text',
+ 'Libre_Barcode_EAN13_Text',
+ 'Libre_Baskerville',
+ 'Libre_Bodoni',
+ 'Libre_Caslon_Display',
+ 'Libre_Caslon_Text',
+ 'Libre_Franklin',
+ 'Licorice',
+ 'Life_Savers',
+ 'Lilex',
+ 'Lilita_One',
+ 'Lily_Script_One',
+ 'Limelight',
+ 'Linden_Hill',
+ 'Linefont',
+ 'Lisu_Bosa',
+ 'Liter',
+ 'Literata',
+ 'Liu_Jian_Mao_Cao',
+ 'Livvic',
+ 'Lobster',
+ 'Lobster_Two',
+ 'Londrina_Outline',
+ 'Londrina_Shadow',
+ 'Londrina_Sketch',
+ 'Londrina_Solid',
+ 'Long_Cang',
+ 'Lora',
+ 'Love_Light',
+ 'Love_Ya_Like_A_Sister',
+ 'Loved_by_the_King',
+ 'Lovers_Quarrel',
+ 'Luckiest_Guy',
+ 'Lugrasimo',
+ 'Lumanosimo',
+ 'Lunasima',
+ 'Lusitana',
+ 'Lustria',
+ 'Luxurious_Roman',
+ 'Luxurious_Script',
+ 'M_PLUS_1',
+ 'M_PLUS_1_Code',
+ 'M_PLUS_1p',
+ 'M_PLUS_2',
+ 'M_PLUS_Code_Latin',
+ 'M_PLUS_Rounded_1c',
+ 'M_PLUS_U',
+ 'Ma_Shan_Zheng',
+ 'Macondo',
+ 'Macondo_Swash_Caps',
+ 'Mada',
+ 'Madimi_One',
+ 'Magra',
+ 'Maiden_Orange',
+ 'Maitree',
+ 'Major_Mono_Display',
+ 'Mako',
+ 'Mali',
+ 'Mallanna',
+ 'Maname',
+ 'Mandali',
+ 'Manjari',
+ 'Manrope',
+ 'Mansalva',
+ 'Manuale',
+ 'Manufacturing_Consent',
+ 'Marcellus',
+ 'Marcellus_SC',
+ 'Marck_Script',
+ 'Margarine',
+ 'Marhey',
+ 'Markazi_Text',
+ 'Marko_One',
+ 'Marmelad',
+ 'Martel',
+ 'Martel_Sans',
+ 'Martian_Mono',
+ 'Marvel',
+ 'Matangi',
+ 'Mate',
+ 'Mate_SC',
+ 'Matemasie',
+ 'Maven_Pro',
+ 'McLaren',
+ 'Mea_Culpa',
+ 'Meddon',
+ 'MedievalSharp',
+ 'Medula_One',
+ 'Meera_Inimai',
+ 'Megrim',
+ 'Meie_Script',
+ 'Menbere',
+ 'Meow_Script',
+ 'Merienda',
+ 'Merriweather',
+ 'Merriweather_Sans',
+ 'Metal',
+ 'Metal_Mania',
+ 'Metamorphous',
+ 'Metrophobic',
+ 'Michroma',
+ 'Micro_5',
+ 'Micro_5_Charted',
+ 'Milonga',
+ 'Miltonian',
+ 'Miltonian_Tattoo',
+ 'Mina',
+ 'Mingzat',
+ 'Miniver',
+ 'Miranda_Sans',
+ 'Miriam_Libre',
+ 'Mirza',
+ 'Miss_Fajardose',
+ 'Mitr',
+ 'Mochiy_Pop_One',
+ 'Mochiy_Pop_P_One',
+ 'Modak',
+ 'Modern_Antiqua',
+ 'Moderustic',
+ 'Mogra',
+ 'Mohave',
+ 'Moirai_One',
+ 'Molengo',
+ 'Molle',
+ 'Momo_Signature',
+ 'Momo_Trust_Display',
+ 'Momo_Trust_Sans',
+ 'Mona_Sans',
+ 'Monda',
+ 'Monofett',
+ 'Monomakh',
+ 'Monomaniac_One',
+ 'Monoton',
+ 'Monsieur_La_Doulaise',
+ 'Montaga',
+ 'Montagu_Slab',
+ 'MonteCarlo',
+ 'Montenegrin_Gothic_One',
+ 'Montez',
+ 'Montserrat',
+ 'Montserrat_Alternates',
+ 'Montserrat_Underline',
+ 'Moo_Lah_Lah',
+ 'Mooli',
+ 'Moon_Dance',
+ 'Moul',
+ 'Moulpali',
+ 'Mountains_of_Christmas',
+ 'Mouse_Memoirs',
+ 'Mozilla_Headline',
+ 'Mozilla_Text',
+ 'Mr_Bedfort',
+ 'Mr_Dafoe',
+ 'Mr_De_Haviland',
+ 'Mrs_Saint_Delafield',
+ 'Mrs_Sheppards',
+ 'Ms_Madi',
+ 'Mukta',
+ 'Mukta_Mahee',
+ 'Mukta_Malar',
+ 'Mukta_Vaani',
+ 'Mulish',
+ 'Murecho',
+ 'MuseoModerno',
+ 'My_Soul',
+ 'Mynerve',
+ 'Mystery_Quest',
+ 'NTR',
+ 'Nabla',
+ 'Namdhinggo',
+ 'Nanum_Brush_Script',
+ 'Nanum_Gothic',
+ 'Nanum_Gothic_Coding',
+ 'Nanum_Myeongjo',
+ 'Nanum_Pen_Script',
+ 'Narnoor',
+ 'Nata_Sans',
+ 'National_Park',
+ 'Neonderthaw',
+ 'Nerko_One',
+ 'Neucha',
+ 'Neuton',
+ 'New_Amsterdam',
+ 'New_Rocker',
+ 'New_Tegomin',
+ 'News_Cycle',
+ 'Newsreader',
+ 'Niconne',
+ 'Niramit',
+ 'Nixie_One',
+ 'Nobile',
+ 'Nokora',
+ 'Norican',
+ 'Nosifer',
+ 'Notable',
+ 'Nothing_You_Could_Do',
+ 'Noticia_Text',
+ 'Noto_Color_Emoji',
+ 'Noto_Emoji',
+ 'Noto_Kufi_Arabic',
+ 'Noto_Music',
+ 'Noto_Naskh_Arabic',
+ 'Noto_Nastaliq_Urdu',
+ 'Noto_Rashi_Hebrew',
+ 'Noto_Sans',
+ 'Noto_Sans_Adlam',
+ 'Noto_Sans_Adlam_Unjoined',
+ 'Noto_Sans_Anatolian_Hieroglyphs',
+ 'Noto_Sans_Arabic',
+ 'Noto_Sans_Armenian',
+ 'Noto_Sans_Avestan',
+ 'Noto_Sans_Balinese',
+ 'Noto_Sans_Bamum',
+ 'Noto_Sans_Bassa_Vah',
+ 'Noto_Sans_Batak',
+ 'Noto_Sans_Bengali',
+ 'Noto_Sans_Bhaiksuki',
+ 'Noto_Sans_Brahmi',
+ 'Noto_Sans_Buginese',
+ 'Noto_Sans_Buhid',
+ 'Noto_Sans_Canadian_Aboriginal',
+ 'Noto_Sans_Carian',
+ 'Noto_Sans_Caucasian_Albanian',
+ 'Noto_Sans_Chakma',
+ 'Noto_Sans_Cham',
+ 'Noto_Sans_Cherokee',
+ 'Noto_Sans_Chorasmian',
+ 'Noto_Sans_Coptic',
+ 'Noto_Sans_Cuneiform',
+ 'Noto_Sans_Cypriot',
+ 'Noto_Sans_Cypro_Minoan',
+ 'Noto_Sans_Deseret',
+ 'Noto_Sans_Devanagari',
+ 'Noto_Sans_Display',
+ 'Noto_Sans_Duployan',
+ 'Noto_Sans_Egyptian_Hieroglyphs',
+ 'Noto_Sans_Elbasan',
+ 'Noto_Sans_Elymaic',
+ 'Noto_Sans_Ethiopic',
+ 'Noto_Sans_Georgian',
+ 'Noto_Sans_Glagolitic',
+ 'Noto_Sans_Gothic',
+ 'Noto_Sans_Grantha',
+ 'Noto_Sans_Gujarati',
+ 'Noto_Sans_Gunjala_Gondi',
+ 'Noto_Sans_Gurmukhi',
+ 'Noto_Sans_HK',
+ 'Noto_Sans_Hanifi_Rohingya',
+ 'Noto_Sans_Hanunoo',
+ 'Noto_Sans_Hatran',
+ 'Noto_Sans_Hebrew',
+ 'Noto_Sans_Imperial_Aramaic',
+ 'Noto_Sans_Indic_Siyaq_Numbers',
+ 'Noto_Sans_Inscriptional_Pahlavi',
+ 'Noto_Sans_Inscriptional_Parthian',
+ 'Noto_Sans_JP',
+ 'Noto_Sans_Javanese',
+ 'Noto_Sans_KR',
+ 'Noto_Sans_Kaithi',
+ 'Noto_Sans_Kannada',
+ 'Noto_Sans_Kawi',
+ 'Noto_Sans_Kayah_Li',
+ 'Noto_Sans_Kharoshthi',
+ 'Noto_Sans_Khmer',
+ 'Noto_Sans_Khojki',
+ 'Noto_Sans_Khudawadi',
+ 'Noto_Sans_Lao',
+ 'Noto_Sans_Lao_Looped',
+ 'Noto_Sans_Lepcha',
+ 'Noto_Sans_Limbu',
+ 'Noto_Sans_Linear_A',
+ 'Noto_Sans_Linear_B',
+ 'Noto_Sans_Lisu',
+ 'Noto_Sans_Lycian',
+ 'Noto_Sans_Lydian',
+ 'Noto_Sans_Mahajani',
+ 'Noto_Sans_Malayalam',
+ 'Noto_Sans_Mandaic',
+ 'Noto_Sans_Manichaean',
+ 'Noto_Sans_Marchen',
+ 'Noto_Sans_Masaram_Gondi',
+ 'Noto_Sans_Math',
+ 'Noto_Sans_Mayan_Numerals',
+ 'Noto_Sans_Medefaidrin',
+ 'Noto_Sans_Meetei_Mayek',
+ 'Noto_Sans_Mende_Kikakui',
+ 'Noto_Sans_Meroitic',
+ 'Noto_Sans_Miao',
+ 'Noto_Sans_Modi',
+ 'Noto_Sans_Mongolian',
+ 'Noto_Sans_Mono',
+ 'Noto_Sans_Mro',
+ 'Noto_Sans_Multani',
+ 'Noto_Sans_Myanmar',
+ 'Noto_Sans_NKo',
+ 'Noto_Sans_NKo_Unjoined',
+ 'Noto_Sans_Nabataean',
+ 'Noto_Sans_Nag_Mundari',
+ 'Noto_Sans_Nandinagari',
+ 'Noto_Sans_New_Tai_Lue',
+ 'Noto_Sans_Newa',
+ 'Noto_Sans_Nushu',
+ 'Noto_Sans_Ogham',
+ 'Noto_Sans_Ol_Chiki',
+ 'Noto_Sans_Old_Hungarian',
+ 'Noto_Sans_Old_Italic',
+ 'Noto_Sans_Old_North_Arabian',
+ 'Noto_Sans_Old_Permic',
+ 'Noto_Sans_Old_Persian',
+ 'Noto_Sans_Old_Sogdian',
+ 'Noto_Sans_Old_South_Arabian',
+ 'Noto_Sans_Old_Turkic',
+ 'Noto_Sans_Oriya',
+ 'Noto_Sans_Osage',
+ 'Noto_Sans_Osmanya',
+ 'Noto_Sans_Pahawh_Hmong',
+ 'Noto_Sans_Palmyrene',
+ 'Noto_Sans_Pau_Cin_Hau',
+ 'Noto_Sans_PhagsPa',
+ 'Noto_Sans_Phoenician',
+ 'Noto_Sans_Psalter_Pahlavi',
+ 'Noto_Sans_Rejang',
+ 'Noto_Sans_Runic',
+ 'Noto_Sans_SC',
+ 'Noto_Sans_Samaritan',
+ 'Noto_Sans_Saurashtra',
+ 'Noto_Sans_Sharada',
+ 'Noto_Sans_Shavian',
+ 'Noto_Sans_Siddham',
+ 'Noto_Sans_SignWriting',
+ 'Noto_Sans_Sinhala',
+ 'Noto_Sans_Sogdian',
+ 'Noto_Sans_Sora_Sompeng',
+ 'Noto_Sans_Soyombo',
+ 'Noto_Sans_Sundanese',
+ 'Noto_Sans_Sunuwar',
+ 'Noto_Sans_Syloti_Nagri',
+ 'Noto_Sans_Symbols',
+ 'Noto_Sans_Symbols_2',
+ 'Noto_Sans_Syriac',
+ 'Noto_Sans_Syriac_Eastern',
+ 'Noto_Sans_Syriac_Western',
+ 'Noto_Sans_TC',
+ 'Noto_Sans_Tagalog',
+ 'Noto_Sans_Tagbanwa',
+ 'Noto_Sans_Tai_Le',
+ 'Noto_Sans_Tai_Tham',
+ 'Noto_Sans_Tai_Viet',
+ 'Noto_Sans_Takri',
+ 'Noto_Sans_Tamil',
+ 'Noto_Sans_Tamil_Supplement',
+ 'Noto_Sans_Tangsa',
+ 'Noto_Sans_Telugu',
+ 'Noto_Sans_Thaana',
+ 'Noto_Sans_Thai',
+ 'Noto_Sans_Thai_Looped',
+ 'Noto_Sans_Tifinagh',
+ 'Noto_Sans_Tirhuta',
+ 'Noto_Sans_Ugaritic',
+ 'Noto_Sans_Vai',
+ 'Noto_Sans_Vithkuqi',
+ 'Noto_Sans_Wancho',
+ 'Noto_Sans_Warang_Citi',
+ 'Noto_Sans_Yi',
+ 'Noto_Sans_Zanabazar_Square',
+ 'Noto_Serif',
+ 'Noto_Serif_Ahom',
+ 'Noto_Serif_Armenian',
+ 'Noto_Serif_Balinese',
+ 'Noto_Serif_Bengali',
+ 'Noto_Serif_Devanagari',
+ 'Noto_Serif_Display',
+ 'Noto_Serif_Dives_Akuru',
+ 'Noto_Serif_Dogra',
+ 'Noto_Serif_Ethiopic',
+ 'Noto_Serif_Georgian',
+ 'Noto_Serif_Grantha',
+ 'Noto_Serif_Gujarati',
+ 'Noto_Serif_Gurmukhi',
+ 'Noto_Serif_HK',
+ 'Noto_Serif_Hebrew',
+ 'Noto_Serif_Hentaigana',
+ 'Noto_Serif_JP',
+ 'Noto_Serif_KR',
+ 'Noto_Serif_Kannada',
+ 'Noto_Serif_Khitan_Small_Script',
+ 'Noto_Serif_Khmer',
+ 'Noto_Serif_Khojki',
+ 'Noto_Serif_Lao',
+ 'Noto_Serif_Makasar',
+ 'Noto_Serif_Malayalam',
+ 'Noto_Serif_Myanmar',
+ 'Noto_Serif_NP_Hmong',
+ 'Noto_Serif_Old_Uyghur',
+ 'Noto_Serif_Oriya',
+ 'Noto_Serif_Ottoman_Siyaq',
+ 'Noto_Serif_SC',
+ 'Noto_Serif_Sinhala',
+ 'Noto_Serif_TC',
+ 'Noto_Serif_Tamil',
+ 'Noto_Serif_Tangut',
+ 'Noto_Serif_Telugu',
+ 'Noto_Serif_Thai',
+ 'Noto_Serif_Tibetan',
+ 'Noto_Serif_Todhri',
+ 'Noto_Serif_Toto',
+ 'Noto_Serif_Vithkuqi',
+ 'Noto_Serif_Yezidi',
+ 'Noto_Traditional_Nushu',
+ 'Noto_Znamenny_Musical_Notation',
+ 'Nova_Cut',
+ 'Nova_Flat',
+ 'Nova_Mono',
+ 'Nova_Oval',
+ 'Nova_Round',
+ 'Nova_Script',
+ 'Nova_Slim',
+ 'Nova_Square',
+ 'Numans',
+ 'Nunito',
+ 'Nunito_Sans',
+ 'Nuosu_SIL',
+ 'Odibee_Sans',
+ 'Odor_Mean_Chey',
+ 'Offside',
+ 'Oi',
+ 'Ojuju',
+ 'Old_Standard_TT',
+ 'Oldenburg',
+ 'Ole',
+ 'Oleo_Script',
+ 'Oleo_Script_Swash_Caps',
+ 'Onest',
+ 'Oooh_Baby',
+ 'Open_Sans',
+ 'Oranienbaum',
+ 'Orbit',
+ 'Orbitron',
+ 'Oregano',
+ 'Orelega_One',
+ 'Orienta',
+ 'Original_Surfer',
+ 'Oswald',
+ 'Outfit',
+ 'Over_the_Rainbow',
+ 'Overlock',
+ 'Overlock_SC',
+ 'Overpass',
+ 'Overpass_Mono',
+ 'Ovo',
+ 'Oxanium',
+ 'Oxygen',
+ 'Oxygen_Mono',
+ 'PT_Mono',
+ 'PT_Sans',
+ 'PT_Sans_Caption',
+ 'PT_Sans_Narrow',
+ 'PT_Serif',
+ 'PT_Serif_Caption',
+ 'Pacifico',
+ 'Padauk',
+ 'Padyakke_Expanded_One',
+ 'Palanquin',
+ 'Palanquin_Dark',
+ 'Palette_Mosaic',
+ 'Pangolin',
+ 'Paprika',
+ 'Parastoo',
+ 'Parisienne',
+ 'Parkinsans',
+ 'Passero_One',
+ 'Passion_One',
+ 'Passions_Conflict',
+ 'Pathway_Extreme',
+ 'Pathway_Gothic_One',
+ 'Patrick_Hand',
+ 'Patrick_Hand_SC',
+ 'Pattaya',
+ 'Patua_One',
+ 'Pavanam',
+ 'Paytone_One',
+ 'Peddana',
+ 'Peralta',
+ 'Permanent_Marker',
+ 'Petemoss',
+ 'Petit_Formal_Script',
+ 'Petrona',
+ 'Phetsarath',
+ 'Philosopher',
+ 'Phudu',
+ 'Piazzolla',
+ 'Piedra',
+ 'Pinyon_Script',
+ 'Pirata_One',
+ 'Pixelify_Sans',
+ 'Plaster',
+ 'Platypi',
+ 'Play',
+ 'Playball',
+ 'Playfair',
+ 'Playfair_Display',
+ 'Playfair_Display_SC',
+ 'Playpen_Sans',
+ 'Playpen_Sans_Arabic',
+ 'Playpen_Sans_Deva',
+ 'Playpen_Sans_Hebrew',
+ 'Playpen_Sans_Thai',
+ 'Playwrite_AR',
+ 'Playwrite_AR_Guides',
+ 'Playwrite_AT',
+ 'Playwrite_AT_Guides',
+ 'Playwrite_AU_NSW',
+ 'Playwrite_AU_NSW_Guides',
+ 'Playwrite_AU_QLD',
+ 'Playwrite_AU_QLD_Guides',
+ 'Playwrite_AU_SA',
+ 'Playwrite_AU_SA_Guides',
+ 'Playwrite_AU_TAS',
+ 'Playwrite_AU_TAS_Guides',
+ 'Playwrite_AU_VIC',
+ 'Playwrite_AU_VIC_Guides',
+ 'Playwrite_BE_VLG',
+ 'Playwrite_BE_VLG_Guides',
+ 'Playwrite_BE_WAL',
+ 'Playwrite_BE_WAL_Guides',
+ 'Playwrite_BR',
+ 'Playwrite_BR_Guides',
+ 'Playwrite_CA',
+ 'Playwrite_CA_Guides',
+ 'Playwrite_CL',
+ 'Playwrite_CL_Guides',
+ 'Playwrite_CO',
+ 'Playwrite_CO_Guides',
+ 'Playwrite_CU',
+ 'Playwrite_CU_Guides',
+ 'Playwrite_CZ',
+ 'Playwrite_CZ_Guides',
+ 'Playwrite_DE_Grund',
+ 'Playwrite_DE_Grund_Guides',
+ 'Playwrite_DE_LA',
+ 'Playwrite_DE_LA_Guides',
+ 'Playwrite_DE_SAS',
+ 'Playwrite_DE_SAS_Guides',
+ 'Playwrite_DE_VA',
+ 'Playwrite_DE_VA_Guides',
+ 'Playwrite_DK_Loopet',
+ 'Playwrite_DK_Loopet_Guides',
+ 'Playwrite_DK_Uloopet',
+ 'Playwrite_DK_Uloopet_Guides',
+ 'Playwrite_ES',
+ 'Playwrite_ES_Deco',
+ 'Playwrite_ES_Deco_Guides',
+ 'Playwrite_ES_Guides',
+ 'Playwrite_FR_Moderne',
+ 'Playwrite_FR_Moderne_Guides',
+ 'Playwrite_FR_Trad',
+ 'Playwrite_FR_Trad_Guides',
+ 'Playwrite_GB_J',
+ 'Playwrite_GB_J_Guides',
+ 'Playwrite_GB_S',
+ 'Playwrite_GB_S_Guides',
+ 'Playwrite_HR',
+ 'Playwrite_HR_Guides',
+ 'Playwrite_HR_Lijeva',
+ 'Playwrite_HR_Lijeva_Guides',
+ 'Playwrite_HU',
+ 'Playwrite_HU_Guides',
+ 'Playwrite_ID',
+ 'Playwrite_ID_Guides',
+ 'Playwrite_IE',
+ 'Playwrite_IE_Guides',
+ 'Playwrite_IN',
+ 'Playwrite_IN_Guides',
+ 'Playwrite_IS',
+ 'Playwrite_IS_Guides',
+ 'Playwrite_IT_Moderna',
+ 'Playwrite_IT_Moderna_Guides',
+ 'Playwrite_IT_Trad',
+ 'Playwrite_IT_Trad_Guides',
+ 'Playwrite_MX',
+ 'Playwrite_MX_Guides',
+ 'Playwrite_NG_Modern',
+ 'Playwrite_NG_Modern_Guides',
+ 'Playwrite_NL',
+ 'Playwrite_NL_Guides',
+ 'Playwrite_NO',
+ 'Playwrite_NO_Guides',
+ 'Playwrite_NZ',
+ 'Playwrite_NZ_Basic',
+ 'Playwrite_NZ_Basic_Guides',
+ 'Playwrite_NZ_Guides',
+ 'Playwrite_PE',
+ 'Playwrite_PE_Guides',
+ 'Playwrite_PL',
+ 'Playwrite_PL_Guides',
+ 'Playwrite_PT',
+ 'Playwrite_PT_Guides',
+ 'Playwrite_RO',
+ 'Playwrite_RO_Guides',
+ 'Playwrite_SK',
+ 'Playwrite_SK_Guides',
+ 'Playwrite_TZ',
+ 'Playwrite_TZ_Guides',
+ 'Playwrite_US_Modern',
+ 'Playwrite_US_Modern_Guides',
+ 'Playwrite_US_Trad',
+ 'Playwrite_US_Trad_Guides',
+ 'Playwrite_VN',
+ 'Playwrite_VN_Guides',
+ 'Playwrite_ZA',
+ 'Playwrite_ZA_Guides',
+ 'Pliant',
+ 'Plus_Jakarta_Sans',
+ 'Pochaevsk',
+ 'Podkova',
+ 'Poetsen_One',
+ 'Poiret_One',
+ 'Poller_One',
+ 'Poltawski_Nowy',
+ 'Poly',
+ 'Pompiere',
+ 'Ponnala',
+ 'Ponomar',
+ 'Pontano_Sans',
+ 'Poor_Story',
+ 'Poppins',
+ 'Port_Lligat_Sans',
+ 'Port_Lligat_Slab',
+ 'Potta_One',
+ 'Pragati_Narrow',
+ 'Praise',
+ 'Prata',
+ 'Preahvihear',
+ 'Press_Start_2P',
+ 'Pridi',
+ 'Princess_Sofia',
+ 'Prociono',
+ 'Prompt',
+ 'Prosto_One',
+ 'Protest_Guerrilla',
+ 'Protest_Revolution',
+ 'Protest_Riot',
+ 'Protest_Strike',
+ 'Proza_Libre',
+ 'Public_Sans',
+ 'Puppies_Play',
+ 'Puritan',
+ 'Purple_Purse',
+ 'Qahiri',
+ 'Quando',
+ 'Quantico',
+ 'Quattrocento',
+ 'Quattrocento_Sans',
+ 'Questrial',
+ 'Quicksand',
+ 'Quintessential',
+ 'Qwigley',
+ 'Qwitcher_Grypen',
+ 'REM',
+ 'Racing_Sans_One',
+ 'Radio_Canada',
+ 'Radio_Canada_Big',
+ 'Radley',
+ 'Rajdhani',
+ 'Rakkas',
+ 'Raleway',
+ 'Raleway_Dots',
+ 'Ramabhadra',
+ 'Ramaraja',
+ 'Rambla',
+ 'Rammetto_One',
+ 'Rampart_One',
+ 'Ramsina',
+ 'Ranchers',
+ 'Rancho',
+ 'Ranga',
+ 'Rasa',
+ 'Rationale',
+ 'Ravi_Prakash',
+ 'Readex_Pro',
+ 'Recursive',
+ 'Red_Hat_Display',
+ 'Red_Hat_Mono',
+ 'Red_Hat_Text',
+ 'Red_Rose',
+ 'Redacted',
+ 'Redacted_Script',
+ 'Reddit_Mono',
+ 'Reddit_Sans',
+ 'Reddit_Sans_Condensed',
+ 'Redressed',
+ 'Reem_Kufi',
+ 'Reem_Kufi_Fun',
+ 'Reem_Kufi_Ink',
+ 'Reenie_Beanie',
+ 'Reggae_One',
+ 'Rethink_Sans',
+ 'Revalia',
+ 'Rhodium_Libre',
+ 'Ribeye',
+ 'Ribeye_Marrow',
+ 'Righteous',
+ 'Risque',
+ 'Road_Rage',
+ 'Roboto',
+ 'Roboto_Condensed',
+ 'Roboto_Flex',
+ 'Roboto_Mono',
+ 'Roboto_Serif',
+ 'Roboto_Slab',
+ 'Rochester',
+ 'Rock_3D',
+ 'Rock_Salt',
+ 'RocknRoll_One',
+ 'Rokkitt',
+ 'Romanesco',
+ 'Ropa_Sans',
+ 'Rosario',
+ 'Rosarivo',
+ 'Rouge_Script',
+ 'Rowdies',
+ 'Rozha_One',
+ 'Rubik',
+ 'Rubik_80s_Fade',
+ 'Rubik_Beastly',
+ 'Rubik_Broken_Fax',
+ 'Rubik_Bubbles',
+ 'Rubik_Burned',
+ 'Rubik_Dirt',
+ 'Rubik_Distressed',
+ 'Rubik_Doodle_Shadow',
+ 'Rubik_Doodle_Triangles',
+ 'Rubik_Gemstones',
+ 'Rubik_Glitch',
+ 'Rubik_Glitch_Pop',
+ 'Rubik_Iso',
+ 'Rubik_Lines',
+ 'Rubik_Maps',
+ 'Rubik_Marker_Hatch',
+ 'Rubik_Maze',
+ 'Rubik_Microbe',
+ 'Rubik_Mono_One',
+ 'Rubik_Moonrocks',
+ 'Rubik_Pixels',
+ 'Rubik_Puddles',
+ 'Rubik_Scribble',
+ 'Rubik_Spray_Paint',
+ 'Rubik_Storm',
+ 'Rubik_Vinyl',
+ 'Rubik_Wet_Paint',
+ 'Ruda',
+ 'Rufina',
+ 'Ruge_Boogie',
+ 'Ruluko',
+ 'Rum_Raisin',
+ 'Ruslan_Display',
+ 'Russo_One',
+ 'Ruthie',
+ 'Ruwudu',
+ 'Rye',
+ 'SN_Pro',
+ 'STIX_Two_Math',
+ 'STIX_Two_Text',
+ 'SUSE',
+ 'SUSE_Mono',
+ 'Sacramento',
+ 'Sahitya',
+ 'Sail',
+ 'Saira',
+ 'Saira_Condensed',
+ 'Saira_Extra_Condensed',
+ 'Saira_Semi_Condensed',
+ 'Saira_Stencil',
+ 'Salsa',
+ 'Sanchez',
+ 'Sancreek',
+ 'Sankofa_Display',
+ 'Sansation',
+ 'Sansita',
+ 'Sansita_Swashed',
+ 'Sarabun',
+ 'Sarala',
+ 'Sarina',
+ 'Sarpanch',
+ 'Sassy_Frass',
+ 'Satisfy',
+ 'Savate',
+ 'Sawarabi_Gothic',
+ 'Sawarabi_Mincho',
+ 'Scada',
+ 'Scheherazade_New',
+ 'Schibsted_Grotesk',
+ 'Schoolbell',
+ 'Science_Gothic',
+ 'Scope_One',
+ 'Seaweed_Script',
+ 'Secular_One',
+ 'Sedan',
+ 'Sedan_SC',
+ 'Sedgwick_Ave',
+ 'Sedgwick_Ave_Display',
+ 'Sekuya',
+ 'Sen',
+ 'Send_Flowers',
+ 'Sevillana',
+ 'Seymour_One',
+ 'Shadows_Into_Light',
+ 'Shadows_Into_Light_Two',
+ 'Shafarik',
+ 'Shalimar',
+ 'Shantell_Sans',
+ 'Shanti',
+ 'Share',
+ 'Share_Tech',
+ 'Share_Tech_Mono',
+ 'Shippori_Antique',
+ 'Shippori_Antique_B1',
+ 'Shippori_Mincho',
+ 'Shippori_Mincho_B1',
+ 'Shizuru',
+ 'Shojumaru',
+ 'Short_Stack',
+ 'Shrikhand',
+ 'Siemreap',
+ 'Sigmar',
+ 'Sigmar_One',
+ 'Signika',
+ 'Signika_Negative',
+ 'Silkscreen',
+ 'Simonetta',
+ 'Single_Day',
+ 'Sintony',
+ 'Sirin_Stencil',
+ 'Sirivennela',
+ 'Six_Caps',
+ 'Sixtyfour',
+ 'Sixtyfour_Convergence',
+ 'Skranji',
+ 'Slabo_13px',
+ 'Slabo_27px',
+ 'Slackey',
+ 'Slackside_One',
+ 'Smokum',
+ 'Smooch',
+ 'Smooch_Sans',
+ 'Smythe',
+ 'Sniglet',
+ 'Snippet',
+ 'Snowburst_One',
+ 'Sofadi_One',
+ 'Sofia',
+ 'Sofia_Sans',
+ 'Sofia_Sans_Condensed',
+ 'Sofia_Sans_Extra_Condensed',
+ 'Sofia_Sans_Semi_Condensed',
+ 'Solitreo',
+ 'Solway',
+ 'Sometype_Mono',
+ 'Song_Myung',
+ 'Sono',
+ 'Sonsie_One',
+ 'Sora',
+ 'Sorts_Mill_Goudy',
+ 'Sour_Gummy',
+ 'Source_Code_Pro',
+ 'Source_Sans_3',
+ 'Source_Serif_4',
+ 'Space_Grotesk',
+ 'Space_Mono',
+ 'Special_Elite',
+ 'Special_Gothic',
+ 'Special_Gothic_Condensed_One',
+ 'Special_Gothic_Expanded_One',
+ 'Spectral',
+ 'Spectral_SC',
+ 'Spicy_Rice',
+ 'Spinnaker',
+ 'Spirax',
+ 'Splash',
+ 'Spline_Sans',
+ 'Spline_Sans_Mono',
+ 'Squada_One',
+ 'Square_Peg',
+ 'Sree_Krushnadevaraya',
+ 'Sriracha',
+ 'Srisakdi',
+ 'Staatliches',
+ 'Stack_Sans_Headline',
+ 'Stack_Sans_Notch',
+ 'Stack_Sans_Text',
+ 'Stalemate',
+ 'Stalinist_One',
+ 'Stardos_Stencil',
+ 'Stick',
+ 'Stick_No_Bills',
+ 'Stint_Ultra_Condensed',
+ 'Stint_Ultra_Expanded',
+ 'Stoke',
+ 'Story_Script',
+ 'Strait',
+ 'Strichpunkt_Sans',
+ 'Style_Script',
+ 'Stylish',
+ 'Sue_Ellen_Francisco',
+ 'Suez_One',
+ 'Sulphur_Point',
+ 'Sumana',
+ 'Sunflower',
+ 'Sunshiney',
+ 'Supermercado_One',
+ 'Sura',
+ 'Suranna',
+ 'Suravaram',
+ 'Suwannaphum',
+ 'Swanky_and_Moo_Moo',
+ 'Syncopate',
+ 'Syne',
+ 'Syne_Mono',
+ 'Syne_Tactile',
+ 'TASA_Explorer',
+ 'TASA_Orbiter',
+ 'Tac_One',
+ 'Tagesschrift',
+ 'Tai_Heritage_Pro',
+ 'Tajawal',
+ 'Tangerine',
+ 'Tapestry',
+ 'Taprom',
+ 'Tauri',
+ 'Taviraj',
+ 'Teachers',
+ 'Teko',
+ 'Tektur',
+ 'Telex',
+ 'Tenali_Ramakrishna',
+ 'Tenor_Sans',
+ 'Text_Me_One',
+ 'Texturina',
+ 'Thasadith',
+ 'The_Girl_Next_Door',
+ 'The_Nautigal',
+ 'Tienne',
+ 'TikTok_Sans',
+ 'Tillana',
+ 'Tilt_Neon',
+ 'Tilt_Prism',
+ 'Tilt_Warp',
+ 'Timmana',
+ 'Tinos',
+ 'Tiny5',
+ 'Tiro_Bangla',
+ 'Tiro_Devanagari_Hindi',
+ 'Tiro_Devanagari_Marathi',
+ 'Tiro_Devanagari_Sanskrit',
+ 'Tiro_Gurmukhi',
+ 'Tiro_Kannada',
+ 'Tiro_Tamil',
+ 'Tiro_Telugu',
+ 'Tirra',
+ 'Titan_One',
+ 'Titillium_Web',
+ 'Tomorrow',
+ 'Tourney',
+ 'Trade_Winds',
+ 'Train_One',
+ 'Triodion',
+ 'Trirong',
+ 'Trispace',
+ 'Trocchi',
+ 'Trochut',
+ 'Truculenta',
+ 'Trykker',
+ 'Tsukimi_Rounded',
+ 'Tuffy',
+ 'Tulpen_One',
+ 'Turret_Road',
+ 'Twinkle_Star',
+ 'Ubuntu',
+ 'Ubuntu_Condensed',
+ 'Ubuntu_Mono',
+ 'Ubuntu_Sans',
+ 'Ubuntu_Sans_Mono',
+ 'Uchen',
+ 'Ultra',
+ 'Unbounded',
+ 'Uncial_Antiqua',
+ 'Underdog',
+ 'Unica_One',
+ 'UnifrakturCook',
+ 'UnifrakturMaguntia',
+ 'Unkempt',
+ 'Unlock',
+ 'Unna',
+ 'UoqMunThenKhung',
+ 'Updock',
+ 'Urbanist',
+ 'VT323',
+ 'Vampiro_One',
+ 'Varela',
+ 'Varela_Round',
+ 'Varta',
+ 'Vast_Shadow',
+ 'Vazirmatn',
+ 'Vend_Sans',
+ 'Vesper_Libre',
+ 'Viaoda_Libre',
+ 'Vibes',
+ 'Vibur',
+ 'Victor_Mono',
+ 'Vidaloka',
+ 'Viga',
+ 'Vina_Sans',
+ 'Voces',
+ 'Volkhov',
+ 'Vollkorn',
+ 'Vollkorn_SC',
+ 'Voltaire',
+ 'Vujahday_Script',
+ 'WDXL_Lubrifont_JP_N',
+ 'WDXL_Lubrifont_SC',
+ 'WDXL_Lubrifont_TC',
+ 'Waiting_for_the_Sunrise',
+ 'Wallpoet',
+ 'Walter_Turncoat',
+ 'Warnes',
+ 'Water_Brush',
+ 'Waterfall',
+ 'Wavefont',
+ 'Wellfleet',
+ 'Wendy_One',
+ 'Whisper',
+ 'WindSong',
+ 'Winky_Rough',
+ 'Winky_Sans',
+ 'Wire_One',
+ 'Wittgenstein',
+ 'Wix_Madefor_Display',
+ 'Wix_Madefor_Text',
+ 'Work_Sans',
+ 'Workbench',
+ 'Xanh_Mono',
+ 'Yaldevi',
+ 'Yanone_Kaffeesatz',
+ 'Yantramanav',
+ 'Yarndings_12',
+ 'Yarndings_12_Charted',
+ 'Yarndings_20',
+ 'Yarndings_20_Charted',
+ 'Yatra_One',
+ 'Yellowtail',
+ 'Yeon_Sung',
+ 'Yeseva_One',
+ 'Yesteryear',
+ 'Yomogi',
+ 'Young_Serif',
+ 'Yrsa',
+ 'Ysabeau',
+ 'Ysabeau_Infant',
+ 'Ysabeau_Office',
+ 'Ysabeau_SC',
+ 'Yuji_Boku',
+ 'Yuji_Hentaigana_Akari',
+ 'Yuji_Hentaigana_Akebono',
+ 'Yuji_Mai',
+ 'Yuji_Syuku',
+ 'Yusei_Magic',
+ 'Yuyu',
+ 'Yuyu_Short',
+ 'ZCOOL_KuaiLe',
+ 'ZCOOL_QingKe_HuangYou',
+ 'ZCOOL_XiaoWei',
+ 'Zain',
+ 'Zalando_Sans',
+ 'Zalando_Sans_Expanded',
+ 'Zalando_Sans_SemiExpanded',
+ 'Zen_Antique',
+ 'Zen_Antique_Soft',
+ 'Zen_Dots',
+ 'Zen_Kaku_Gothic_Antique',
+ 'Zen_Kaku_Gothic_New',
+ 'Zen_Kurenaido',
+ 'Zen_Loop',
+ 'Zen_Maru_Gothic',
+ 'Zen_Old_Mincho',
+ 'Zen_Tokyo_Zoo',
+ 'Zeyada',
+ 'Zhi_Mang_Xing',
+ 'Zilla_Slab',
+ 'Zilla_Slab_Highlight',
+]
diff --git a/src/next/head.tsx b/src/next/head.tsx
new file mode 100644
index 0000000..e4faa4b
--- /dev/null
+++ b/src/next/head.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from 'react'
+
+/**
+ * `next/head`: React 19 already hoists ``, `` and `` into
+ * `document.head` from anywhere in the tree, which is what Next.js does with
+ * these children in the browser.
+ */
+export default function Head({ children }: { children?: ReactNode }) {
+ return <>{children}>
+}
+
+export function defaultHead(): ReactNode[] {
+ return [
+ ,
+ ,
+ ]
+}
diff --git a/src/next/headers.ts b/src/next/headers.ts
new file mode 100644
index 0000000..a80c0f9
--- /dev/null
+++ b/src/next/headers.ts
@@ -0,0 +1,39 @@
+import { RequestCookies } from './cookies.ts'
+import { state } from './state.ts'
+
+function readOnly(): never {
+ throw new Error(
+ 'Headers cannot be modified. Read more: https://nextjs.org/docs/app/api-reference/functions/headers',
+ )
+}
+
+/** The request headers a test set with `setHeaders`, read-only as in Next.js. */
+export async function headers(): Promise {
+ return Object.assign(new Headers(state.headers), {
+ append: readOnly,
+ delete: readOnly,
+ set: readOnly,
+ })
+}
+
+/**
+ * The request cookies a test set with `setCookies`. Writes stick for the rest
+ * of the test, as a Server Action's would.
+ */
+export async function cookies() {
+ return new RequestCookies(state.cookies)
+}
+
+export async function draftMode() {
+ return {
+ get isEnabled() {
+ return state.draftMode
+ },
+ enable() {
+ state.draftMode = true
+ },
+ disable() {
+ state.draftMode = false
+ },
+ }
+}
diff --git a/src/next/image.tsx b/src/next/image.tsx
new file mode 100644
index 0000000..60ae1ff
--- /dev/null
+++ b/src/next/image.tsx
@@ -0,0 +1,158 @@
+import { type CSSProperties, forwardRef, type ImgHTMLAttributes } from 'react'
+
+export interface StaticImageData {
+ src: string
+ width: number
+ height: number
+ blurDataURL?: string
+}
+
+type ImageSource = string | StaticImageData | { default: StaticImageData }
+
+type Size = number | `${number}`
+
+type ImageProps = Omit<
+ ImgHTMLAttributes,
+ 'src' | 'width' | 'height' | 'loading'
+> & {
+ src: ImageSource
+ alt: string
+ width?: Size
+ height?: Size
+ fill?: boolean
+ loader?: unknown
+ quality?: Size
+ priority?: boolean
+ preload?: boolean
+ loading?: 'eager' | 'lazy'
+ placeholder?: string
+ blurDataURL?: string
+ unoptimized?: boolean
+ overrideSrc?: string
+ onLoadingComplete?: (image: HTMLImageElement) => void
+}
+
+const FILL_STYLE: CSSProperties = {
+ position: 'absolute',
+ height: '100%',
+ width: '100%',
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+}
+
+const BLUR_STYLE: CSSProperties = {
+ backgroundSize: 'cover',
+ backgroundPosition: '50% 50%',
+ backgroundRepeat: 'no-repeat',
+}
+
+const staticData = (src: ImageSource) =>
+ typeof src === 'string' ? undefined : 'default' in src ? src.default : src
+
+const toNumber = (value: Size | undefined) =>
+ value === undefined ? undefined : Number(value)
+
+/**
+ * The props Next.js gives an unoptimized image: the source as written, lazy
+ * unless preloaded, and the same inline style. There is no image optimizer in
+ * a test, so `srcSet` and `/_next/image` URLs are never produced.
+ */
+export function getImageProps({
+ src,
+ width,
+ height,
+ fill,
+ loader: _loader,
+ quality: _quality,
+ priority,
+ preload,
+ loading,
+ placeholder,
+ blurDataURL: _blurDataURL,
+ unoptimized: _unoptimized,
+ overrideSrc,
+ onLoadingComplete: _onLoadingComplete,
+ sizes: _sizes,
+ className,
+ style,
+ ...rest
+}: ImageProps) {
+ const data = staticData(src)
+ return {
+ props: {
+ ...rest,
+ loading: priority || preload ? undefined : (loading ?? 'lazy'),
+ width: fill ? undefined : toNumber(width ?? data?.width),
+ height: fill ? undefined : toNumber(height ?? data?.height),
+ decoding: 'async' as const,
+ className,
+ style: {
+ ...(fill ? FILL_STYLE : {}),
+ color: 'transparent',
+ ...style,
+ ...(placeholder === 'blur' ? BLUR_STYLE : {}),
+ },
+ src: overrideSrc ?? data?.src ?? (src as string),
+ },
+ }
+}
+
+const Image = forwardRef(
+ function Image(props, ref) {
+ const { loading, width, height, decoding, className, style, src, ...rest } =
+ getImageProps(props).props
+ // Attribute order follows Next.js, so the markup - and snapshots - match.
+ return (
+ // biome-ignore lint/a11y/useAltText: the required alt is in rest, in its place
+
+ )
+ },
+)
+
+export default Image
+
+type LegacyImageProps = ImageProps & {
+ layout?: 'fixed' | 'intrinsic' | 'responsive' | 'fill'
+ objectFit?: CSSProperties['objectFit']
+ objectPosition?: CSSProperties['objectPosition']
+ lazyBoundary?: string
+ lazyRoot?: unknown
+}
+
+/** `next/legacy/image`: the same image without the old wrapper spans. */
+export const LegacyImage = forwardRef(
+ function LegacyImage(
+ {
+ layout = 'intrinsic',
+ objectFit,
+ objectPosition,
+ lazyBoundary: _lazyBoundary,
+ lazyRoot: _lazyRoot,
+ style,
+ ...props
+ },
+ ref,
+ ) {
+ const imageProps = getImageProps({
+ ...props,
+ style: { objectFit, objectPosition, ...style },
+ }).props
+ return (
+ // biome-ignore lint/a11y/useAltText: the required alt is in imageProps
+
+ )
+ },
+)
diff --git a/src/next/index.ts b/src/next/index.ts
new file mode 100644
index 0000000..171d1ab
--- /dev/null
+++ b/src/next/index.ts
@@ -0,0 +1,185 @@
+import { beforeEach, mock } from 'bun:test'
+
+import App from './app.tsx'
+import * as cache from './cache.ts'
+import Document, {
+ Head as DocumentHead,
+ Html,
+ Main,
+ NextScript,
+} from './document.tsx'
+import dynamic, { noSSR } from './dynamic.tsx'
+import ErrorPage, { catchError } from './error.tsx'
+import localFont, { googleFonts } from './font.ts'
+import Form from './form.tsx'
+import Head, { defaultHead } from './head.tsx'
+import { cookies, draftMode, headers } from './headers.ts'
+import Image, { getImageProps, LegacyImage } from './image.tsx'
+import Link, { useLinkStatus } from './link.tsx'
+import * as navigation from './navigation.ts'
+import {
+ dispatchOfflineChange,
+ OfflineProvider,
+ useOffline,
+} from './offline.ts'
+import {
+ createRouter,
+ makePublicRouterInstance,
+ pagesRouter,
+ Router,
+ useCompatRouter,
+ useRouter,
+ withRouter,
+} from './router.tsx'
+import Script, { handleClientScriptLoad, initScriptLoader } from './script.ts'
+import * as server from './server.ts'
+import { appRouter, type Params, resetState, setUrl, state } from './state.ts'
+import { useReportWebVitals } from './web-vitals.ts'
+
+// Every public `next/*` module, with the exports it has at runtime. A named
+// import links only when the name is listed, so none of these is a Proxy.
+const { isNextRouterError: _, ...nextNavigation } = navigation
+
+const modules: Record object> = {
+ 'next/navigation': () => nextNavigation,
+ 'next/link': () => ({ default: Link, useLinkStatus }),
+ 'next/image': () => ({ default: Image, getImageProps }),
+ 'next/legacy/image': () => ({ default: LegacyImage }),
+ 'next/script': () => ({
+ default: Script,
+ handleClientScriptLoad,
+ initScriptLoader,
+ }),
+ 'next/head': () => ({ default: Head, defaultHead }),
+ 'next/dynamic': () => ({ default: dynamic, noSSR }),
+ 'next/form': () => ({ default: Form }),
+ 'next/headers': () => ({ cookies, draftMode, headers }),
+ 'next/cache': () => ({ ...cache }),
+ 'next/server': () => ({ ...server }),
+ 'next/og': () => ({ ImageResponse: server.ImageResponse }),
+ 'next/font/google': () => googleFonts,
+ 'next/font/local': () => ({ default: localFont }),
+ 'next/router': () => ({
+ default: pagesRouter,
+ Router,
+ createRouter,
+ makePublicRouterInstance,
+ useRouter,
+ withRouter,
+ router: pagesRouter,
+ readyCallbacks: pagesRouter.readyCallbacks,
+ ready: pagesRouter.ready,
+ push: pagesRouter.push,
+ replace: pagesRouter.replace,
+ reload: pagesRouter.reload,
+ back: pagesRouter.back,
+ prefetch: pagesRouter.prefetch,
+ beforePopState: pagesRouter.beforePopState,
+ }),
+ 'next/compat/router': () => ({ useRouter: useCompatRouter }),
+ 'next/document': () => ({
+ default: Document,
+ Html,
+ Head: DocumentHead,
+ Main,
+ NextScript,
+ }),
+ 'next/app': () => ({
+ default: App,
+ getInitialProps: App.getInitialProps,
+ origGetInitialProps: App.origGetInitialProps,
+ }),
+ 'next/error': () => ({
+ default: ErrorPage,
+ catchError,
+ displayName: ErrorPage.displayName,
+ getInitialProps: ErrorPage.getInitialProps,
+ origGetInitialProps: ErrorPage.origGetInitialProps,
+ }),
+ 'next/offline': () => ({
+ OfflineProvider,
+ useOffline,
+ dispatchOfflineChange,
+ }),
+ 'next/web-vitals': () => ({ useReportWebVitals }),
+}
+
+for (const [specifier, factory] of Object.entries(modules)) {
+ mock.module(specifier, factory)
+}
+
+// `next/root-params` exports one getter per root layout param, generated for
+// each app, so its exports are the names tests have set.
+const rootParamNames = new Set()
+
+function registerRootParams() {
+ mock.module('next/root-params', () =>
+ Object.fromEntries(
+ [...rootParamNames].map((name) => [
+ name,
+ async () => state.rootParams[name],
+ ]),
+ ),
+ )
+}
+
+registerRootParams()
+
+beforeEach(resetState)
+
+/**
+ * The router `useRouter()` from `next/navigation` returns, and the one
+ * `next/link` and `next/form` navigate through. Its methods are mocks.
+ */
+export const router = appRouter
+
+/** The URL `usePathname`, `useSearchParams` and the Pages Router read. */
+export { setUrl }
+
+/** The dynamic route params: `useParams()` and the Pages Router's `query`. */
+export function setParams(params: Params) {
+ state.params = params
+}
+
+/** The Pages Router route, like `/posts/[id]`: `pathname` and `route`. */
+export function setRoute(route: string) {
+ state.route = route
+}
+
+/** What `useSelectedLayoutSegments()` returns, in place of the path's. */
+export function setSegments(segments: string[]) {
+ state.segments = segments
+}
+
+/** The request headers `headers()` returns. */
+export function setHeaders(init: HeadersInit) {
+ state.headers = new Headers(init)
+}
+
+/** The request cookies `cookies()` returns. */
+export function setCookies(cookies: Record) {
+ state.cookies = new Map(Object.entries(cookies))
+}
+
+/** Whether `draftMode()` is enabled. */
+export function setDraftMode(enabled: boolean) {
+ state.draftMode = enabled
+}
+
+/** What `useOffline()` returns. */
+export function setOffline(offline: boolean) {
+ state.offline = offline
+}
+
+/**
+ * The params `next/root-params` reads. A module that imports one links only
+ * once it is set, so load that module afterwards, with `await import()`.
+ */
+export function setRootParams(params: Record) {
+ state.rootParams = params
+ for (const name of Object.keys(params)) rootParamNames.add(name)
+ registerRootParams()
+}
+
+/** Restores all of the above, and clears every mock. Runs before each test. */
+export const resetNext = resetState
diff --git a/src/next/link.tsx b/src/next/link.tsx
new file mode 100644
index 0000000..5152980
--- /dev/null
+++ b/src/next/link.tsx
@@ -0,0 +1,203 @@
+import {
+ type AnchorHTMLAttributes,
+ Children,
+ cloneElement,
+ createElement,
+ forwardRef,
+ type MouseEvent,
+ type ReactElement,
+} from 'react'
+
+import { appRouter, state } from './state.ts'
+
+type QueryValue = string | number | boolean | null | undefined
+
+export interface UrlObject {
+ auth?: string | null
+ hash?: string | null
+ host?: string | null
+ hostname?: string | null
+ pathname?: string | null
+ port?: string | number | null
+ protocol?: string | null
+ query?: string | null | Record
+ search?: string | null
+ slashes?: boolean | null
+}
+
+export type Url = string | UrlObject
+
+type LinkProps = Omit, 'href'> & {
+ href: Url
+ as?: Url
+ replace?: boolean
+ scroll?: boolean
+ shallow?: boolean
+ passHref?: boolean
+ prefetch?: boolean | 'auto' | null
+ locale?: string | false
+ legacyBehavior?: boolean
+ onNavigate?: (event: { preventDefault(): void }) => void
+ transitionTypes?: string[]
+ unstable_dynamicOnHover?: boolean
+}
+
+const SLASHED_PROTOCOLS = /https?|ftp|gopher|file/
+const ABSOLUTE_URL = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/
+
+const stringifyQueryValue = (value: QueryValue) =>
+ typeof value === 'string'
+ ? value
+ : (typeof value === 'number' && !Number.isNaN(value)) ||
+ typeof value === 'boolean'
+ ? String(value)
+ : ''
+
+/** Next's `formatUrl`: how a url object `href` is written into the markup. */
+export function formatUrl(url: Url): string {
+ if (typeof url === 'string') return url
+ let protocol = url.protocol || ''
+ let pathname = url.pathname || ''
+ let hash = url.hash || ''
+ let query = url.query || ''
+ const auth = url.auth
+ ? `${encodeURIComponent(url.auth).replace(/%3A/i, ':')}@`
+ : ''
+ let host: string | false = false
+ if (url.host) {
+ host = auth + url.host
+ } else if (url.hostname) {
+ host =
+ auth + (url.hostname.includes(':') ? `[${url.hostname}]` : url.hostname)
+ if (url.port) host += `:${url.port}`
+ }
+ if (typeof query === 'object') {
+ const params = new URLSearchParams()
+ for (const [key, value] of Object.entries(query)) {
+ if (Array.isArray(value)) {
+ for (const item of value) params.append(key, stringifyQueryValue(item))
+ } else {
+ params.set(key, stringifyQueryValue(value as QueryValue))
+ }
+ }
+ query = String(params)
+ }
+ let search = url.search || (query && `?${query}`) || ''
+ if (protocol && !protocol.endsWith(':')) protocol += ':'
+ if (
+ url.slashes ||
+ ((!protocol || SLASHED_PROTOCOLS.test(protocol)) && host !== false)
+ ) {
+ host = `//${host || ''}`
+ if (pathname && pathname[0] !== '/') pathname = `/${pathname}`
+ } else if (!host) {
+ host = ''
+ }
+ if (hash && hash[0] !== '#') hash = `#${hash}`
+ if (search && search[0] !== '?') search = `?${search}`
+ pathname = pathname.replace(/[?#]/g, encodeURIComponent)
+ search = search.replace('#', '%23')
+ return `${protocol}${host}${pathname}${search}${hash}`
+}
+
+/** A relative URL, or an absolute one on the page's own origin. */
+function isLocalURL(url: string) {
+ if (!ABSOLUTE_URL.test(url)) return true
+ try {
+ return new URL(url).origin === state.url.origin
+ } catch {
+ return false
+ }
+}
+
+/**
+ * `next/link`. A click Next.js would handle client-side calls `router.push`
+ * (or `replace`) with the `href`; the browser keeps modified clicks, other
+ * targets, downloads and URLs of another origin.
+ */
+const Link = forwardRef(function Link(
+ {
+ href,
+ as,
+ children,
+ replace,
+ scroll,
+ shallow: _shallow,
+ passHref,
+ prefetch: _prefetch,
+ locale: _locale,
+ legacyBehavior,
+ onNavigate,
+ transitionTypes: _transitionTypes,
+ unstable_dynamicOnHover: _dynamicOnHover,
+ onClick,
+ ...props
+ },
+ ref,
+) {
+ const url = formatUrl(as ?? href)
+ // Text gets an `` of its own, which then takes the href.
+ const child = legacyBehavior
+ ? (Children.only(
+ typeof children === 'string' || typeof children === 'number'
+ ? createElement('a', null, children)
+ : children,
+ ) as ReactElement>)
+ : undefined
+
+ const handleClick = (event: MouseEvent) => {
+ const clickHandler = child ? child.props.onClick : onClick
+ clickHandler?.(event)
+ if (event.defaultPrevented) return
+ const element = event.currentTarget
+ const target = element.getAttribute('target')
+ const modified =
+ (target && target !== '_self') ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey ||
+ event.button === 1
+ if (
+ (element.nodeName.toUpperCase() === 'A' && modified) ||
+ element.hasAttribute('download') ||
+ !isLocalURL(url)
+ ) {
+ return
+ }
+ event.preventDefault()
+ let prevented = false
+ onNavigate?.({
+ preventDefault: () => {
+ prevented = true
+ },
+ })
+ if (prevented) return
+ const navigate = replace ? appRouter.replace : appRouter.push
+ navigate(url, { scroll: scroll ?? true })
+ }
+
+ if (child) {
+ // The child gets the click handler, and the href where Next.js hands it
+ // on: absolute URLs, `passHref`, or an `` without an href of its own.
+ const withHref =
+ ABSOLUTE_URL.test(url) ||
+ passHref ||
+ (child.type === 'a' && !('href' in child.props))
+ return cloneElement(child, {
+ onClick: handleClick,
+ ...(withHref ? { href: url } : {}),
+ })
+ }
+
+ // Props before href, like Next.js, so the markup - and snapshots - match.
+ return (
+
+ {children}
+
+ )
+})
+
+export default Link
+
+export const useLinkStatus = () => ({ pending: false })
diff --git a/src/next/navigation.ts b/src/next/navigation.ts
new file mode 100644
index 0000000..9d2a3b5
--- /dev/null
+++ b/src/next/navigation.ts
@@ -0,0 +1,98 @@
+import { createContext } from 'react'
+
+import { ReadonlyURLSearchParams } from './readonly-url-search-params.ts'
+import { appRouter, state } from './state.ts'
+
+// Errors carry the same `digest` Next.js gives them, so code and tests that
+// branch on it (`isRedirectError`, error boundaries, `unstable_rethrow`)
+// behave as they do in the app.
+const REDIRECT = 'NEXT_REDIRECT'
+const HTTP_ERROR_FALLBACK = 'NEXT_HTTP_ERROR_FALLBACK'
+const BAILOUT_TO_CSR = 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
+
+export const RedirectType = { push: 'push', replace: 'replace' } as const
+type RedirectKind = (typeof RedirectType)[keyof typeof RedirectType]
+
+function digestError(message: string, digest: string): never {
+ throw Object.assign(new Error(message), { digest })
+}
+
+export function redirect(url: string, type: RedirectKind = 'replace'): never {
+ return digestError(REDIRECT, `${REDIRECT};${type};${url};307;`)
+}
+
+export function permanentRedirect(
+ url: string,
+ type: RedirectKind = 'replace',
+): never {
+ return digestError(REDIRECT, `${REDIRECT};${type};${url};308;`)
+}
+
+export function notFound(): never {
+ return digestError(`${HTTP_ERROR_FALLBACK};404`, `${HTTP_ERROR_FALLBACK};404`)
+}
+
+export function forbidden(): never {
+ return digestError(`${HTTP_ERROR_FALLBACK};403`, `${HTTP_ERROR_FALLBACK};403`)
+}
+
+export function unauthorized(): never {
+ return digestError(`${HTTP_ERROR_FALLBACK};401`, `${HTTP_ERROR_FALLBACK};401`)
+}
+
+const digestOf = (error: unknown) =>
+ typeof error === 'object' &&
+ error !== null &&
+ 'digest' in error &&
+ typeof error.digest === 'string'
+ ? error.digest
+ : ''
+
+/** Redirects and `notFound` / `forbidden` / `unauthorized`. */
+export function isNextRouterError(error: unknown) {
+ const digest = digestOf(error)
+ return (
+ digest.startsWith(`${REDIRECT};`) ||
+ digest.startsWith(`${HTTP_ERROR_FALLBACK};`)
+ )
+}
+
+/** Re-throws errors Next.js uses for control flow, including wrapped ones. */
+export function unstable_rethrow(error: unknown): void {
+ if (isNextRouterError(error) || digestOf(error) === BAILOUT_TO_CSR) {
+ throw error
+ }
+ if (error instanceof Error && 'cause' in error) unstable_rethrow(error.cause)
+}
+
+export function unstable_isUnrecognizedActionError(error: unknown) {
+ return error instanceof Error && error.name === 'UnrecognizedActionError'
+}
+
+export const ServerInsertedHTMLContext = createContext<
+ ((callback: () => unknown) => void) | null
+>(null)
+
+export function useServerInsertedHTML(_callback: () => unknown): void {}
+
+export const useRouter = () => appRouter
+
+export const usePathname = () => state.url.pathname
+
+export const useSearchParams = () => state.searchParams
+
+export const useParams = () => state.params
+
+export function useSelectedLayoutSegments(
+ _parallelRoutesKey?: string,
+): string[] {
+ return state.segments ?? state.url.pathname.split('/').filter(Boolean)
+}
+
+export function useSelectedLayoutSegment(
+ parallelRoutesKey?: string,
+): string | null {
+ return useSelectedLayoutSegments(parallelRoutesKey)[0] ?? null
+}
+
+export { ReadonlyURLSearchParams }
diff --git a/src/next/offline.ts b/src/next/offline.ts
new file mode 100644
index 0000000..622d1ae
--- /dev/null
+++ b/src/next/offline.ts
@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react'
+
+import { state } from './state.ts'
+
+/** `next/offline`: online unless a test calls `setOffline(true)`. */
+export const useOffline = () => state.offline
+
+export function dispatchOfflineChange(isOffline: boolean) {
+ state.offline = isOffline
+}
+
+export function OfflineProvider({ children }: { children?: ReactNode }) {
+ return children
+}
diff --git a/src/next/readonly-url-search-params.ts b/src/next/readonly-url-search-params.ts
new file mode 100644
index 0000000..8f1616b
--- /dev/null
+++ b/src/next/readonly-url-search-params.ts
@@ -0,0 +1,29 @@
+function unavailable(): never {
+ throw new Error(
+ 'Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams',
+ )
+}
+
+/** Same contract as Next's: readable like `URLSearchParams`, never writable. */
+export class ReadonlyURLSearchParams extends URLSearchParams {
+ // biome-ignore lint/complexity/noUselessConstructor: bun's coverage never counts the implicit constructor of a built-in's subclass as run
+ constructor(init?: ConstructorParameters[0]) {
+ super(init)
+ }
+
+ override append(): never {
+ return unavailable()
+ }
+
+ override delete(): never {
+ return unavailable()
+ }
+
+ override set(): never {
+ return unavailable()
+ }
+
+ override sort(): never {
+ return unavailable()
+ }
+}
diff --git a/src/next/router.tsx b/src/next/router.tsx
new file mode 100644
index 0000000..363ac02
--- /dev/null
+++ b/src/next/router.tsx
@@ -0,0 +1,171 @@
+import type { ComponentType } from 'react'
+
+import { onReset, type Params, resettableMock, state } from './state.ts'
+
+type Handler = (...args: unknown[]) => void
+
+// `mitt`, the emitter behind `Router.events`. Handlers a test leaves behind
+// are dropped before the next one.
+const handlers = new Map()
+onReset(() => handlers.clear())
+
+const events = {
+ on(type: string, handler: Handler) {
+ handlers.set(type, [...(handlers.get(type) ?? []), handler])
+ },
+ off(type: string, handler: Handler) {
+ const list = handlers.get(type)
+ list?.splice(list.indexOf(handler) >>> 0, 1)
+ },
+ emit(type: string, ...args: unknown[]) {
+ for (const handler of handlers.get(type)?.slice() ?? []) handler(...args)
+ },
+}
+
+// The same object until the URL or params change, as in Next.js, so effects
+// that depend on `router.query` do not run on every render.
+let cachedQuery = { searchParams: {}, params: {}, query: {} as Params }
+
+function query() {
+ const { searchParams, params } = state
+ if (
+ cachedQuery.searchParams !== searchParams ||
+ cachedQuery.params !== params
+ ) {
+ const fromSearch: Params = {}
+ for (const key of new Set(searchParams.keys())) {
+ const values = searchParams.getAll(key)
+ fromSearch[key] = values.length > 1 ? values : (values[0] ?? '')
+ }
+ // Dynamic route params win over the query string.
+ cachedQuery = { searchParams, params, query: { ...fromSearch, ...params } }
+ }
+ return cachedQuery.query
+}
+
+type Url = string | object
+type TransitionOptions = { shallow?: boolean; scroll?: boolean }
+
+/**
+ * The Pages Router: `useRouter()` and the `Router` singleton from
+ * `next/router`. `pathname` and `route` are the route set with `setRoute`
+ * (`/posts/[id]`), or the URL's path without one.
+ */
+export const pagesRouter = {
+ get pathname() {
+ return state.route ?? state.url.pathname
+ },
+ get route() {
+ return state.route ?? state.url.pathname
+ },
+ get query() {
+ return query()
+ },
+ get asPath() {
+ return `${state.url.pathname}${state.url.search}${state.url.hash}`
+ },
+ get isPreview() {
+ return state.draftMode
+ },
+ basePath: '',
+ locale: undefined as string | undefined,
+ locales: undefined as string[] | undefined,
+ defaultLocale: undefined as string | undefined,
+ domainLocales: undefined,
+ isLocaleDomain: false,
+ isReady: true,
+ isFallback: false,
+ components: {},
+ events,
+ push: resettableMock(
+ async (_url: Url, _as?: Url, _options?: TransitionOptions) => true,
+ ),
+ replace: resettableMock(
+ async (_url: Url, _as?: Url, _options?: TransitionOptions) => true,
+ ),
+ reload: resettableMock(() => {}),
+ back: resettableMock(() => {}),
+ forward: resettableMock(() => {}),
+ prefetch: resettableMock(
+ async (_url: string, _asPath?: string, _options?: object) => {},
+ ),
+ beforePopState: resettableMock((_callback: (state: object) => boolean) => {}),
+ get router() {
+ return pagesRouter
+ },
+ readyCallbacks: [] as (() => void)[],
+ ready(callback: () => void) {
+ callback()
+ },
+}
+
+export const useRouter = () => pagesRouter
+
+/** `next/compat/router`: `null` as in the App Router, until `setRoute`. */
+export const useCompatRouter = () => (state.route === null ? null : pagesRouter)
+
+// biome-ignore lint/complexity/noStaticOnlyClass: Next.js exports a class
+export class Router {
+ static events = events
+}
+
+export function createRouter(..._args: unknown[]) {
+ return pagesRouter
+}
+
+const URL_FIELDS = [
+ 'pathname',
+ 'route',
+ 'query',
+ 'asPath',
+ 'components',
+ 'isFallback',
+ 'basePath',
+ 'locale',
+ 'locales',
+ 'defaultLocale',
+ 'isReady',
+ 'isPreview',
+ 'isLocaleDomain',
+ 'domainLocales',
+] as const
+const CORE_METHODS = [
+ 'push',
+ 'replace',
+ 'reload',
+ 'back',
+ 'prefetch',
+ 'beforePopState',
+] as const
+
+/** A snapshot of `router` whose methods still call it, as in Next.js. */
+export function makePublicRouterInstance(router: Record) {
+ const instance: Record = { events }
+ for (const field of URL_FIELDS) {
+ const value = router[field]
+ instance[field] =
+ typeof value === 'object'
+ ? Object.assign(Array.isArray(value) ? [] : {}, value)
+ : value
+ }
+ for (const method of CORE_METHODS) {
+ instance[method] = (...args: unknown[]) =>
+ (router[method] as (...args: unknown[]) => unknown)(...args)
+ }
+ return instance
+}
+
+type Composed = ComponentType> & {
+ getInitialProps?: unknown
+ origGetInitialProps?: unknown
+}
+
+export function withRouter(Component: Composed) {
+ function WithRouterWrapper(props: Record) {
+ return
+ }
+ WithRouterWrapper.getInitialProps = Component.getInitialProps
+ WithRouterWrapper.origGetInitialProps = Component.origGetInitialProps
+ WithRouterWrapper.displayName = `withRouter(${Component.displayName || Component.name || 'Unknown'})`
+ return WithRouterWrapper
+}
diff --git a/src/next/script.ts b/src/next/script.ts
new file mode 100644
index 0000000..e36a859
--- /dev/null
+++ b/src/next/script.ts
@@ -0,0 +1,12 @@
+/**
+ * `next/script` loads third-party code as a side effect; it adds nothing to
+ * the component's own markup, and a test never loads the script. So it renders
+ * nothing and never calls `onLoad` / `onReady`.
+ */
+export default function Script(_props: object): null {
+ return null
+}
+
+export function handleClientScriptLoad(_props: object): void {}
+
+export function initScriptLoader(_scripts: object[]): void {}
diff --git a/src/next/server.ts b/src/next/server.ts
new file mode 100644
index 0000000..43d1142
--- /dev/null
+++ b/src/next/server.ts
@@ -0,0 +1,176 @@
+import type { ReactElement } from 'react'
+
+import {
+ parseCookieHeader,
+ RequestCookies,
+ ResponseCookies,
+ serializeCookies,
+} from './cookies.ts'
+
+// In a DOM test `Request` and `Response` are happy-dom's, which drop what a
+// browser keeps from pages: `cookie` and `set-cookie`. These are a server's
+// requests and responses, so their headers keep them.
+function serverHeaders(target: Request | Response, init?: HeadersInit) {
+ const headers = new Headers(init)
+ Object.defineProperty(target, 'headers', { value: headers })
+ return headers
+}
+
+export class NextRequest extends Request {
+ readonly nextUrl: URL & { clone(): URL }
+ readonly cookies: RequestCookies
+
+ constructor(input: URL | RequestInfo, init: RequestInit = {}) {
+ super(input, init)
+ const headers = serverHeaders(
+ this,
+ init.headers ?? (input instanceof Request ? input.headers : undefined),
+ )
+ const url = new URL(this.url)
+ this.nextUrl = Object.assign(url, { clone: () => new URL(url) })
+ const jar = parseCookieHeader(headers.get('cookie'))
+ this.cookies = new RequestCookies(jar, () =>
+ headers.set('cookie', serializeCookies(jar)),
+ )
+ }
+}
+
+interface MiddlewareResponseInit extends ResponseInit {
+ request?: { headers?: Headers }
+}
+
+const REDIRECTS = new Set([301, 302, 303, 307, 308])
+
+// Next.js requires an absolute URL for redirects and rewrites.
+function validateURL(url: string | URL) {
+ try {
+ return String(new URL(String(url)))
+ } catch (error) {
+ throw new Error(
+ `URL is malformed "${String(url)}". Please use only absolute URLs - https://nextjs.org/docs/messages/middleware-relative-urls`,
+ { cause: error },
+ )
+ }
+}
+
+// How `NextResponse.next` and `rewrite` hand modified request headers on.
+function middlewareHeaders(init: MiddlewareResponseInit | undefined) {
+ const headers = new Headers(init?.headers)
+ const request = init?.request?.headers
+ if (request) {
+ const keys: string[] = []
+ for (const [key, value] of request) {
+ headers.set(`x-middleware-request-${key}`, value)
+ keys.push(key)
+ }
+ headers.set('x-middleware-override-headers', keys.join(','))
+ }
+ return headers
+}
+
+export class NextResponse extends Response {
+ readonly cookies: ResponseCookies
+
+ constructor(body?: BodyInit | null, init?: ResponseInit) {
+ super(body, init)
+ this.cookies = new ResponseCookies(serverHeaders(this, init?.headers))
+ }
+
+ static override json(body: unknown, init?: ResponseInit): NextResponse {
+ const response = Response.json(body, init)
+ return new NextResponse(response.body, response)
+ }
+
+ static override redirect(
+ url: string | URL,
+ init?: number | ResponseInit,
+ ): NextResponse {
+ const status = typeof init === 'number' ? init : (init?.status ?? 307)
+ if (!REDIRECTS.has(status)) {
+ throw new RangeError(
+ 'Failed to execute "redirect" on "response": Invalid status code',
+ )
+ }
+ const options = typeof init === 'object' ? init : {}
+ const headers = new Headers(options.headers)
+ headers.set('Location', validateURL(url))
+ return new NextResponse(null, { ...options, headers, status })
+ }
+
+ static rewrite(
+ destination: string | URL,
+ init?: MiddlewareResponseInit,
+ ): NextResponse {
+ const headers = middlewareHeaders(init)
+ headers.set('x-middleware-rewrite', validateURL(destination))
+ return new NextResponse(null, { ...init, headers })
+ }
+
+ static next(init?: MiddlewareResponseInit): NextResponse {
+ const headers = middlewareHeaders(init)
+ headers.set('x-middleware-next', '1')
+ return new NextResponse(null, { ...init, headers })
+ }
+}
+
+const BOT =
+ /Googlebot|Mediapartners-Google|AdsBot-Google|googleweblight|Storebot-Google|Google-PageRenderer|Google-InspectionTool|Bingbot|BingPreview|Slurp|DuckDuckBot|baiduspider|yandex|sogou|LinkedInBot|bitlybot|tumblr|vkShare|quora link preview|facebookexternalhit|facebookcatalog|Twitterbot|applebot|redditbot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|ia_archiver|GPTBot/i
+
+/**
+ * `isBot` is Next's own check. Browser, engine, OS, device and CPU come from
+ * ua-parser-js in Next.js and are left empty here.
+ */
+export function userAgentFromString(ua: string | undefined) {
+ return {
+ ua: ua ?? '',
+ isBot: ua === undefined ? false : BOT.test(ua),
+ browser: {},
+ engine: {},
+ os: {},
+ device: {},
+ cpu: {},
+ }
+}
+
+export function userAgent({ headers }: { headers: Headers }) {
+ return userAgentFromString(headers.get('user-agent') || undefined)
+}
+
+/** Runs the task after the current work, as Next.js does after a response. */
+export function after(task: Promise | (() => unknown)): void {
+ queueMicrotask(() => {
+ if (typeof task === 'function') task()
+ })
+}
+
+export async function connection(): Promise {}
+
+export const URLPattern = (globalThis as unknown as { URLPattern?: unknown })
+ .URLPattern
+
+/**
+ * An empty PNG response with Next's headers. Rendering the image needs the
+ * satori and resvg pipeline, which a test does not run.
+ */
+export class ImageResponse extends Response {
+ constructor(
+ _element: ReactElement,
+ options: ResponseInit & Record = {},
+ ) {
+ const headers = new Headers({
+ 'content-type': 'image/png',
+ 'cache-control':
+ process.env.NODE_ENV === 'development'
+ ? 'no-cache, no-store'
+ : 'public, immutable, no-transform, max-age=31536000',
+ })
+ for (const [key, value] of new Headers(options.headers)) {
+ headers.set(key, value)
+ }
+ super(null, {
+ status: options.status,
+ statusText: options.statusText,
+ headers,
+ })
+ }
+}
diff --git a/src/next/state.ts b/src/next/state.ts
new file mode 100644
index 0000000..a6b1c96
--- /dev/null
+++ b/src/next/state.ts
@@ -0,0 +1,86 @@
+import { mock } from 'bun:test'
+
+import { ReadonlyURLSearchParams } from './readonly-url-search-params.ts'
+
+export type Params = Record
+
+export interface NavigateOptions {
+ scroll?: boolean
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: mocks of any signature
+type AnyFunction = (...args: any[]) => any
+
+/**
+ * One value per process. Should the built and the source entry both be
+ * loaded, each registers its own stand-ins, and every one of them has to read
+ * - and reset - what a test sets through either copy's control functions.
+ */
+function shared(name: string, create: () => T): T {
+ const registry = globalThis as unknown as Record
+ const key = Symbol.for(`bun-test-env-dom.next.${name}`)
+ registry[key] ??= create()
+ return registry[key]
+}
+
+const resets = shared('resets', (): (() => void)[] => [])
+
+/** Registers work `resetState` does before every test. */
+export function onReset(reset: () => void) {
+ resets.push(reset)
+}
+
+/**
+ * A mock that `resetState` restores: its calls are cleared and anything a
+ * test set with `mockImplementation` / `mockReturnValue` is dropped, so
+ * nothing one test does leaks into the next.
+ */
+export function resettableMock(implementation?: T) {
+ const fn = mock(implementation as T)
+ onReset(() => {
+ fn.mockReset()
+ if (implementation) fn.mockImplementation(implementation)
+ })
+ return fn
+}
+
+/** The origin of the page under test. */
+export const ORIGIN = 'http://localhost'
+
+/** The instance `useRouter()` from `next/navigation` returns. */
+export const appRouter = shared('appRouter', () => ({
+ push: resettableMock<(href: string, options?: NavigateOptions) => void>(),
+ replace: resettableMock<(href: string, options?: NavigateOptions) => void>(),
+ prefetch: resettableMock<(href: string, options?: object) => void>(),
+ back: resettableMock<() => void>(),
+ forward: resettableMock<() => void>(),
+ refresh: resettableMock<() => void>(),
+ hmrRefresh: resettableMock<() => void>(),
+}))
+
+const initialState = () => ({
+ url: new URL('/', ORIGIN),
+ searchParams: new ReadonlyURLSearchParams(),
+ params: {} as Params,
+ route: null as string | null,
+ segments: null as string[] | null,
+ headers: new Headers(),
+ cookies: new Map(),
+ draftMode: false,
+ offline: false,
+ rootParams: {} as Record,
+})
+
+/** Everything a test can set, read by every stand-in on each call. */
+export const state = shared('state', initialState)
+
+/** Resolved against the current URL, like a link on the page. */
+export function setUrl(url: string) {
+ state.url = new URL(url, state.url)
+ state.searchParams = new ReadonlyURLSearchParams(state.url.searchParams)
+}
+
+export function resetState() {
+ Object.assign(state, initialState())
+ for (const reset of resets) reset()
+}
diff --git a/src/next/web-vitals.ts b/src/next/web-vitals.ts
new file mode 100644
index 0000000..69653b2
--- /dev/null
+++ b/src/next/web-vitals.ts
@@ -0,0 +1,2 @@
+/** `next/web-vitals`: a test measures no page, so nothing is reported. */
+export function useReportWebVitals(_report: (metric: object) => void): void {}
diff --git a/tsconfig.json b/tsconfig.json
index 4fa11c1..dce1723 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
- "lib": ["ESNext", "DOM"],
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
@@ -28,5 +28,5 @@
"noPropertyAccessFromIndexSignature": false,
"declaration": true
},
- "include": ["src/index.ts"]
+ "include": ["src/index.ts", "src/next/index.ts"]
}