From 813b246a884db93889e6dd3604375068733e4b49 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 23 Sep 2026 21:33:06 +0900 Subject: [PATCH 1/4] feat(next): add stand-ins for the public next/* modules Page tests should not depend on Next's runtime: its router, server and compiler. Each stand-in keeps the runtime exports of next@16.3.6 - a named import only links when the name is listed, so none of them is a Proxy - and reads the URL, params, headers and cookies a test sets. - next/link, next/form: a client-side navigation calls router.push or replace, with Next's rules for what is left to the browser. - next/link, next/image (unoptimized), next/form and next/error render the markup of Next.js 16, checked against next@16.3.6 in happy-dom. - redirect, notFound, forbidden and unauthorized throw the digests of Next. - next/server keeps the cookie and set-cookie headers that happy-dom's Request and Response drop. - next/font/google lists its 1942 fonts, generated by scripts/generate-google-fonts.ts from the declarations of Next.js. State lives on globalThis, so a built and a source copy loaded together read and reset the same values. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- scripts/generate-google-fonts.ts | 30 + src/next/app.tsx | 28 + src/next/cache.ts | 29 + src/next/cookies.ts | 186 +++ src/next/document.tsx | 44 + src/next/dynamic.tsx | 48 + src/next/error.tsx | 205 +++ src/next/font.ts | 54 + src/next/form.tsx | 77 + src/next/google-fonts.ts | 1945 ++++++++++++++++++++++++ src/next/head.tsx | 17 + src/next/headers.ts | 39 + src/next/image.tsx | 158 ++ src/next/index.ts | 185 +++ src/next/link.tsx | 203 +++ src/next/navigation.ts | 98 ++ src/next/offline.ts | 14 + src/next/readonly-url-search-params.ts | 29 + src/next/router.tsx | 171 +++ src/next/script.ts | 12 + src/next/server.ts | 176 +++ src/next/state.ts | 86 ++ src/next/web-vitals.ts | 2 + tsconfig.json | 4 +- 24 files changed, 3838 insertions(+), 2 deletions(-) create mode 100644 scripts/generate-google-fonts.ts create mode 100644 src/next/app.tsx create mode 100644 src/next/cache.ts create mode 100644 src/next/cookies.ts create mode 100644 src/next/document.tsx create mode 100644 src/next/dynamic.tsx create mode 100644 src/next/error.tsx create mode 100644 src/next/font.ts create mode 100644 src/next/form.tsx create mode 100644 src/next/google-fonts.ts create mode 100644 src/next/head.tsx create mode 100644 src/next/headers.ts create mode 100644 src/next/image.tsx create mode 100644 src/next/index.ts create mode 100644 src/next/link.tsx create mode 100644 src/next/navigation.ts create mode 100644 src/next/offline.ts create mode 100644 src/next/readonly-url-search-params.ts create mode 100644 src/next/router.tsx create mode 100644 src/next/script.ts create mode 100644 src/next/server.ts create mode 100644 src/next/state.ts create mode 100644 src/next/web-vitals.ts 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/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 ``, `<meta>` and `<link>` 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 [ + <meta key="charset" charSet="utf-8" />, + <meta key="viewport" content="width=device-width" name="viewport" />, + ] +} 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<Headers> { + 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<HTMLImageElement>, + '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<HTMLImageElement, ImageProps>( + 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 + <img + {...rest} + ref={ref} + loading={loading} + width={width} + height={height} + decoding={decoding} + data-nimg={props.fill ? 'fill' : '1'} + className={className} + style={style} + src={src} + /> + ) + }, +) + +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<HTMLImageElement, LegacyImageProps>( + 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 + <img {...imageProps} ref={ref} data-nimg={layout} /> + ) + }, +) 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<string, () => 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<string>() + +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<string, string>) { + 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<string, string>) { + 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<string, QueryValue | readonly QueryValue[]> + search?: string | null + slashes?: boolean | null +} + +export type Url = string | UrlObject + +type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, '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<HTMLAnchorElement, LinkProps>(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 `<a>` 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<AnchorHTMLAttributes<HTMLAnchorElement>>) + : undefined + + const handleClick = (event: MouseEvent<HTMLAnchorElement>) => { + 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 `<a>` 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 ( + <a {...props} ref={ref} href={url} onClick={handleClick}> + {children} + </a> + ) +}) + +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<typeof URLSearchParams>[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<string, Handler[]>() +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<string, unknown>) { + const instance: Record<string, unknown> = { 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<Record<string, unknown>> & { + getInitialProps?: unknown + origGetInitialProps?: unknown +} + +export function withRouter(Component: Composed) { + function WithRouterWrapper(props: Record<string, unknown>) { + return <Component router={pagesRouter} {...props} /> + } + 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> | (() => unknown)): void { + queueMicrotask(() => { + if (typeof task === 'function') task() + }) +} + +export async function connection(): Promise<void> {} + +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<string, unknown> = {}, + ) { + 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<string, string | string[]> + +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<T>(name: string, create: () => T): T { + const registry = globalThis as unknown as Record<symbol, T | undefined> + 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<T extends AnyFunction>(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<string, string>(), + draftMode: false, + offline: false, + rootParams: {} as Record<string, string | undefined>, +}) + +/** 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"] } From 00a558c7d87d40059f82a93596f218fb90dabb9d Mon Sep 17 00:00:00 2001 From: owjs3901 <owjs3901@gmail.com> Date: Wed, 23 Sep 2026 21:33:24 +0900 Subject: [PATCH 2/4] test(next): preload the stand-ins and cover them The suite preloads src/next/index.ts after the DOM, as a consumer would, and keeps coverage at 100%. The tests check each module's export names against next@16.3.6, the markup of next/link, next/image and next/form, which clicks and submits reach the router, the error digests, catchError, the Pages Router, next/headers, next/cache and next/server, and that every test starts from a reset state. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --- bunfig.toml | 2 +- src/__tests__/next-components.test.tsx | 472 +++++++++++++++++++++++++ src/__tests__/next-image.test.tsx | 107 ++++++ src/__tests__/next-link.test.tsx | 250 +++++++++++++ src/__tests__/next-modules.test.ts | 102 ++++++ src/__tests__/next-navigation.test.tsx | 158 +++++++++ src/__tests__/next-root-params.test.ts | 17 + src/__tests__/next-router.test.tsx | 134 +++++++ src/__tests__/next-server.test.tsx | 293 +++++++++++++++ 9 files changed, 1534 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/next-components.test.tsx create mode 100644 src/__tests__/next-image.test.tsx create mode 100644 src/__tests__/next-link.test.tsx create mode 100644 src/__tests__/next-modules.test.ts create mode 100644 src/__tests__/next-navigation.test.tsx create mode 100644 src/__tests__/next-root-params.test.ts create mode 100644 src/__tests__/next-router.test.tsx create mode 100644 src/__tests__/next-server.test.tsx 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/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( + <Script src="/a.js" strategy="afterInteractive" onLoad={onLoad} />, + ) + 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( + <Head> + <title>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(A)).toBe( + 'A', + ) + }) + + test('with a class, and eager when preloaded', () => { + expect( + html( + A, + ), + ).toBe( + 'A', + ) + expect(html(A)).not.toContain( + 'loading', + ) + }) + + test('fill', () => { + expect(html(A)).toBe( + 'A', + ) + }) + + test('with a blur placeholder', () => { + expect( + html( + A, + ), + ).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(A)).toBe( + 'A', + ) + expect(html(A)).toContain( + 'width="5" height="40"', + ) + expect(html(A)).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('A)).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 + , + ) + 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(() =>