fix(tailwind): make tailwind.config.js loadable by a plain CommonJS require (#3588)

* fix(tailwind): make tailwind.config.js loadable by a plain CommonJS require

`tailwind.config.js` is a CommonJS module, but it `require()`d two TypeScript
sources:

    require('./src/utils/tailwind')            -> src/utils/tailwind.ts
    require('./src/tailwind/container-queries') -> src/tailwind/container-queries.ts

Node's CJS resolver does not resolve `.ts`, so a plain `require()` of the config
threw `Cannot find module './src/utils/tailwind'`.

This was latent rather than user-visible: tailwind v3's `loadConfig()` routes the
config through its bundled jiti, and jiti *does* resolve `.ts`, so Next and the
PostCSS pipeline kept loading the real theme. (Verified: building CSS through the
repo's own postcss.config.js resolves `theme('colors.blue.8')` and
`theme('screens.xs')` correctly both before and after this change.)

The problem is that the project theme silently depended on being loaded by a
TypeScript-aware loader. Any consumer that plain-`require()`s the config — a
script, a lint rule, a tool that inlines it — and any tailwind version that stops
routing through jiti gets tailwind's *stock* theme instead of this project's.
Stock tailwind has no `xs` screen and no numeric colour scale, while ~80
stylesheets use `theme('screens.…')` / `theme('colors.<name>.<digit>')`.

Fix, keeping `breakpoints` single-sourced:

- Move the breakpoints literal to `src/utils/breakpoints.json`. JSON is the one
  format Node's CJS resolver, jiti, webpack and Vite all load natively, so both
  module systems can read the same file.
- `src/utils/tailwind.ts` re-exports it, so the two importers
  (`ContainerGrid.tsx`, `ImageCarousel.tsx`) keep their import path unchanged.
  `resolveJsonModule` keeps the inferred type byte-identical to the old inline
  literal — verified with a type-equality probe that passes against both the old
  and the new module, and fails when the expected type is perturbed.
- Convert `src/tailwind/container-queries.ts` to CommonJS `.js` (types preserved
  as JSDoc). It is loaded only by the tailwind config, so it has no ESM consumers.

Adds `src/utils/tailwind.test.ts`, which loads the config through `createRequire`
— a genuine Node CJS require, no jiti, no TS loader — and asserts the project
screens resolve. Red before this change with the original
`Cannot find module './src/utils/tailwind'`; green after.

* fix(tailwind): keep container-queries.js inside tsc, and tighten the plugin assertion

Both findings from the adversarial audit of this PR.

1. 🔴 The .ts -> .js conversion silently dropped this file out of the type system.
   tsconfig.json:10 sets `allowJs: true` but leaves `checkJs` UNSET, so a .js file
   under src/ is compiled by tsc and type-checked by nothing — the JSDoc @typedef
   /@param annotations added in the previous commit were pure decoration. Adding
   `// @ts-check` restores checking for this one file without touching the global
   config.

   Verified by mutation in BOTH directions, not asserted:
     - with `// @ts-check`, no mutation ....... 13 errors (baseline, file clean)
     - with `// @ts-check`, `(42).toUpperCase()` injected
                                        ....... 14 errors, and the new one is
                                                src/tailwind/container-queries.js(20,31)
                                                TS2339 — this file, this reason
     - WITHOUT `// @ts-check`, same mutation ... 13 errors, ZERO in this file
                                                (i.e. genuinely invisible)
   Every run carried a negative control: the known pre-existing
   packages/civitai-db-schema/.../updated-at-tables.ts(7,38) TS2769 was present in
   all three, proving tsc actually ran. That control matters here — tsc OOM-crashes
   on this repo at the default heap and then reports zero `error TS` lines, which is
   indistinguishable from clean. All runs used --max_old_space_size=8192.

2. The `eslint-disable-next-line @typescript-eslint/no-var-requires` was INERT:
   .eslintignore line 1 is `*.js`, so this file is not linted at all and the
   suppression documented a rule that never runs. Replaced with a note recording
   that fact and what to do if `*.js` is ever removed from .eslintignore.

3. `expect(config.plugins.length).toBeGreaterThanOrEqual(2)` -> `toBe(2)`. The
   loose bound still passed if a plugin were dropped, which is exactly the
   regression the test exists to catch. plugins.length is exactly 2.

src/utils/tailwind.test.ts: 4/4 still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zachary Lowden
2026-08-03 16:42:21 -05:00
committed by GitHub
parent f596de51c6
commit 854973a9f1
5 changed files with 126 additions and 17 deletions
@@ -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<string, string> = theme('containers') ?? {};
/** @type {Record<string, string>} */
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);
+7
View File
@@ -0,0 +1,7 @@
{
"xs": "480px",
"sm": "768px",
"md": "1024px",
"lg": "1184px",
"xl": "1440px"
}
+71
View File
@@ -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<string, string>; extend: { containers: Record<string, string> } };
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 projects screens, not tailwinds 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');
});
});
+11 -7
View File
@@ -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 };
+5 -1
View File
@@ -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"',