mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Improve incremental builds (#13168)
* ensure we don't crash on deleted files * change return type of `compile` to include a `rebuild()` function This will allow us in the future to perform incremental rebuilds after the initial rebuild. This is purely the API change so that we can prepare all the call sites to use this new API. * set `@tailwind utilities` nodes Instead of replacing the node that represents the `@tailwind utilities` with the generated AST nodes from the rawCandidates, we will set the nodes of the `@tailwind utilities` rule to the AST nodes instead. This way we dont' have to remove and replace the `@tailwind utilities` rule with `n` new nodes. This will later allow us to track the `@tailwindcss utilities` rule itself and update its `nodes` for incremental rebuilds. This also requires a small change to the printer where we now need to print the children of the `@tailwind utilities` rule. Note: we keep the same `depth` as-if the `@tailwindcss utilities` rule was not there. Otherwise additional indentation would be present. * move sorting to the `ast.sort()` call This will allow us to keep sorting AST nodes in a single spot. * move parser functions to the `DesignSystem` This allows us to put all the parsers in the `DesignSystem`, this allows us to scope the parsers to the current design system (the current theme, current utility values and variants). The implementation of these parsers are also using a `DefaultMap` implementation. This allows us to make use of caching and only parse a candidate, parse a variant or compile AST nodes for a given raw candidate once if we've already done this work in the past. Again, this is scoped to the `DesignSystem` itself. This means that if the corresponding theme changes, then we will create a new `DesignSystem` entirely and the caches will be garbage collected. This is important because a candidate like `bg-primary` can be invalid in `DesignSystem` A, but can be valid in `DesignSystem` B and vice versa. * ensure we sort variants alphabetically by root For incremental rebuilds we don't know all the used variants upfront, which means that we can't sort them upfront either (what we used to do). This code now allows us to sort the variants deterministically when sorting the variants themselves instead of relying on the fact that they used to be sorted before. The sort itself could change slightly compared to the previous implementation (especially when you used stacked variants in your candidates), but it will still be deterministic. * replace `localeCompare` comparisons Use cheaper comparisons than `localeCompare` when comparing 2 strings. We currently don't care if it is 100% correctly sorted, but we just want consistent sorting. This is currently faster compared to `localeCompare`. Another benefit is that `localeCompare` could result in non-deterministic results if the CSS was generated on 2 separate computers where the `locale` is different. We could solve that by adding a dedicated locale, but it would still be slower compared to this. * track invalid candidates When an incoming raw candidates doesn't produce any output, then we can mark it as an invalid candidate. This will allow us to reduce the amount of candidates to handle in incremental rebuilds. * add initial incremental rebuild implementation This includes a number of steps: 1. Track the `@tailwind utilities` rule, so that we can adjust its nodes later without re-parsing the full incoming CSS. 2. Add the new incoming raw candidates to the existing set of candidates. 3. Parse the merged set to `compileCandidates` (this can accept any `Iterable<string>`, which means `string[]`, `Set<string>`, ...) 4. Get the new AST nodes, update the `@tailwind utilities` rule's nodes and re-print the AST to CSS. * improvement 1: ignore known invalid candidates This will reduce the amount of candidates to handle. They would eventually be skipped anyway, but now we don't even have to re-parse (and hit a cache) at all. * improvement 2: skip work, when generated AST is the same Currently incremental rebuilds are additive, which means that we are not keeping track if we should remove CSS again in development. We can exploit this information, because now we can quickly check the amoutn of generated AST nodes. - If they are the same then nothing new is generated — this means that we can re-use the previous compiled CSS. We don't even have to re-print the AST because we already did do that work in the past. - If there are more AST nodes, something new is generated — this means that we should update the `@tailwind utilities` rule and re-print the CSS. We can store the result for future incremental rebuilds. * improvement 3: skip work if no new candidates are detected - We already know a set of candidates from previous runs. - We also already know a set of candidates that are invalid and don't produce anything. This means that an incremental rebuild could give us a new set of candidates that either already exist or are invalid. If nothing changes, then we can re-use the compiled CSS. This actually happens more often than you think, and the bigger your project is the better this optimization will be. For example: ``` // Imagine file A exists: <div class="flex items-center justify-center"></div> <button class="text-red-500">Delete</button> ``` ``` // Now you add a second file B: <div class="text-red-500 flex"></div> ``` You just created a brand new file with a bunch of HTML elements and classes, yet all of the candidates in file B already exist in file A, so nothing changes to the actual generated CSS. Now imagine the other hundreds of files that already contain hundreds of classes. The beauty of this optimization is two-fold: - On small projects, compiling is very fast even without this check. This means it is performant. - On bigger projects, we will be able to re-use existing candidates. This means it stays performant. * remove `getAstNodeSize` We can move this up the tree and move it to the `rebuild` function instead. * remove invalid candidate tracking from `DesignSystem` This isn't used anywhere but only in the `rebuild` of the compile step. This allows us to remove it entirely from core logic, and move it up the chain where it is needed. * replace `throwOnInvalidCandidate` with `onInvalidCanidate` This was only needed for working with `@apply`, now this logic _only_ exists in the code path where we are handling `@apply`. * update `compile` API signature * update callsite of `compile()` function * fix typo
This commit is contained in:
parent
78d1c50f1c
commit
d230f2e13b
@ -433,9 +433,9 @@ fn read_all_files_sync(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
|
||||
|
||||
changed_content
|
||||
.into_iter()
|
||||
.map(|c| match (c.file, c.content) {
|
||||
(Some(file), None) => std::fs::read(file).unwrap(),
|
||||
(None, Some(content)) => content.into_bytes(),
|
||||
.filter_map(|c| match (c.file, c.content) {
|
||||
(Some(file), None) => std::fs::read(file).ok(),
|
||||
(None, Some(content)) => Some(content.into_bytes()),
|
||||
_ => Default::default(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
@ -100,7 +100,8 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
||||
)
|
||||
|
||||
// Compile the input
|
||||
let result = compile(input, candidates)
|
||||
let { build } = compile(input)
|
||||
let result = build(candidates)
|
||||
|
||||
// Optimize the output
|
||||
if (args['--minify'] || args['--optimize']) {
|
||||
@ -193,7 +194,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
||||
}
|
||||
|
||||
// Compile the input
|
||||
let result = compile(input, candidates)
|
||||
result = compile(input).build(candidates)
|
||||
|
||||
// Optimize the output
|
||||
if (args['--minify'] || args['--optimize']) {
|
||||
|
||||
@ -53,7 +53,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
|
||||
|
||||
// No `@tailwind` means we don't have to look for candidates
|
||||
if (!hasTailwind) {
|
||||
replaceCss(compile(root.toString(), []))
|
||||
replaceCss(compile(root.toString()).build([]))
|
||||
return
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
replaceCss(compile(root.toString(), candidates))
|
||||
replaceCss(compile(root.toString()).build(candidates))
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ export default function tailwindcss(): Plugin[] {
|
||||
}
|
||||
|
||||
function generateCss(css: string) {
|
||||
return compile(css, Array.from(candidates))
|
||||
return compile(css).build(Array.from(candidates))
|
||||
}
|
||||
|
||||
function generateOptimizedCss(css: string) {
|
||||
|
||||
@ -105,6 +105,13 @@ export function toCss(ast: AstNode[]) {
|
||||
return css
|
||||
}
|
||||
|
||||
if (node.selector === '@tailwind utilities') {
|
||||
for (let child of node.nodes) {
|
||||
css += stringify(child, depth)
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
// Print at-rules without nodes with a `;` instead of an empty block.
|
||||
//
|
||||
// E.g.:
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { scanDir } from '@tailwindcss/oxide'
|
||||
import { bench } from 'vitest'
|
||||
import { parseCandidate, parseVariant } from './candidate'
|
||||
import { parseCandidate } from './candidate'
|
||||
import { buildDesignSystem } from './design-system'
|
||||
import { Theme } from './theme'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
|
||||
// FOLDER=path/to/folder vitest bench
|
||||
const root = process.env.FOLDER || process.cwd()
|
||||
@ -15,10 +14,6 @@ const designSystem = buildDesignSystem(new Theme())
|
||||
|
||||
bench('parseCandidate', () => {
|
||||
for (let candidate of result.candidates) {
|
||||
parseCandidate(
|
||||
candidate,
|
||||
designSystem.utilities,
|
||||
new DefaultMap((variant, map) => parseVariant(variant, designSystem.variants, map)),
|
||||
)
|
||||
parseCandidate(candidate, designSystem)
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseCandidate, parseVariant } from './candidate'
|
||||
import { buildDesignSystem } from './design-system'
|
||||
import { Theme } from './theme'
|
||||
import { Utilities } from './utilities'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { Variants } from './variants'
|
||||
|
||||
function run(
|
||||
@ -11,11 +11,12 @@ function run(
|
||||
utilities ??= new Utilities()
|
||||
variants ??= new Variants()
|
||||
|
||||
let parsedVariants = new DefaultMap((variant, map) => {
|
||||
return parseVariant(variant, variants!, map)
|
||||
})
|
||||
let designSystem = buildDesignSystem(new Theme())
|
||||
|
||||
return parseCandidate(candidate, utilities, parsedVariants)
|
||||
designSystem.utilities = utilities
|
||||
designSystem.variants = variants
|
||||
|
||||
return designSystem.parseCandidate(candidate)
|
||||
}
|
||||
|
||||
it('should skip unknown utilities', () => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { DesignSystem } from './design-system'
|
||||
import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
|
||||
import { segment } from './utils/segment'
|
||||
|
||||
@ -206,14 +207,7 @@ export type Candidate =
|
||||
important: boolean
|
||||
}
|
||||
|
||||
export function parseCandidate(
|
||||
input: string,
|
||||
utilities: {
|
||||
has: (value: string) => boolean
|
||||
kind: (root: string) => Omit<Candidate['kind'], 'arbitrary'>
|
||||
},
|
||||
parsedVariants: { get: (value: string) => Variant | null },
|
||||
): Candidate | null {
|
||||
export function parseCandidate(input: string, designSystem: DesignSystem): Candidate | null {
|
||||
// hover:focus:underline
|
||||
// ^^^^^ ^^^^^^ -> Variants
|
||||
// ^^^^^^^^^ -> Base
|
||||
@ -228,7 +222,7 @@ export function parseCandidate(
|
||||
let parsedCandidateVariants: Variant[] = []
|
||||
|
||||
for (let variant of rawVariants) {
|
||||
let parsedVariant = parsedVariants.get(variant)
|
||||
let parsedVariant = designSystem.parseVariant(variant)
|
||||
if (parsedVariant === null) return null
|
||||
|
||||
// Variants are applied left-to-right meaning that any representing pseudo-
|
||||
@ -320,7 +314,7 @@ export function parseCandidate(
|
||||
base = base.slice(1)
|
||||
}
|
||||
|
||||
let [root, value] = findRoot(base, utilities)
|
||||
let [root, value] = findRoot(base, designSystem.utilities)
|
||||
|
||||
let modifierSegment: string | null = null
|
||||
|
||||
@ -335,13 +329,13 @@ export function parseCandidate(
|
||||
modifierSegment = rootModifierSegment
|
||||
|
||||
// Try to find the root and value, without the modifier present
|
||||
;[root, value] = findRoot(rootWithoutModifier, utilities)
|
||||
;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities)
|
||||
}
|
||||
|
||||
// If there's no root, the candidate isn't a valid class and can be discarded.
|
||||
if (root === null) return null
|
||||
|
||||
let kind = utilities.kind(root)
|
||||
let kind = designSystem.utilities.kind(root)
|
||||
|
||||
if (kind === 'static') {
|
||||
if (value !== null) return null
|
||||
@ -475,15 +469,7 @@ function parseModifier(modifier: string): CandidateModifier {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVariant(
|
||||
variant: string,
|
||||
variants: {
|
||||
has: (value: string) => boolean
|
||||
kind: (root: string) => Omit<Variant['kind'], 'arbitrary'>
|
||||
compounds: (root: string) => boolean
|
||||
},
|
||||
parsedVariants: { get: (value: string) => Variant | null },
|
||||
): Variant | null {
|
||||
export function parseVariant(variant: string, designSystem: DesignSystem): Variant | null {
|
||||
// Arbitrary variants
|
||||
if (variant[0] === '[' && variant[variant.length - 1] === ']') {
|
||||
/**
|
||||
@ -535,20 +521,20 @@ export function parseVariant(
|
||||
// - `group-hover/foo/bar`
|
||||
if (additionalModifier) return null
|
||||
|
||||
let [root, value] = findRoot(variantWithoutModifier, variants)
|
||||
let [root, value] = findRoot(variantWithoutModifier, designSystem.variants)
|
||||
|
||||
// Variant is invalid, therefore the candidate is invalid and we can skip
|
||||
// continue parsing it.
|
||||
if (root === null) return null
|
||||
|
||||
switch (variants.kind(root)) {
|
||||
switch (designSystem.variants.kind(root)) {
|
||||
case 'static': {
|
||||
if (value !== null) return null
|
||||
|
||||
return {
|
||||
kind: 'static',
|
||||
root,
|
||||
compounds: variants.compounds(root),
|
||||
compounds: designSystem.variants.compounds(root),
|
||||
}
|
||||
}
|
||||
|
||||
@ -564,7 +550,7 @@ export function parseVariant(
|
||||
kind: 'arbitrary',
|
||||
value: decodeArbitraryValue(value.slice(1, -1)),
|
||||
},
|
||||
compounds: variants.compounds(root),
|
||||
compounds: designSystem.variants.compounds(root),
|
||||
}
|
||||
}
|
||||
|
||||
@ -573,14 +559,14 @@ export function parseVariant(
|
||||
root,
|
||||
modifier: modifier === null ? null : parseModifier(modifier),
|
||||
value: { kind: 'named', value },
|
||||
compounds: variants.compounds(root),
|
||||
compounds: designSystem.variants.compounds(root),
|
||||
}
|
||||
}
|
||||
|
||||
case 'compound': {
|
||||
if (value === null) return null
|
||||
|
||||
let subVariant = parsedVariants.get(value)
|
||||
let subVariant = designSystem.parseVariant(value)
|
||||
if (subVariant === null) return null
|
||||
if (subVariant.compounds === false) return null
|
||||
|
||||
@ -589,7 +575,7 @@ export function parseVariant(
|
||||
root,
|
||||
modifier: modifier === null ? null : { kind: 'named', value: modifier },
|
||||
variant: subVariant,
|
||||
compounds: variants.compounds(root),
|
||||
compounds: designSystem.variants.compounds(root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,120 +1,52 @@
|
||||
import { rule, type AstNode, type Rule } from './ast'
|
||||
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate'
|
||||
import { type Candidate, type Variant } from './candidate'
|
||||
import { type DesignSystem } from './design-system'
|
||||
import GLOBAL_PROPERTY_ORDER from './property-order'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { escape } from './utils/escape'
|
||||
import type { Variants } from './variants'
|
||||
|
||||
export function compileCandidates(
|
||||
rawCandidates: string[],
|
||||
rawCandidates: Iterable<string>,
|
||||
designSystem: DesignSystem,
|
||||
{ throwOnInvalidCandidate = false } = {},
|
||||
{ onInvalidCandidate }: { onInvalidCandidate?: (candidate: string) => void } = {},
|
||||
) {
|
||||
// Ensure the candidates are sorted alphabetically
|
||||
rawCandidates.sort()
|
||||
|
||||
let nodeSorting = new Map<
|
||||
AstNode,
|
||||
{ properties: number[]; variants: bigint; candidate: string }
|
||||
>()
|
||||
let astNodes: AstNode[] = []
|
||||
|
||||
// A lazy map implementation that will return the variant if it exists. If it
|
||||
// doesn't exist yet, the raw string variant will be parsed and added to the
|
||||
// map.
|
||||
let parsedVariants: DefaultMap<string, Variant | null> = new DefaultMap((variant, map) => {
|
||||
return parseVariant(variant, designSystem.variants, map)
|
||||
})
|
||||
|
||||
let candidates = new Map<Candidate, string>()
|
||||
|
||||
// Parse candidates and variants
|
||||
for (let rawCandidate of rawCandidates) {
|
||||
let candidate = parseCandidate(rawCandidate, designSystem.utilities, parsedVariants)
|
||||
let candidate = designSystem.parseCandidate(rawCandidate)
|
||||
if (candidate === null) {
|
||||
if (throwOnInvalidCandidate) {
|
||||
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
|
||||
}
|
||||
onInvalidCandidate?.(rawCandidate)
|
||||
continue // Bail, invalid candidate
|
||||
}
|
||||
candidates.set(candidate, rawCandidate)
|
||||
}
|
||||
|
||||
// Sort the variants
|
||||
let variants = Array.from(parsedVariants.values()).sort((a, z) => {
|
||||
let variants = designSystem.getUsedVariants().sort((a, z) => {
|
||||
return designSystem.variants.compare(a, z)
|
||||
})
|
||||
|
||||
// Create the AST
|
||||
next: for (let [candidate, rawCandidate] of candidates) {
|
||||
let nodes: AstNode[] = []
|
||||
|
||||
// Handle arbitrary properties
|
||||
if (candidate.kind === 'arbitrary') {
|
||||
let compileFn = designSystem.utilities.getArbitrary()
|
||||
|
||||
// Build the node
|
||||
let compiledNodes = compileFn(candidate)
|
||||
if (compiledNodes === undefined) {
|
||||
if (throwOnInvalidCandidate) {
|
||||
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
|
||||
}
|
||||
continue next
|
||||
}
|
||||
|
||||
nodes = compiledNodes
|
||||
let astNode = designSystem.compileAstNodes(rawCandidate)
|
||||
if (astNode === null) {
|
||||
onInvalidCandidate?.(rawCandidate)
|
||||
continue next
|
||||
}
|
||||
|
||||
// Handle named utilities
|
||||
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
|
||||
// Safety: At this point it is safe to use TypeScript's non-null assertion
|
||||
// operator because if the `candidate.root` didn't exist, `parseCandidate`
|
||||
// would have returned `null` and we would have returned early resulting
|
||||
// in not hitting this code path.
|
||||
let { compileFn } = designSystem.utilities.get(candidate.root)!
|
||||
|
||||
// Build the node
|
||||
let compiledNodes = compileFn(candidate)
|
||||
if (compiledNodes === undefined) {
|
||||
if (throwOnInvalidCandidate) {
|
||||
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
|
||||
}
|
||||
continue next
|
||||
}
|
||||
|
||||
nodes = compiledNodes
|
||||
}
|
||||
|
||||
let propertySort = getPropertySort(nodes)
|
||||
|
||||
if (candidate.important) {
|
||||
applyImportant(nodes)
|
||||
}
|
||||
|
||||
let node: Rule = {
|
||||
kind: 'rule',
|
||||
selector: `.${escape(rawCandidate)}`,
|
||||
nodes,
|
||||
}
|
||||
let { node, propertySort } = astNode
|
||||
|
||||
// Track the variant order which is a number with each bit representing a
|
||||
// variant. This allows us to sort the rules based on the order of
|
||||
// variants used.
|
||||
let variantOrder = 0n
|
||||
for (let variant of candidate.variants) {
|
||||
let result = applyVariant(node, variant, designSystem.variants)
|
||||
|
||||
// When the variant results in `null`, it means that the variant cannot be
|
||||
// applied to the rule. Discard the candidate and continue to the next
|
||||
// one.
|
||||
if (result === null) {
|
||||
if (throwOnInvalidCandidate) {
|
||||
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
|
||||
}
|
||||
continue next
|
||||
}
|
||||
|
||||
// Track the variant order which is a number with each bit representing a
|
||||
// variant. This allows us to sort the rules based on the order of
|
||||
// variants used.
|
||||
variantOrder |= 1n << BigInt(variants.indexOf(variant))
|
||||
}
|
||||
|
||||
@ -153,7 +85,9 @@ export function compileCandidates(
|
||||
// Sort by lowest property index first
|
||||
(aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) ||
|
||||
// Sort by most properties first, then by least properties
|
||||
zSorting.properties.length - aSorting.properties.length
|
||||
zSorting.properties.length - aSorting.properties.length ||
|
||||
// Sort alphabetically
|
||||
(aSorting.candidate < zSorting.candidate ? -1 : 1)
|
||||
)
|
||||
})
|
||||
|
||||
@ -163,6 +97,65 @@ export function compileCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem) {
|
||||
let candidate = designSystem.parseCandidate(rawCandidate)
|
||||
if (candidate === null) return null
|
||||
|
||||
let nodes: AstNode[] = []
|
||||
|
||||
// Handle arbitrary properties
|
||||
if (candidate.kind === 'arbitrary') {
|
||||
let compileFn = designSystem.utilities.getArbitrary()
|
||||
|
||||
// Build the node
|
||||
let compiledNodes = compileFn(candidate)
|
||||
if (compiledNodes === undefined) return null
|
||||
|
||||
nodes = compiledNodes
|
||||
}
|
||||
|
||||
// Handle named utilities
|
||||
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
|
||||
// Safety: At this point it is safe to use TypeScript's non-null assertion
|
||||
// operator because if the `candidate.root` didn't exist, `parseCandidate`
|
||||
// would have returned `null` and we would have returned early resulting
|
||||
// in not hitting this code path.
|
||||
let { compileFn } = designSystem.utilities.get(candidate.root)!
|
||||
|
||||
// Build the node
|
||||
let compiledNodes = compileFn(candidate)
|
||||
if (compiledNodes === undefined) return null
|
||||
|
||||
nodes = compiledNodes
|
||||
}
|
||||
|
||||
let propertySort = getPropertySort(nodes)
|
||||
|
||||
if (candidate.important) {
|
||||
applyImportant(nodes)
|
||||
}
|
||||
|
||||
let node: Rule = {
|
||||
kind: 'rule',
|
||||
selector: `.${escape(rawCandidate)}`,
|
||||
nodes,
|
||||
}
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
let result = applyVariant(node, variant, designSystem.variants)
|
||||
|
||||
// When the variant results in `null`, it means that the variant cannot be
|
||||
// applied to the rule. Discard the candidate and continue to the next
|
||||
// one.
|
||||
if (result === null) return null
|
||||
}
|
||||
|
||||
return {
|
||||
node,
|
||||
propertySort,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void {
|
||||
if (variant.kind === 'arbitrary') {
|
||||
node.nodes = [rule(variant.selector, node.nodes)]
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { toCss } from './ast'
|
||||
import { compileCandidates } from './compile'
|
||||
import { parseCandidate, parseVariant } from './candidate'
|
||||
import { compileAstNodes, compileCandidates } from './compile'
|
||||
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
|
||||
import { getClassOrder } from './sort'
|
||||
import type { Theme } from './theme'
|
||||
import { Utilities, createUtilities } from './utilities'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { Variants, createVariants } from './variants'
|
||||
|
||||
export type DesignSystem = {
|
||||
@ -15,19 +17,32 @@ export type DesignSystem = {
|
||||
getClassOrder(classes: string[]): [string, bigint | null][]
|
||||
getClassList(): ClassEntry[]
|
||||
getVariants(): VariantEntry[]
|
||||
|
||||
parseCandidate(candidate: string): ReturnType<typeof parseCandidate>
|
||||
parseVariant(variant: string): ReturnType<typeof parseVariant>
|
||||
compileAstNodes(candidate: string): ReturnType<typeof compileAstNodes>
|
||||
|
||||
getUsedVariants(): ReturnType<typeof parseVariant>[]
|
||||
}
|
||||
|
||||
export function buildDesignSystem(theme: Theme): DesignSystem {
|
||||
return {
|
||||
let utilities = createUtilities(theme)
|
||||
let variants = createVariants(theme)
|
||||
|
||||
let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem))
|
||||
let parsedCandidates = new DefaultMap((candidate) => parseCandidate(candidate, designSystem))
|
||||
let compiledAstNodes = new DefaultMap((candidate) => compileAstNodes(candidate, designSystem))
|
||||
|
||||
let designSystem: DesignSystem = {
|
||||
theme,
|
||||
utilities: createUtilities(theme),
|
||||
variants: createVariants(theme),
|
||||
utilities,
|
||||
variants,
|
||||
|
||||
candidatesToCss(classes: string[]) {
|
||||
let result: (string | null)[] = []
|
||||
|
||||
for (let className of classes) {
|
||||
let { astNodes } = compileCandidates([className], this, { throwOnInvalidCandidate: false })
|
||||
let { astNodes } = compileCandidates([className], this)
|
||||
if (astNodes.length === 0) {
|
||||
result.push(null)
|
||||
} else {
|
||||
@ -47,5 +62,20 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
|
||||
getVariants() {
|
||||
return getVariants(this)
|
||||
},
|
||||
|
||||
parseCandidate(candidate: string) {
|
||||
return parsedCandidates.get(candidate)
|
||||
},
|
||||
parseVariant(variant: string) {
|
||||
return parsedVariants.get(variant)
|
||||
},
|
||||
compileAstNodes(candidate: string) {
|
||||
return compiledAstNodes.get(candidate)
|
||||
},
|
||||
getUsedVariants() {
|
||||
return Array.from(parsedVariants.values())
|
||||
},
|
||||
}
|
||||
|
||||
return designSystem
|
||||
}
|
||||
|
||||
@ -9,10 +9,7 @@ const css = String.raw
|
||||
bench('compile', async () => {
|
||||
let { candidates } = scanDir({ base: root, globs: true })
|
||||
|
||||
compile(
|
||||
css`
|
||||
@tailwind utilities;
|
||||
`,
|
||||
candidates,
|
||||
)
|
||||
compile(css`
|
||||
@tailwind utilities;
|
||||
`).build(candidates)
|
||||
})
|
||||
|
||||
@ -93,9 +93,7 @@ describe('compiling CSS', () => {
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@tailwind utilities;"
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
|
||||
@ -6,13 +6,21 @@ import * as CSS from './css-parser'
|
||||
import { buildDesignSystem } from './design-system'
|
||||
import { Theme } from './theme'
|
||||
|
||||
export function compile(css: string, rawCandidates: string[]) {
|
||||
export function compile(css: string): {
|
||||
build(candidates: string[]): string
|
||||
} {
|
||||
let ast = CSS.parse(css)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
|
||||
}
|
||||
|
||||
// Track all invalid candidates
|
||||
let invalidCandidates = new Set<string>()
|
||||
function onInvalidCandidate(candidate: string) {
|
||||
invalidCandidates.add(candidate)
|
||||
}
|
||||
|
||||
// Find all `@theme` declarations
|
||||
let theme = new Theme()
|
||||
let firstThemeRule: Rule | null = null
|
||||
@ -97,11 +105,14 @@ export function compile(css: string, rawCandidates: string[]) {
|
||||
|
||||
let designSystem = buildDesignSystem(theme)
|
||||
|
||||
let tailwindUtilitiesNode: Rule | null = null
|
||||
|
||||
// Find `@tailwind utilities` and replace it with the actual generated utility
|
||||
// class CSS.
|
||||
walk(ast, (node, { replaceWith }) => {
|
||||
walk(ast, (node) => {
|
||||
if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
|
||||
replaceWith(compileCandidates(rawCandidates, designSystem).astNodes)
|
||||
tailwindUtilitiesNode = node
|
||||
|
||||
// Stop walking after finding `@tailwind utilities` to avoid walking all
|
||||
// of the generated CSS. This means `@tailwind utilities` can only appear
|
||||
// once per file but that's the intended usage at this point in time.
|
||||
@ -122,7 +133,9 @@ export function compile(css: string, rawCandidates: string[]) {
|
||||
{
|
||||
// Parse the candidates to an AST that we can replace the `@apply` rule with.
|
||||
let candidateAst = compileCandidates(candidates, designSystem, {
|
||||
throwOnInvalidCandidate: true,
|
||||
onInvalidCandidate: (candidate) => {
|
||||
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
|
||||
},
|
||||
}).astNodes
|
||||
|
||||
// Collect the nodes to insert in place of the `@apply` rule. When a
|
||||
@ -162,7 +175,53 @@ export function compile(css: string, rawCandidates: string[]) {
|
||||
})
|
||||
}
|
||||
|
||||
return toCss(ast)
|
||||
// Track all valid candidates, these are the incoming `rawCandidate` that
|
||||
// resulted in a generated AST Node. All the other `rawCandidates` are invalid
|
||||
// and should be ignored.
|
||||
let allValidCandidates = new Set<string>()
|
||||
let compiledCss = toCss(ast)
|
||||
let previousAstNodeCount = 0
|
||||
|
||||
return {
|
||||
build(newRawCandidates: string[]) {
|
||||
let didChange = false
|
||||
|
||||
// Add all new candidates unless we know that they are invalid.
|
||||
let prevSize = allValidCandidates.size
|
||||
for (let candidate of newRawCandidates) {
|
||||
if (!invalidCandidates.has(candidate)) {
|
||||
allValidCandidates.add(candidate)
|
||||
didChange ||= allValidCandidates.size !== prevSize
|
||||
}
|
||||
}
|
||||
|
||||
// If no new candidates were added, we can return the original CSS. This
|
||||
// currently assumes that we only add new candidates and never remove any.
|
||||
if (!didChange) {
|
||||
return compiledCss
|
||||
}
|
||||
|
||||
if (tailwindUtilitiesNode) {
|
||||
let newNodes = compileCandidates(allValidCandidates, designSystem, {
|
||||
onInvalidCandidate,
|
||||
}).astNodes
|
||||
|
||||
// If no new ast nodes were generated, then we can return the original
|
||||
// CSS. This currently assumes that we only add new ast nodes and never
|
||||
// remove any.
|
||||
if (previousAstNodeCount === newNodes.length) {
|
||||
return compiledCss
|
||||
}
|
||||
|
||||
previousAstNodeCount = newNodes.length
|
||||
|
||||
tailwindUtilitiesNode.nodes = newNodes
|
||||
compiledCss = toCss(ast)
|
||||
}
|
||||
|
||||
return compiledCss
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function optimizeCss(
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { decl, rule } from './ast'
|
||||
import { parseVariant, type Variant } from './candidate'
|
||||
import { applyVariant } from './compile'
|
||||
import type { DesignSystem } from './design-system'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
|
||||
interface ClassMetadata {
|
||||
modifiers: string[]
|
||||
@ -40,7 +38,7 @@ export function getClassList(design: DesignSystem): ClassEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
list.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
list.sort((a, b) => (a[0] === b[0] ? 0 : a[0] < b[0] ? -1 : 1))
|
||||
|
||||
return list
|
||||
}
|
||||
@ -60,9 +58,6 @@ export interface VariantEntry {
|
||||
|
||||
export function getVariants(design: DesignSystem) {
|
||||
let list: VariantEntry[] = []
|
||||
let parsedVariants = new DefaultMap<string, Variant | null>((variant, map) =>
|
||||
parseVariant(variant, design.variants, map),
|
||||
)
|
||||
|
||||
for (let [root, variant] of design.variants.entries()) {
|
||||
if (variant.kind === 'arbitrary') continue
|
||||
@ -74,7 +69,7 @@ export function getVariants(design: DesignSystem) {
|
||||
if (value) name += `-${value}`
|
||||
if (modifier) name += `/${modifier}`
|
||||
|
||||
let variant = parsedVariants.get(name)
|
||||
let variant = design.parseVariant(name)
|
||||
|
||||
if (!variant) return []
|
||||
|
||||
|
||||
@ -3,9 +3,7 @@ import type { DesignSystem } from './design-system'
|
||||
|
||||
export function getClassOrder(design: DesignSystem, classes: string[]): [string, bigint | null][] {
|
||||
// Generate a sorted AST
|
||||
let { astNodes, nodeSorting } = compileCandidates(Array.from(classes), design, {
|
||||
throwOnInvalidCandidate: false,
|
||||
})
|
||||
let { astNodes, nodeSorting } = compileCandidates(Array.from(classes), design)
|
||||
|
||||
// Map class names to their order in the AST
|
||||
// `null` indicates a non-Tailwind class
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { compile, optimizeCss } from '..'
|
||||
|
||||
export function compileCss(css: string, candidates: string[] = []) {
|
||||
return optimizeCss(compile(css, candidates)).trim()
|
||||
return optimizeCss(compile(css).build(candidates)).trim()
|
||||
}
|
||||
|
||||
export function run(candidates: string[]) {
|
||||
return optimizeCss(compile('@tailwind utilities;', candidates)).trim()
|
||||
return optimizeCss(compile('@tailwind utilities;').build(candidates)).trim()
|
||||
}
|
||||
|
||||
@ -1280,7 +1280,13 @@ test('supports', () => {
|
||||
expect(
|
||||
run(['supports-gap:grid', 'supports-[display:grid]:flex', 'supports-[selector(A_>_B)]:flex']),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@supports (display: grid) {
|
||||
"@supports (gap: var(--tw)) {
|
||||
.supports-gap\\:grid {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (display: grid) {
|
||||
.supports-\\[display\\:grid\\]\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@ -1290,12 +1296,6 @@ test('supports', () => {
|
||||
.supports-\\[selector\\(A_\\>_B\\)\\]\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (gap: var(--tw)) {
|
||||
.supports-gap\\:grid {
|
||||
display: grid;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
@ -1322,11 +1322,11 @@ test('not', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) {
|
||||
.group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) {
|
||||
.group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1334,11 +1334,11 @@ test('not', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) {
|
||||
.peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) {
|
||||
.peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) {
|
||||
display: flex;
|
||||
}"
|
||||
`)
|
||||
@ -1362,11 +1362,11 @@ test('has', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) {
|
||||
.group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) {
|
||||
.group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1374,11 +1374,11 @@ test('has', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) {
|
||||
.peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) {
|
||||
.peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1405,15 +1405,7 @@ test('aria', () => {
|
||||
'peer-aria-checked/parent-name:flex',
|
||||
]),
|
||||
).toMatchInlineSnapshot(`
|
||||
".group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) {
|
||||
".group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1421,7 +1413,11 @@ test('aria', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) {
|
||||
.group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1429,20 +1425,24 @@ test('aria', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] {
|
||||
.peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.aria-checked\\:flex[aria-checked="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] {
|
||||
display: flex;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
@ -1460,15 +1460,11 @@ test('data', () => {
|
||||
'peer-data-[disabled]/parent-name:flex',
|
||||
]),
|
||||
).toMatchInlineSnapshot(`
|
||||
".group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) {
|
||||
".group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) {
|
||||
.group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -1476,12 +1472,16 @@ test('data', () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] {
|
||||
.peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.data-disabled\\:flex[data-disabled] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] {
|
||||
display: flex;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
@ -1567,14 +1567,14 @@ test('container queries', () => {
|
||||
--width-lg: 1024px;
|
||||
}
|
||||
|
||||
@container name (width < 1024px) {
|
||||
.\\@max-lg\\/name\\:flex {
|
||||
@container (width < 1024px) {
|
||||
.\\@max-lg\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width < 1024px) {
|
||||
.\\@max-lg\\:flex {
|
||||
@container name (width < 1024px) {
|
||||
.\\@max-lg\\/name\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@ -1615,12 +1615,6 @@ test('container queries', () => {
|
||||
}
|
||||
}
|
||||
|
||||
@container name (width >= 1024px) {
|
||||
.\\@lg\\/name\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 1024px) {
|
||||
.\\@lg\\:flex {
|
||||
display: flex;
|
||||
@ -1628,7 +1622,7 @@ test('container queries', () => {
|
||||
}
|
||||
|
||||
@container name (width >= 1024px) {
|
||||
.\\@min-lg\\/name\\:flex {
|
||||
.\\@lg\\/name\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@ -1637,6 +1631,12 @@ test('container queries', () => {
|
||||
.\\@min-lg\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container name (width >= 1024px) {
|
||||
.\\@min-lg\\/name\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
@ -94,7 +94,7 @@ export class Variants {
|
||||
if (z === null) return 1
|
||||
|
||||
if (a.kind === 'arbitrary' && z.kind === 'arbitrary') {
|
||||
return a.selector.localeCompare(z.selector)
|
||||
return a.selector < z.selector ? -1 : 1
|
||||
} else if (a.kind === 'arbitrary') {
|
||||
return 1
|
||||
} else if (z.kind === 'arbitrary') {
|
||||
@ -114,7 +114,7 @@ export class Variants {
|
||||
let compareFn = this.compareFns.get(aOrder)
|
||||
if (compareFn === undefined) return 0
|
||||
|
||||
return compareFn(a, z)
|
||||
return compareFn(a, z) || (a.root < z.root ? -1 : 1)
|
||||
}
|
||||
|
||||
keys() {
|
||||
@ -469,7 +469,7 @@ export function createVariants(theme: Theme): Variants {
|
||||
|
||||
let order =
|
||||
// Compare by bucket name
|
||||
aBucket.localeCompare(zBucket) ||
|
||||
(aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) ||
|
||||
// If bucket names are the same, compare by value
|
||||
(direction === 'asc'
|
||||
? parseInt(aValue) - parseInt(zValue)
|
||||
@ -489,7 +489,7 @@ export function createVariants(theme: Theme): Variants {
|
||||
// In this scenario, we want to alphabetically sort `calc(100%-1rem)` and
|
||||
// `calc(100%-2rem)` to make it deterministic.
|
||||
if (Number.isNaN(order)) {
|
||||
return aValue.localeCompare(zValue)
|
||||
return aValue < zValue ? -1 : 1
|
||||
}
|
||||
|
||||
return order
|
||||
|
||||
@ -255,21 +255,18 @@ async function render(page: Page, content: string) {
|
||||
await page.setContent(content)
|
||||
await page.addStyleTag({
|
||||
content: optimizeCss(
|
||||
compile(
|
||||
css`
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
${defaultTheme}
|
||||
}
|
||||
@layer base {
|
||||
${preflight}
|
||||
}
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`,
|
||||
scanFiles([{ content, extension: 'html' }], IO.Sequential | Parsing.Sequential),
|
||||
),
|
||||
compile(css`
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
${defaultTheme}
|
||||
}
|
||||
@layer base {
|
||||
${preflight}
|
||||
}
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(scanFiles([{ content, extension: 'html' }], IO.Sequential | Parsing.Sequential)),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user