mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(compiler): configurable @property handling for --tw-* slots
Tailwind registers internal slots (`--tw-content`, `--tw-shadow`, …) via
`@property` rules that supply their defaults. `decompose` dropped those
rules, so utilities that reference a slot without setting it locally —
notably `after:*`/`before:*` emitting `content: var(--tw-content)` — left
a dangling reference that resolves to nothing and suppresses the
pseudo-element.
`decompose` now captures the `@property` registrations, and `emitCss`
gains a `properties` option to handle them, configurably:
- `mode: 'emit'` — emit `@property` rules for referenced slots,
preserving Tailwind's typed defaults.
- `mode: 'inline'` — substitute each slot's `initial-value` into the
values that reference it (a superset of `inlineVars` for the matched
slots), so the output is fully self-contained.
- `resolve(name, captured)` hook — override or supply a slot's config
(initial-value / syntax / inherits), or register one Tailwind didn't.
- `match` — restrict handling to matching names (default `/^--tw-/`).
`tailwindPlugin` forwards `properties` through its `onCss` path. Adds 9
tests (capture, emit, inline, resolve override, match, back-compat,
plugin e2e). Compiler suite: 178 passing.
This commit is contained in:
@@ -83,8 +83,10 @@ Unrelated (drop from our set — belongs to main or a separate change):
|
||||
- ✅ Marker-class drop (`group`/`peer`) — preserved on the element.
|
||||
- ✅ Class-name collisions — `DiagnosticError` thrown on same-name/different-styles.
|
||||
- ✅ Undefined theme vars — `emitCss` emits a resolved theme block via `resolveThemeVar`.
|
||||
- ⬜ Follow-up: `@property`-registered `--tw-*` slots (e.g. `--tw-content`) still
|
||||
resolve to `undefined`; emit their `@property` initial values or inline them.
|
||||
- ✅ `@property`-registered `--tw-*` slots (e.g. `--tw-content`) — `decompose`
|
||||
now captures the `@property` rules; `emitCss`'s `properties` option handles
|
||||
them in `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values)
|
||||
mode, with a `resolve` hook + `match` filter for per-property config.
|
||||
|
||||
## Deferred porting backlog (NOT this pass)
|
||||
- Constrained-JSX for airplay/live/menu/playback-rate components + manifests.
|
||||
|
||||
@@ -28,10 +28,28 @@ export interface Variant {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `@property` registration Tailwind appends alongside a utility (e.g.
|
||||
* `@property --tw-content { syntax: "*"; inherits: false; initial-value: "" }`).
|
||||
* Values are kept verbatim so they can be re-emitted or inlined as authored.
|
||||
*/
|
||||
export interface PropertyRule {
|
||||
name: string;
|
||||
syntax?: string;
|
||||
inherits?: boolean;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export interface UtilityCss {
|
||||
utility: string;
|
||||
declarations: readonly Declaration[];
|
||||
variants: readonly Variant[];
|
||||
/**
|
||||
* `@property` registrations Tailwind emitted for this utility. These supply
|
||||
* the typed defaults for `--tw-*` slots referenced (but not set) by the
|
||||
* declarations — see `emitCss`'s `properties` option.
|
||||
*/
|
||||
properties?: readonly PropertyRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +81,41 @@ export function decompose(utility: string, design: DesignSystem): UtilityCss | n
|
||||
const declarations: Declaration[] = [];
|
||||
walkNested(body, variants, declarations);
|
||||
|
||||
return { utility, declarations, variants };
|
||||
// Tailwind appends `@property --tw-* { ... }` registrations after the utility
|
||||
// rule. They live at the top level of the compiled output (siblings of the
|
||||
// utility class), so we scan the whole string rather than the rule body.
|
||||
const properties = parseProperties(trimmed);
|
||||
|
||||
return properties.length > 0 ? { utility, declarations, variants, properties } : { utility, declarations, variants };
|
||||
}
|
||||
|
||||
/** Parse every `@property --name { ... }` block from a compiled utility. */
|
||||
function parseProperties(css: string): PropertyRule[] {
|
||||
const out: PropertyRule[] = [];
|
||||
const re = /@property\s+(--[A-Za-z0-9_-]+)\s*\{/g;
|
||||
let match = re.exec(css);
|
||||
while (match !== null) {
|
||||
const name = match[1]!;
|
||||
const openIdx = match.index + match[0].length - 1;
|
||||
const block = readBalancedBlock(css, openIdx);
|
||||
if (!block) break;
|
||||
|
||||
const rule: PropertyRule = { name };
|
||||
for (const decl of block.inner.split(';')) {
|
||||
const colon = decl.indexOf(':');
|
||||
if (colon === -1) continue;
|
||||
const prop = decl.slice(0, colon).trim();
|
||||
const value = decl.slice(colon + 1).trim();
|
||||
if (!value) continue;
|
||||
if (prop === 'syntax') rule.syntax = value;
|
||||
else if (prop === 'inherits') rule.inherits = value === 'true';
|
||||
else if (prop === 'initial-value') rule.initialValue = value;
|
||||
}
|
||||
out.push(rule);
|
||||
re.lastIndex = block.end + 1;
|
||||
match = re.exec(css);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,46 @@ export interface EmitCssOptions {
|
||||
* skin selector (e.g. `[data-skin="default-video"]`) to scope the variables.
|
||||
*/
|
||||
themeSelector?: string;
|
||||
/**
|
||||
* How to handle Tailwind's `@property`-registered slots (e.g. `--tw-content`,
|
||||
* `--tw-shadow`) that the compiled rules reference but never set locally.
|
||||
* Without this they resolve to nothing and break — e.g. `content:
|
||||
* var(--tw-content)` suppresses the `::after`/`::before` box.
|
||||
*
|
||||
* Omit to leave them alone (current behavior). See `RegisteredPropertiesOptions`.
|
||||
*/
|
||||
properties?: RegisteredPropertiesOptions;
|
||||
}
|
||||
|
||||
/** A `@property` definition (sans name), as captured or overridden. */
|
||||
export interface PropertyDef {
|
||||
/** `@property` syntax descriptor, e.g. `"*"`. Defaults to `"*"` when emitted. */
|
||||
syntax?: string;
|
||||
/** Whether the property inherits. Defaults to `false` when emitted. */
|
||||
inherits?: boolean;
|
||||
/** The registered default, e.g. `""` for `--tw-content`. */
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export interface RegisteredPropertiesOptions {
|
||||
/**
|
||||
* - `'emit'` — emit `@property` rules for referenced slots, preserving
|
||||
* Tailwind's typed defaults (relies on browser `@property` support).
|
||||
* - `'inline'` — substitute each slot's `initial-value` into the values that
|
||||
* reference it (and drop any `--tw-*` setter declarations), so the output
|
||||
* is fully self-contained. This is a superset of `inlineVars` for the
|
||||
* matched slots.
|
||||
*/
|
||||
mode: 'emit' | 'inline';
|
||||
/** Which property names to handle. Defaults to `inlineVars`'s matcher, else `/^--tw-/`. */
|
||||
match?: RegExp;
|
||||
/**
|
||||
* Override or supply a property's definition. Receives the name and the
|
||||
* definition captured from Tailwind's output (if any); return a new
|
||||
* definition (merged in), or `undefined` to keep the captured one. Lets you
|
||||
* fix an initial-value or register a slot Tailwind didn't.
|
||||
*/
|
||||
resolve?: (name: string, captured: PropertyDef | undefined) => PropertyDef | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,11 +148,28 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
|
||||
const hoist = opts.hoist === false ? undefined : opts.hoist;
|
||||
const inlineVars = normalizeInlineMatcher(opts.inlineVars);
|
||||
|
||||
// Registered `@property` (--tw-*) handling. In 'inline' mode the slots'
|
||||
// initial-values seed the inline pass as fallbacks (and the matcher widens to
|
||||
// cover them, even when `inlineVars` was off). In 'emit' mode they're left in
|
||||
// place to be emitted as `@property` rules.
|
||||
const propMode = opts.properties?.mode;
|
||||
const captured = collectPropertyDefs(opts.rules);
|
||||
const resolveDef = (name: string): PropertyDef | undefined => {
|
||||
const cap = captured.get(name);
|
||||
return opts.properties?.resolve?.(name, cap) ?? cap;
|
||||
};
|
||||
const propMatch = opts.properties?.match ?? inlineVars ?? /^--tw-/;
|
||||
const inlineMatch = propMode === 'inline' ? propMatch : inlineVars;
|
||||
const fallbacks = propMode === 'inline' ? buildFallbackSetters(captured, propMatch, resolveDef) : undefined;
|
||||
const emitProperties = (css: string): string =>
|
||||
propMode === 'emit' ? buildPropertyBlocks(css, propMatch, resolveDef) : '';
|
||||
|
||||
if (mode === 'merged') {
|
||||
const base = await bundleBaseCss(opts.baseCss ?? [], configDir);
|
||||
const body = composeRules(opts.rules, hoist, inlineVars);
|
||||
const body = composeRules(opts.rules, hoist, inlineMatch, fallbacks);
|
||||
const theme = buildThemeBlock(body, opts.resolveThemeVar, opts.themeSelector);
|
||||
return { kind: 'merged', css: joinSections(base, theme, body) };
|
||||
const properties = emitProperties(body);
|
||||
return { kind: 'merged', css: joinSections(base, properties, theme, body) };
|
||||
}
|
||||
|
||||
// Split mode: group rules by `bag`.
|
||||
@@ -130,14 +187,17 @@ export async function emitCss(opts: EmitCssOptions): Promise<EmittedCss> {
|
||||
const sortedBags = [...byBag.keys()].sort();
|
||||
for (const bagName of sortedBags) {
|
||||
const bagRules = byBag.get(bagName)!;
|
||||
bags.set(bagName, composeRules(bagRules, undefined, inlineVars));
|
||||
bags.set(bagName, composeRules(bagRules, undefined, inlineMatch, fallbacks));
|
||||
importLines.push(`@import "./${bagName || 'index'}.css";`);
|
||||
}
|
||||
|
||||
const base = await bundleBaseCss(opts.baseCss ?? [], configDir);
|
||||
// Theme variables go in `index` (it loads first), resolved against every bag.
|
||||
const theme = buildThemeBlock([...bags.values()].join('\n'), opts.resolveThemeVar, opts.themeSelector);
|
||||
const index = joinSections(base, theme, importLines.join('\n'));
|
||||
// Theme variables and @property rules go in `index` (it loads first),
|
||||
// resolved against every bag.
|
||||
const allBagsCss = [...bags.values()].join('\n');
|
||||
const theme = buildThemeBlock(allBagsCss, opts.resolveThemeVar, opts.themeSelector);
|
||||
const properties = emitProperties(allBagsCss);
|
||||
const index = joinSections(base, properties, theme, importLines.join('\n'));
|
||||
return { kind: 'split', index, bags };
|
||||
}
|
||||
|
||||
@@ -203,6 +263,60 @@ function collectDefinedVars(css: string): Set<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Registered `@property` slots
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Aggregate the `@property` defs captured across every rule (first wins). */
|
||||
function collectPropertyDefs(rules: readonly CompiledRule[]): Map<string, PropertyDef> {
|
||||
const out = new Map<string, PropertyDef>();
|
||||
for (const rule of rules) {
|
||||
for (const p of rule.utility.properties ?? []) {
|
||||
if (out.has(p.name)) continue;
|
||||
const { name, ...def } = p;
|
||||
out.set(name, def);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build the `name → initial-value` fallback map for `mode: 'inline'`. */
|
||||
function buildFallbackSetters(
|
||||
captured: Map<string, PropertyDef>,
|
||||
match: RegExp,
|
||||
resolveDef: (name: string) => PropertyDef | undefined
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const name of captured.keys()) {
|
||||
if (!match.test(name)) continue;
|
||||
const def = resolveDef(name);
|
||||
if (def?.initialValue !== undefined) out.set(name, def.initialValue);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `@property` rules for every matching slot the CSS references but
|
||||
* doesn't itself declare. Descriptors default to `syntax: "*"` / `inherits:
|
||||
* false` when a resolved def omits them.
|
||||
*/
|
||||
function buildPropertyBlocks(
|
||||
css: string,
|
||||
match: RegExp,
|
||||
resolveDef: (name: string) => PropertyDef | undefined
|
||||
): string {
|
||||
const referenced = [...collectReferencedVars(css)].filter((name) => match.test(name)).sort();
|
||||
const blocks: string[] = [];
|
||||
for (const name of referenced) {
|
||||
const def = resolveDef(name);
|
||||
if (!def) continue;
|
||||
const lines = [` syntax: ${def.syntax ?? '"*"'};`, ` inherits: ${def.inherits ?? false};`];
|
||||
if (def.initialValue !== undefined) lines.push(` initial-value: ${def.initialValue};`);
|
||||
blocks.push(`@property ${name} {\n${lines.join('\n')}\n}`);
|
||||
}
|
||||
return blocks.join('\n\n');
|
||||
}
|
||||
|
||||
/** Internal: read each `baseCss` file via Lightning CSS, return concatenated string. */
|
||||
async function bundleBaseCss(paths: readonly string[], configDir: string): Promise<string> {
|
||||
if (paths.length === 0) return '';
|
||||
@@ -244,7 +358,8 @@ interface EmitUnit {
|
||||
function composeRules(
|
||||
rules: readonly CompiledRule[],
|
||||
hoist: HoistOptions | undefined,
|
||||
inlineVars: RegExp | undefined
|
||||
inlineVars: RegExp | undefined,
|
||||
fallbackSetters?: Map<string, string>
|
||||
): string {
|
||||
// Step 1: turn each CompiledRule into one EmitUnit.
|
||||
const units: EmitUnit[] = [];
|
||||
@@ -284,7 +399,7 @@ function composeRules(
|
||||
// consumers, then drop the matching declarations. Pulls setters from the
|
||||
// consumer's own rule plus the hoist root (when set) so consumers in
|
||||
// separate rules from the original setter still resolve.
|
||||
if (inlineVars) applyInline(merged, inlineVars, hoist?.rootSelector);
|
||||
if (inlineVars) applyInline(merged, inlineVars, hoist?.rootSelector, fallbackSetters);
|
||||
|
||||
// Step 3: collapse units that share the same (atRulePath, declarations) into
|
||||
// a comma-separated selector list. Sort the declaration set to make the
|
||||
@@ -446,7 +561,8 @@ function normalizeInlineMatcher(opt: true | RegExp | undefined): RegExp | undefi
|
||||
function applyInline(
|
||||
merged: Map<string, EmitUnit & { declarations: Declaration[]; declSet: Set<string> }>,
|
||||
match: RegExp,
|
||||
hoistRootSelector: string | undefined
|
||||
hoistRootSelector: string | undefined,
|
||||
fallbackSetters?: Map<string, string>
|
||||
): void {
|
||||
// Pull root-scope setters once. They serve as fallback when a consumer
|
||||
// rule doesn't declare the property locally.
|
||||
@@ -473,8 +589,11 @@ function applyInline(
|
||||
}
|
||||
}
|
||||
|
||||
// Effective setters: rule-local first, hoist root as fallback.
|
||||
const setters = new Map<string, string>(rootSetters);
|
||||
// Effective setters, narrowest last: registered `@property` initial-values
|
||||
// (lowest), then hoist root, then rule-local. Initial-values resolve
|
||||
// references to slots no rule ever sets (e.g. `content: var(--tw-content)`).
|
||||
const setters = new Map<string, string>(fallbackSetters);
|
||||
for (const [name, value] of rootSetters) setters.set(name, value);
|
||||
for (const [name, value] of localSetters) setters.set(name, value);
|
||||
resolveSettersInPlace(setters, match);
|
||||
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
export { type Declaration, decompose, type UtilityCss, type Variant, type VariantKind } from './decompose';
|
||||
export {
|
||||
type Declaration,
|
||||
decompose,
|
||||
type PropertyRule,
|
||||
type UtilityCss,
|
||||
type Variant,
|
||||
type VariantKind,
|
||||
} from './decompose';
|
||||
export { type DesignSystem, loadDesignSystem } from './design-system';
|
||||
export { type CompiledRule, type EmitCssOptions, type EmittedCss, emitCss, type HoistOptions } from './emit';
|
||||
export {
|
||||
type CompiledRule,
|
||||
type EmitCssOptions,
|
||||
type EmittedCss,
|
||||
emitCss,
|
||||
type HoistOptions,
|
||||
type PropertyDef,
|
||||
type RegisteredPropertiesOptions,
|
||||
} from './emit';
|
||||
export { clearTokenModuleCache, EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
|
||||
export {
|
||||
type DeriveClassNameOptions,
|
||||
|
||||
@@ -5,7 +5,13 @@ import { tagName } from '../matchers';
|
||||
import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles';
|
||||
import { decompose, type UtilityCss } from './decompose';
|
||||
import type { DesignSystem } from './design-system';
|
||||
import { type CompiledRule, type EmittedCss, emitCss, type HoistOptions } from './emit';
|
||||
import {
|
||||
type CompiledRule,
|
||||
type EmittedCss,
|
||||
emitCss,
|
||||
type HoistOptions,
|
||||
type RegisteredPropertiesOptions,
|
||||
} from './emit';
|
||||
import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
|
||||
import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming';
|
||||
|
||||
@@ -83,6 +89,17 @@ export interface TailwindPluginOptions {
|
||||
* driving `emitCss` themselves should pass the same value through.
|
||||
*/
|
||||
inlineVars?: true | RegExp;
|
||||
/**
|
||||
* Handle Tailwind's `@property`-registered slots (`--tw-content`, etc.) that
|
||||
* are referenced but never set, so the output isn't broken (e.g.
|
||||
* `content: var(--tw-content)`). See `RegisteredPropertiesOptions` — choose
|
||||
* `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in),
|
||||
* with an optional `resolve` hook for the per-property config.
|
||||
*
|
||||
* Forwarded to the internal `emitCss` call when `onCss` is set; consumers
|
||||
* driving `emitCss` themselves should pass the same value through.
|
||||
*/
|
||||
properties?: RegisteredPropertiesOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +144,7 @@ function inlinedPlugin(options: TailwindPluginOptions): ts.TransformerFactory<ts
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory<ts.SourceFile> {
|
||||
const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars } = options;
|
||||
const { design, transformName, overrides, bagFor, onRules, onCss, emit, hoistVars, inlineVars, properties } = options;
|
||||
|
||||
const env = buildTokenEnv(options.sourcePath);
|
||||
|
||||
@@ -241,6 +258,7 @@ function vanillaCssPlugin(options: TailwindPluginOptions): ts.TransformerFactory
|
||||
...(emit ?? {}),
|
||||
...(hoistVars !== undefined ? { hoist: hoistVars } : {}),
|
||||
...(inlineVars !== undefined ? { inlineVars } : {}),
|
||||
...(properties ? { properties } : {}),
|
||||
resolveThemeVar: (name) => design.resolveThemeVar(name),
|
||||
...(themeSelector ? { themeSelector } : {}),
|
||||
})
|
||||
|
||||
@@ -93,6 +93,25 @@ describe('decompose — variants', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompose — @property registrations', () => {
|
||||
it('captures the @property rule Tailwind appends for a slot', () => {
|
||||
const r = decompose('before:content-["x"]', design);
|
||||
expect(r).not.toBeNull();
|
||||
const content = r!.properties?.find((p) => p.name === '--tw-content');
|
||||
expect(content).toEqual({
|
||||
name: '--tw-content',
|
||||
syntax: '"*"',
|
||||
inherits: false,
|
||||
initialValue: '""',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits `properties` when a utility registers none', () => {
|
||||
const r = decompose('flex', design);
|
||||
expect(r!.properties).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompose — caching', () => {
|
||||
it('returns the same compiled CSS on repeat lookups (DesignSystem cache)', () => {
|
||||
const a = design.compileUtility('flex');
|
||||
|
||||
@@ -532,3 +532,78 @@ describe('emitCss — theme variables', () => {
|
||||
expect(out.css).not.toMatch(/:root/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitCss — registered @property slots', () => {
|
||||
// The `after:absolute` pattern: an `::after` rule that references
|
||||
// `--tw-content` but never sets it, relying on Tailwind's @property default.
|
||||
const contentRule = (): CompiledRule => ({
|
||||
className: 'card',
|
||||
utility: {
|
||||
utility: 'after:absolute',
|
||||
declarations: [
|
||||
{ property: 'content', value: 'var(--tw-content)' },
|
||||
{ property: 'position', value: 'absolute' },
|
||||
],
|
||||
variants: [{ kind: 'pseudo', selector: '::after', raw: '::after' }],
|
||||
properties: [{ name: '--tw-content', syntax: '"*"', inherits: false, initialValue: '""' }],
|
||||
},
|
||||
});
|
||||
|
||||
it("mode 'inline' substitutes the initial-value and drops the dangling reference", async () => {
|
||||
const out = await emitCss({ rules: [contentRule()], properties: { mode: 'inline' } });
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(collapse('content: "";'));
|
||||
expect(out.css).not.toMatch(/var\(--tw-content\)/);
|
||||
});
|
||||
|
||||
it("mode 'emit' emits an @property rule and keeps the reference", async () => {
|
||||
const out = await emitCss({ rules: [contentRule()], properties: { mode: 'emit' } });
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(
|
||||
collapse('@property --tw-content {\n syntax: "*";\n inherits: false;\n initial-value: "";\n}')
|
||||
);
|
||||
expect(out.css).toMatch(/var\(--tw-content\)/);
|
||||
});
|
||||
|
||||
it('lets the resolve hook override the initial-value (inline)', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [contentRule()],
|
||||
properties: {
|
||||
mode: 'inline',
|
||||
resolve: (name, captured) => (name === '--tw-content' ? { ...captured, initialValue: '"!"' } : undefined),
|
||||
},
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(collapse('content: "!";'));
|
||||
});
|
||||
|
||||
it('lets the resolve hook override descriptors (emit)', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [contentRule()],
|
||||
properties: {
|
||||
mode: 'emit',
|
||||
resolve: () => ({ syntax: '"<length>"', inherits: true, initialValue: '0px' }),
|
||||
},
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(collapse(out.css)).toContain(
|
||||
collapse('@property --tw-content {\n syntax: "<length>";\n inherits: true;\n initial-value: 0px;\n}')
|
||||
);
|
||||
});
|
||||
|
||||
it('honours the match filter (leaves non-matching slots alone)', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [contentRule()],
|
||||
properties: { mode: 'inline', match: /^--brand-/ },
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(out.css).toMatch(/var\(--tw-content\)/);
|
||||
});
|
||||
|
||||
it('leaves slots untouched when no properties option is given (back-compat)', async () => {
|
||||
const out = await emitCss({ rules: [contentRule()] });
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(out.css).toMatch(/var\(--tw-content\)/);
|
||||
expect(out.css).not.toMatch(/@property/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,4 +464,27 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
|
||||
const css = await cssPromise;
|
||||
expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/);
|
||||
});
|
||||
|
||||
it('forwards the `properties` option (inline) so --tw-content resolves', async () => {
|
||||
// `after:absolute` emits `content: var(--tw-content)` with no setter.
|
||||
const source = `function App(){ return <Foo className="after:absolute"/>; }`;
|
||||
const cssPromise = new Promise<string>((resolve) => {
|
||||
compile(source, {
|
||||
target: 'react',
|
||||
plugins: [
|
||||
tailwindPlugin({
|
||||
design,
|
||||
target: 'vanilla-css',
|
||||
properties: { mode: 'inline' },
|
||||
onCss: (out) => {
|
||||
if (out.kind === 'merged') resolve(out.css);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const css = await cssPromise;
|
||||
expect(css).not.toMatch(/var\(--tw-content\)/);
|
||||
expect(collapse(css)).toContain(collapse('content: "";'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user