| 1 | import fs from 'fs'
|
|---|
| 2 | import LRU from '@alloc/quick-lru'
|
|---|
| 3 | import * as sharedState from './sharedState'
|
|---|
| 4 | import { generateRules } from './generateRules'
|
|---|
| 5 | import log from '../util/log'
|
|---|
| 6 | import cloneNodes from '../util/cloneNodes'
|
|---|
| 7 | import { defaultExtractor } from './defaultExtractor'
|
|---|
| 8 |
|
|---|
| 9 | let env = sharedState.env
|
|---|
| 10 |
|
|---|
| 11 | const builtInExtractors = {
|
|---|
| 12 | DEFAULT: defaultExtractor,
|
|---|
| 13 | }
|
|---|
| 14 |
|
|---|
| 15 | const builtInTransformers = {
|
|---|
| 16 | DEFAULT: (content) => content,
|
|---|
| 17 | svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '),
|
|---|
| 18 | }
|
|---|
| 19 |
|
|---|
| 20 | function getExtractor(context, fileExtension) {
|
|---|
| 21 | let extractors = context.tailwindConfig.content.extract
|
|---|
| 22 |
|
|---|
| 23 | return (
|
|---|
| 24 | extractors[fileExtension] ||
|
|---|
| 25 | extractors.DEFAULT ||
|
|---|
| 26 | builtInExtractors[fileExtension] ||
|
|---|
| 27 | builtInExtractors.DEFAULT(context)
|
|---|
| 28 | )
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | function getTransformer(tailwindConfig, fileExtension) {
|
|---|
| 32 | let transformers = tailwindConfig.content.transform
|
|---|
| 33 |
|
|---|
| 34 | return (
|
|---|
| 35 | transformers[fileExtension] ||
|
|---|
| 36 | transformers.DEFAULT ||
|
|---|
| 37 | builtInTransformers[fileExtension] ||
|
|---|
| 38 | builtInTransformers.DEFAULT
|
|---|
| 39 | )
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | let extractorCache = new WeakMap()
|
|---|
| 43 |
|
|---|
| 44 | // Scans template contents for possible classes. This is a hot path on initial build but
|
|---|
| 45 | // not too important for subsequent builds. The faster the better though — if we can speed
|
|---|
| 46 | // up these regexes by 50% that could cut initial build time by like 20%.
|
|---|
| 47 | function getClassCandidates(content, extractor, candidates, seen) {
|
|---|
| 48 | if (!extractorCache.has(extractor)) {
|
|---|
| 49 | extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | for (let line of content.split('\n')) {
|
|---|
| 53 | line = line.trim()
|
|---|
| 54 |
|
|---|
| 55 | if (seen.has(line)) {
|
|---|
| 56 | continue
|
|---|
| 57 | }
|
|---|
| 58 | seen.add(line)
|
|---|
| 59 |
|
|---|
| 60 | if (extractorCache.get(extractor).has(line)) {
|
|---|
| 61 | for (let match of extractorCache.get(extractor).get(line)) {
|
|---|
| 62 | candidates.add(match)
|
|---|
| 63 | }
|
|---|
| 64 | } else {
|
|---|
| 65 | let extractorMatches = extractor(line).filter((s) => s !== '!*')
|
|---|
| 66 | let lineMatchesSet = new Set(extractorMatches)
|
|---|
| 67 |
|
|---|
| 68 | for (let match of lineMatchesSet) {
|
|---|
| 69 | candidates.add(match)
|
|---|
| 70 | }
|
|---|
| 71 |
|
|---|
| 72 | extractorCache.get(extractor).set(line, lineMatchesSet)
|
|---|
| 73 | }
|
|---|
| 74 | }
|
|---|
| 75 | }
|
|---|
| 76 |
|
|---|
| 77 | /**
|
|---|
| 78 | *
|
|---|
| 79 | * @param {[import('./offsets.js').RuleOffset, import('postcss').Node][]} rules
|
|---|
| 80 | * @param {*} context
|
|---|
| 81 | */
|
|---|
| 82 | function buildStylesheet(rules, context) {
|
|---|
| 83 | let sortedRules = context.offsets.sort(rules)
|
|---|
| 84 |
|
|---|
| 85 | let returnValue = {
|
|---|
| 86 | base: new Set(),
|
|---|
| 87 | defaults: new Set(),
|
|---|
| 88 | components: new Set(),
|
|---|
| 89 | utilities: new Set(),
|
|---|
| 90 | variants: new Set(),
|
|---|
| 91 | }
|
|---|
| 92 |
|
|---|
| 93 | for (let [sort, rule] of sortedRules) {
|
|---|
| 94 | returnValue[sort.layer].add(rule)
|
|---|
| 95 | }
|
|---|
| 96 |
|
|---|
| 97 | return returnValue
|
|---|
| 98 | }
|
|---|
| 99 |
|
|---|
| 100 | export default function expandTailwindAtRules(context) {
|
|---|
| 101 | return async (root) => {
|
|---|
| 102 | let layerNodes = {
|
|---|
| 103 | base: null,
|
|---|
| 104 | components: null,
|
|---|
| 105 | utilities: null,
|
|---|
| 106 | variants: null,
|
|---|
| 107 | }
|
|---|
| 108 |
|
|---|
| 109 | root.walkAtRules((rule) => {
|
|---|
| 110 | // Make sure this file contains Tailwind directives. If not, we can save
|
|---|
| 111 | // a lot of work and bail early. Also we don't have to register our touch
|
|---|
| 112 | // file as a dependency since the output of this CSS does not depend on
|
|---|
| 113 | // the source of any templates. Think Vue <style> blocks for example.
|
|---|
| 114 | if (rule.name === 'tailwind') {
|
|---|
| 115 | if (Object.keys(layerNodes).includes(rule.params)) {
|
|---|
| 116 | layerNodes[rule.params] = rule
|
|---|
| 117 | }
|
|---|
| 118 | }
|
|---|
| 119 | })
|
|---|
| 120 |
|
|---|
| 121 | if (Object.values(layerNodes).every((n) => n === null)) {
|
|---|
| 122 | return root
|
|---|
| 123 | }
|
|---|
| 124 |
|
|---|
| 125 | // ---
|
|---|
| 126 |
|
|---|
| 127 | // Find potential rules in changed files
|
|---|
| 128 | let candidates = new Set([...(context.candidates ?? []), sharedState.NOT_ON_DEMAND])
|
|---|
| 129 | let seen = new Set()
|
|---|
| 130 |
|
|---|
| 131 | env.DEBUG && console.time('Reading changed files')
|
|---|
| 132 |
|
|---|
| 133 | /** @type {[item: {file?: string, content?: string}, meta: {transformer: any, extractor: any}][]} */
|
|---|
| 134 | let regexParserContent = []
|
|---|
| 135 |
|
|---|
| 136 | for (let item of context.changedContent) {
|
|---|
| 137 | let transformer = getTransformer(context.tailwindConfig, item.extension)
|
|---|
| 138 | let extractor = getExtractor(context, item.extension)
|
|---|
| 139 | regexParserContent.push([item, { transformer, extractor }])
|
|---|
| 140 | }
|
|---|
| 141 |
|
|---|
| 142 | const BATCH_SIZE = 500
|
|---|
| 143 |
|
|---|
| 144 | for (let i = 0; i < regexParserContent.length; i += BATCH_SIZE) {
|
|---|
| 145 | let batch = regexParserContent.slice(i, i + BATCH_SIZE)
|
|---|
| 146 | await Promise.all(
|
|---|
| 147 | batch.map(async ([{ file, content }, { transformer, extractor }]) => {
|
|---|
| 148 | content = file ? await fs.promises.readFile(file, 'utf8') : content
|
|---|
| 149 | getClassCandidates(transformer(content), extractor, candidates, seen)
|
|---|
| 150 | })
|
|---|
| 151 | )
|
|---|
| 152 | }
|
|---|
| 153 |
|
|---|
| 154 | env.DEBUG && console.timeEnd('Reading changed files')
|
|---|
| 155 |
|
|---|
| 156 | // ---
|
|---|
| 157 |
|
|---|
| 158 | // Generate the actual CSS
|
|---|
| 159 | let classCacheCount = context.classCache.size
|
|---|
| 160 |
|
|---|
| 161 | env.DEBUG && console.time('Generate rules')
|
|---|
| 162 | env.DEBUG && console.time('Sorting candidates')
|
|---|
| 163 | let sortedCandidates = new Set(
|
|---|
| 164 | [...candidates].sort((a, z) => {
|
|---|
| 165 | if (a === z) return 0
|
|---|
| 166 | if (a < z) return -1
|
|---|
| 167 | return 1
|
|---|
| 168 | })
|
|---|
| 169 | )
|
|---|
| 170 | env.DEBUG && console.timeEnd('Sorting candidates')
|
|---|
| 171 | generateRules(sortedCandidates, context)
|
|---|
| 172 | env.DEBUG && console.timeEnd('Generate rules')
|
|---|
| 173 |
|
|---|
| 174 | // We only ever add to the classCache, so if it didn't grow, there is nothing new.
|
|---|
| 175 | env.DEBUG && console.time('Build stylesheet')
|
|---|
| 176 | if (context.stylesheetCache === null || context.classCache.size !== classCacheCount) {
|
|---|
| 177 | context.stylesheetCache = buildStylesheet([...context.ruleCache], context)
|
|---|
| 178 | }
|
|---|
| 179 | env.DEBUG && console.timeEnd('Build stylesheet')
|
|---|
| 180 |
|
|---|
| 181 | let {
|
|---|
| 182 | defaults: defaultNodes,
|
|---|
| 183 | base: baseNodes,
|
|---|
| 184 | components: componentNodes,
|
|---|
| 185 | utilities: utilityNodes,
|
|---|
| 186 | variants: screenNodes,
|
|---|
| 187 | } = context.stylesheetCache
|
|---|
| 188 |
|
|---|
| 189 | // ---
|
|---|
| 190 |
|
|---|
| 191 | // Replace any Tailwind directives with generated CSS
|
|---|
| 192 |
|
|---|
| 193 | if (layerNodes.base) {
|
|---|
| 194 | layerNodes.base.before(
|
|---|
| 195 | cloneNodes([...defaultNodes, ...baseNodes], layerNodes.base.source, {
|
|---|
| 196 | layer: 'base',
|
|---|
| 197 | })
|
|---|
| 198 | )
|
|---|
| 199 | layerNodes.base.remove()
|
|---|
| 200 | }
|
|---|
| 201 |
|
|---|
| 202 | if (layerNodes.components) {
|
|---|
| 203 | layerNodes.components.before(
|
|---|
| 204 | cloneNodes([...componentNodes], layerNodes.components.source, {
|
|---|
| 205 | layer: 'components',
|
|---|
| 206 | })
|
|---|
| 207 | )
|
|---|
| 208 | layerNodes.components.remove()
|
|---|
| 209 | }
|
|---|
| 210 |
|
|---|
| 211 | if (layerNodes.utilities) {
|
|---|
| 212 | layerNodes.utilities.before(
|
|---|
| 213 | cloneNodes([...utilityNodes], layerNodes.utilities.source, {
|
|---|
| 214 | layer: 'utilities',
|
|---|
| 215 | })
|
|---|
| 216 | )
|
|---|
| 217 | layerNodes.utilities.remove()
|
|---|
| 218 | }
|
|---|
| 219 |
|
|---|
| 220 | // We do post-filtering to not alter the emitted order of the variants
|
|---|
| 221 | const variantNodes = Array.from(screenNodes).filter((node) => {
|
|---|
| 222 | const parentLayer = node.raws.tailwind?.parentLayer
|
|---|
| 223 |
|
|---|
| 224 | if (parentLayer === 'components') {
|
|---|
| 225 | return layerNodes.components !== null
|
|---|
| 226 | }
|
|---|
| 227 |
|
|---|
| 228 | if (parentLayer === 'utilities') {
|
|---|
| 229 | return layerNodes.utilities !== null
|
|---|
| 230 | }
|
|---|
| 231 |
|
|---|
| 232 | return true
|
|---|
| 233 | })
|
|---|
| 234 |
|
|---|
| 235 | if (layerNodes.variants) {
|
|---|
| 236 | layerNodes.variants.before(
|
|---|
| 237 | cloneNodes(variantNodes, layerNodes.variants.source, {
|
|---|
| 238 | layer: 'variants',
|
|---|
| 239 | })
|
|---|
| 240 | )
|
|---|
| 241 | layerNodes.variants.remove()
|
|---|
| 242 | } else if (variantNodes.length > 0) {
|
|---|
| 243 | root.append(
|
|---|
| 244 | cloneNodes(variantNodes, root.source, {
|
|---|
| 245 | layer: 'variants',
|
|---|
| 246 | })
|
|---|
| 247 | )
|
|---|
| 248 | }
|
|---|
| 249 |
|
|---|
| 250 | // TODO: Why is the root node having no source location for `end` possible?
|
|---|
| 251 | root.source.end = root.source.end ?? root.source.start
|
|---|
| 252 |
|
|---|
| 253 | // If we've got a utility layer and no utilities are generated there's likely something wrong
|
|---|
| 254 | const hasUtilityVariants = variantNodes.some(
|
|---|
| 255 | (node) => node.raws.tailwind?.parentLayer === 'utilities'
|
|---|
| 256 | )
|
|---|
| 257 |
|
|---|
| 258 | if (layerNodes.utilities && utilityNodes.size === 0 && !hasUtilityVariants) {
|
|---|
| 259 | log.warn('content-problems', [
|
|---|
| 260 | 'No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.',
|
|---|
| 261 | 'https://tailwindcss.com/docs/content-configuration',
|
|---|
| 262 | ])
|
|---|
| 263 | }
|
|---|
| 264 |
|
|---|
| 265 | // ---
|
|---|
| 266 |
|
|---|
| 267 | if (env.DEBUG) {
|
|---|
| 268 | console.log('Potential classes: ', candidates.size)
|
|---|
| 269 | console.log('Active contexts: ', sharedState.contextSourcesMap.size)
|
|---|
| 270 | }
|
|---|
| 271 |
|
|---|
| 272 | // Clear the cache for the changed files
|
|---|
| 273 | context.changedContent = []
|
|---|
| 274 |
|
|---|
| 275 | // Cleanup any leftover @layer atrules
|
|---|
| 276 | root.walkAtRules('layer', (rule) => {
|
|---|
| 277 | if (Object.keys(layerNodes).includes(rule.params)) {
|
|---|
| 278 | rule.remove()
|
|---|
| 279 | }
|
|---|
| 280 | })
|
|---|
| 281 | }
|
|---|
| 282 | }
|
|---|