mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"xs": "480px",
|
||||
"sm": "768px",
|
||||
"md": "1024px",
|
||||
"lg": "1184px",
|
||||
"xl": "1440px"
|
||||
}
|
||||
@@ -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 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');
|
||||
});
|
||||
});
|
||||
+11
-7
@@ -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
@@ -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"',
|
||||
|
||||
Reference in New Issue
Block a user