diff --git a/src/tailwind/container-queries.ts b/src/tailwind/container-queries.js similarity index 57% rename from src/tailwind/container-queries.ts rename to src/tailwind/container-queries.js index 82db583454..084491d48f 100644 --- a/src/tailwind/container-queries.ts +++ b/src/tailwind/container-queries.js @@ -1,22 +1,45 @@ -import plugin from 'tailwindcss/plugin'; +// @ts-check +// +// `@ts-check` above is load-bearing, not decorative. tsconfig sets `allowJs: true` +// but leaves `checkJs` unset, so a `.js` file under src/ is COMPILED BY tsc and +// TYPE-CHECKED BY NOTHING — the JSDoc annotations below would be pure decoration +// without it. Verified by mutation: injecting `(42).toUpperCase()` here is +// invisible to `tsc --noEmit` when this directive is absent, and raises TS2339 +// when it is present. Do not remove it when editing this file. +// +// CommonJS on purpose. This plugin is loaded exclusively by `tailwind.config.js`, a +// CJS module, via `require()`. Node's CJS resolver cannot resolve `.ts`, so authoring +// this as TypeScript made a plain `require()` of the tailwind config throw — the whole +// project theme then silently depended on the config being loaded by a TypeScript-aware +// loader (tailwind's bundled jiti). Types are kept as JSDoc. +// NOTE: no eslint-disable here on purpose. `.eslintignore` line 1 is `*.js`, so this +// file is not linted at all and a suppression comment would be inert — documenting a +// rule that never runs. If `*.js` is ever removed from `.eslintignore`, this `require` +// will need `@typescript-eslint/no-var-requires` disabled. +const plugin = require('tailwindcss/plugin'); -type VariantSortProps = { - value: string; - modifier: string | null; -}; +/** + * @typedef {{ value: string; modifier: string | null }} VariantSortProps + */ -export default plugin( +module.exports = plugin( function containerQueries({ matchUtilities, matchVariant, theme }) { - const values: Record = theme('containers') ?? {}; + /** @type {Record} */ + const values = theme('containers') ?? {}; - function parseValue(value: string) { + /** @param {string} value */ + function parseValue(value) { const numericValue = value.match(/^(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null; if (numericValue === null) return null; return parseFloat(value); } - function sort(aVariant: VariantSortProps, zVariant: VariantSortProps) { + /** + * @param {VariantSortProps} aVariant + * @param {VariantSortProps} zVariant + */ + function sort(aVariant, zVariant) { const a = parseFloat(aVariant.value); const z = parseFloat(zVariant.value); diff --git a/src/utils/breakpoints.json b/src/utils/breakpoints.json new file mode 100644 index 0000000000..96bdf8b0f2 --- /dev/null +++ b/src/utils/breakpoints.json @@ -0,0 +1,7 @@ +{ + "xs": "480px", + "sm": "768px", + "md": "1024px", + "lg": "1184px", + "xl": "1440px" +} diff --git a/src/utils/tailwind.test.ts b/src/utils/tailwind.test.ts new file mode 100644 index 0000000000..7efae46a19 --- /dev/null +++ b/src/utils/tailwind.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { breakpoints } from '~/utils/tailwind'; + +// `tailwind.config.js` is a CommonJS module, but it used to `require()` TypeScript +// sources (`./src/utils/tailwind`, `./src/tailwind/container-queries`). Node's CJS +// resolver cannot resolve `.ts`, so a plain `require()` of the config threw +// `Cannot find module './src/utils/tailwind'`. +// +// That stayed invisible because tailwind v3's own `loadConfig()` routes the config +// through its bundled jiti, and jiti *does* resolve `.ts` — so Next and the PostCSS +// pipeline loaded the real theme regardless. The dependency on a TypeScript-aware +// loader was therefore silent: any consumer that plain-`require()`s the config (a +// script, a lint rule, a bundler that inlines it), or any tailwind version that stops +// routing through jiti, would fall back to tailwind's *stock* theme instead of this +// project's — and stock tailwind has no `xs` screen and no numeric colour scale, so +// the ~80 stylesheets doing `theme('screens.xs')` / `theme('colors.blue.8')` would +// break at build time. +// +// These tests pin the plain-Node path. `createRequire` gives a genuine Node CJS +// require — no vite transform, no jiti, no TypeScript loader — i.e. exactly the +// resolver that was failing. +const nodeRequire = createRequire(import.meta.url); +const configPath = path.resolve(__dirname, '../../tailwind.config.js'); + +type TailwindConfig = { + theme: { screens: Record; extend: { containers: Record } }; + plugins: unknown[]; +}; + +describe('tailwind.config.js', () => { + it('loads with a plain Node require (no jiti, no TypeScript loader)', () => { + expect(() => nodeRequire(configPath)).not.toThrow(); + }); + + it('resolves this project’s screens, not tailwind’s stock defaults', () => { + const config = nodeRequire(configPath) as TailwindConfig; + + // Pinned literally: derived from the implementation these would pass against a + // stock theme too. `xs` is the discriminator — stock tailwind ships + // sm/md/lg/xl/2xl and has no `xs`. + expect(config.theme.screens).toEqual({ + xs: '480px', + sm: '768px', + md: '1024px', + lg: '1184px', + xl: '1440px', + }); + expect(config.theme.screens).not.toHaveProperty('2xl'); + }); + + it('keeps the breakpoints single-sourced with the module app code imports', () => { + const config = nodeRequire(configPath) as TailwindConfig; + + // If someone re-inlines the literal into either file, these drift and this fails. + expect(config.theme.screens).toEqual(breakpoints); + expect(config.theme.extend.containers).toEqual(breakpoints); + }); + + it('loads the container-queries plugin (the other formerly-TS require)', () => { + const config = nodeRequire(configPath) as TailwindConfig; + + // Exactly 2, not >=2: the loose bound still passed if a plugin were dropped, + // which is the regression this test exists to catch. + expect(config.plugins.length).toBe(2); + // tailwind plugins are `{ handler, config? }`; a failed require would have thrown + // above, so this asserts the module actually produced a usable plugin. + expect(config.plugins[0]).toHaveProperty('handler'); + }); +}); diff --git a/src/utils/tailwind.ts b/src/utils/tailwind.ts index 520316de58..2081a3d15c 100644 --- a/src/utils/tailwind.ts +++ b/src/utils/tailwind.ts @@ -1,7 +1,11 @@ -export const breakpoints = { - xs: '480px', - sm: '768px', - md: '1024px', - lg: '1184px', - xl: '1440px', -}; +// The literal lives in `breakpoints.json` so that it stays single-sourced across two +// module systems: this ESM/TypeScript module (imported by app components) and the +// CommonJS `tailwind.config.js` (which `require()`s the same JSON). JSON is the one +// format Node's CJS resolver, jiti, webpack and Vite all load natively, so neither +// side needs a TypeScript-aware loader to read it. +// +// `resolveJsonModule` keeps the inferred type here identical to the old inline object +// literal (`{ xs: string; sm: string; md: string; lg: string; xl: string }`). +import breakpoints from './breakpoints.json'; + +export { breakpoints }; diff --git a/tailwind.config.js b/tailwind.config.js index d1499dae87..945773919b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,10 @@ const plugin = require('tailwindcss/plugin'); const colors = require('tailwindcss/colors'); -const { breakpoints } = require('./src/utils/tailwind') +// Shared with app code via `src/utils/tailwind.ts`, which re-exports this same JSON. +// It must stay a format Node's CommonJS resolver can load on its own: this file is a +// CJS module, and a plain `require()` of it (a script, a lint rule, a bundler that +// inlines the config) gets Node's resolver, which cannot resolve `.ts`. +const breakpoints = require('./src/utils/breakpoints.json'); const fontFamilies = [ '"-apple-system"',