Files
civitai__civitai/src/tailwind/container-queries.js
T
Zachary Lowden 854973a9f1 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>
2026-08-03 16:42:21 -05:00

129 lines
3.8 KiB
JavaScript

// @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');
/**
* @typedef {{ value: string; modifier: string | null }} VariantSortProps
*/
module.exports = plugin(
function containerQueries({ matchUtilities, matchVariant, theme }) {
/** @type {Record<string, string>} */
const values = theme('containers') ?? {};
/** @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);
}
/**
* @param {VariantSortProps} aVariant
* @param {VariantSortProps} zVariant
*/
function sort(aVariant, zVariant) {
const a = parseFloat(aVariant.value);
const z = parseFloat(zVariant.value);
if (a === null || z === null) return 0;
// Sort values themselves regardless of unit
if (a - z !== 0) return a - z;
const aLabel = aVariant.modifier ?? '';
const zLabel = zVariant.modifier ?? '';
// Explicitly move empty labels to the end
if (aLabel === '' && zLabel !== '') {
return 1;
} else if (aLabel !== '' && zLabel === '') {
return -1;
}
// Sort labels alphabetically in the English locale
// We are intentionally overriding the locale because we do not want the sort to
// be affected by the machine's locale (be it a developer or CI environment)
return aLabel.localeCompare(zLabel, 'en', { numeric: true });
}
matchUtilities(
{
'@container': (value, { modifier }) => {
return {
'container-type': value,
'container-name': modifier,
};
},
},
{
values: {
DEFAULT: 'inline-size',
normal: 'normal',
},
modifiers: 'any',
}
);
matchVariant(
'@',
(value = '', { modifier }) => {
const parsed = parseValue(value);
return parsed !== null ? `@container ${modifier ?? ''} (min-width: ${value})` : [];
},
{
values,
sort,
}
);
matchVariant(
'@max',
(value = '', { modifier }) => {
const parsed = parseValue(value);
return parsed !== null ? `@container ${modifier ?? ''} (width < ${value})` : [];
},
{
values,
sort,
}
);
},
{
theme: {
containers: {
xs: '20rem',
sm: '24rem',
md: '28rem',
lg: '32rem',
xl: '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
'7xl': '80rem',
},
},
}
);