import {__, _x} from '@wordpress/i18n' import { select, dispatch, subscribe, } from '@wordpress/data' import { store as coreDataStore, } from '@wordpress/core-data' import { store as noticesStore, } from '@wordpress/notices' import { applyFilters, } from '@wordpress/hooks' import { compile, __unstable__loadDesignSystem as loadDesignSystem, Polyfills, // @ts-expect-error } from 'tailwindcss' // @ts-ignore import formsPlugin from '@tailwindcss/forms' // @ts-ignore import typographyPlugin from '@tailwindcss/typography' import { getStaticPlugins, wpAdminBarPlugin, backgroundImagePlugin, dynamicVariantsPlugin, } from './plugins' import { initCssProcessor, processCss, processCssRules, type ProcessCSSConfig, } from './process-css' import { extractLayer, } from './util' import { getPresetsOption, } from '../../supports/presets' import { triggerCompiledEvent, } from '../../plugins/options/plugin-icon' import { ARRAY_UNIQUE, debugLog, debugWarn, } from '@ska/utils' import type { Tailwind4Options, } from '../../types' export { useLightningcssInitialized, } from './process-css' const ERROR = { FAILED_LOADING_DESIGN_SYSTEM: 1, INVALID_DESIGN_SYSTEM: 2, } export const TAILWIND4_UPDATED_EVENT = 'ska_tailwind4_version_update' const versionUpdatedEvent = new Event(TAILWIND4_UPDATED_EVENT) const triggerVersionUpdated = () => document.dispatchEvent(versionUpdatedEvent) type CompileOptions = { base?: string loadModule?: (id: string, base: string, resourceHint: 'plugin' | 'config') => Promise<{module: any, base: string}> loadStylesheet?: (id: string, base: string) => Promise<{content: string, base: string}> polyfills?: any // Polyfills } interface CompileOpts { /** Whether Compiler should handle error messages or throw them (default: false). */ throwOnError?: boolean } type DesignSystem = { theme: any utilities: any variants: any getClassOrder: (classes: string[]) => [string, bigint | null][] getClassList: () => /*ClassEntry*/any[] getVariants: () => /*VariantEntry*/any[] parseCandidate: (candidate: string) => /*Candidate*/any[] parseVariant: (variant: string) => /*Variant*/any | null compileAstNodes: (candidate: /*Candidate*/any) => /*ReturnType*/any getVariantOrder: () => Map resolveThemeValue: (path: string) => string | undefined candidatesToCss: (classes: string[]) => (string | null)[] } const builtThemeKeys = new Map>>() const builtColors = new Map, themeColors: Map>, paletteColors: Map>, }>() const builtOpacities = new Map() /** * Tailwind 4 compiler. */ export default class TailwindCompiler { #currentTheme: string = '' #currentOptions: Tailwind4Options | undefined /** Options last modified time. */ #t: number = -1 /** Theme last modified time, changes even when options were kept stale by not updating T. */ #themeKey: number = 0 // @ts-ignore designSystem: DesignSystem constructor() { // @ts-ignore if(!Object(globalThis).hasOwnProperty('process')) { // @ts-ignore globalThis.process = {env: {}} } this.recompileTheme() } handleError({error: e, message: msg = '', detail = {}, opts = {}}: {error: any, message?: string, detail?: any, opts?: CompileOpts}) { const { throwOnError = false, } = opts const message = `${msg || 'ska-blocks Tailwind 4 error'}: ${e?.message || 'unknown'}` if(throwOnError) { throw new Error(message) } else { dispatch(noticesStore).createErrorNotice(message, {id: 'ska-blocks-tailwind-4', type: 'snackbar'}) /** Bigger message if it has a code it's probably a critical one. */ if(detail?.code) { dispatch(noticesStore).createErrorNotice(`ska-blocks: ${message}`, { id: 'ska-blocks-tailwind-4-critical', isDismissible: false, }) } } debugWarn(message, {e, detail}) } get #compileOptions(): CompileOptions { const loadPlugin = async (id: string, base: string) => { switch(id) { case '@tailwindcss/typography': return typographyPlugin case '@tailwindcss/forms': return formsPlugin case 'ska/wp-admin-bar': return wpAdminBarPlugin case 'ska/background': return backgroundImagePlugin case 'ska/dynamic-variants': return dynamicVariantsPlugin default: const plugin = applyFilters('ska.blocks.tailwind.plugin', null, id) if(plugin === null) { throw new Error(`Cannot load plugin "${id}"`) } return plugin } } return { base: '', polyfills: Polyfills.All, loadModule: async (id, base, resourceHint) => { if(resourceHint === 'plugin') { return { module: await loadPlugin(id, base), base, } } throw new Error(`Cannot load module "${id}"`) }, loadStylesheet: async (id, base) => { throw new Error(`Cannot load stylesheet "${id}"`) }, } } /** * Unique identifier of the Theme, stored in block attributes. * When the `t` in the attributes doesn't match we have the option * to automatically recompile so that changes made in the theme are applied. * `t` changes when any `ska_blocks_tailwind4` WP option is modified. */ get t() { return this.#t } /** * Unique identifier of the Theme, updated even when `t` is kept stale. */ get themeKey() { return this.#themeKey } #isValidDesignSystem(ds: any): boolean { if(!ds || !ds?.theme) { return false } return ds.theme.values.size > 0 } #buildFonts(fonts: Tailwind4Options['fontFamily']) { return Object.keys(fonts).reduce((acc, key) => { const fontString = fonts[key] if(fontString.includes('[&]')) { const [confJson, fallbackFonts = ''] = fontString.split('[&]') const conf = JSON.parse(confJson) const {font} = conf const dynamicFontString = font ? [ `'${font}'`, ...(fallbackFonts ? [fallbackFonts] : []), ].join(', ') : fallbackFonts acc = `${acc}\n--font-${key}: ${dynamicFontString};` } else { acc = `${acc}\n--font-${key}: ${fontString};` } return acc }, '') } #buildThemePalette(colors: Tailwind4Options['themePalette']) { return Object.keys(colors).reduce((acc, key) => { return Object.keys(colors[key]).reduce((acc, subKey) => { const suffix = subKey === 'DEFAULT' ? '' : `-${subKey}` return `${acc}\n--color-${key}${suffix}: ${colors[key][subKey]};` }, acc) }, '') } /** Combine editable options into a compilable theme. */ buildTheme(options: Tailwind4Options) { const { theme = '', themeTheme = '', customTheme = '', fontFamily, themePalette, tailwindColors, plugins = '', themePlugins = '', editorPlugins = '', } = options const tailwindTheme = ` ${theme} ${themeTheme} ${customTheme} ` const combinedTheme = tailwindTheme .replace('@ska-blocks-fonts;', this.#buildFonts(fontFamily)) .replace('@ska-blocks-theme-palette;', this.#buildThemePalette(themePalette)) .replace('@ska-blocks-tailwind-colors;', tailwindColors) return ` ${combinedTheme} ${getStaticPlugins()} ${plugins} ${themePlugins} ${editorPlugins} ` } async loadDesignSystem(options: Tailwind4Options) { const theme = this.buildTheme(options) if(this.#currentTheme === theme) { // Already loaded/loading this theme if(this.#t > -1 && this.#t !== options.lastmod) { debugLog('ska-blocks: Loaded identical DesignSystem', this.#t, options.lastmod) this.#t = options.lastmod this.#themeKey = new Date().getTime() triggerVersionUpdated() } return } const _prevTheme = this.#currentTheme this.#currentTheme = theme try { const designSystem = await loadDesignSystem(theme, this.#compileOptions) if(this.#isValidDesignSystem(designSystem)) { this.designSystem = designSystem this.#currentOptions = options if(this.#t === -1) { // Updating t to > -1 will allow compiling, which requires CSS processor to be ready. requestIdleCallback(async () => { await initCssProcessor() this.#t = options.lastmod this.#themeKey = new Date().getTime() triggerVersionUpdated() debugLog('ska-blocks: Loaded initial DesignSystem', options.lastmod) }) } else { this.#t = options.lastmod this.#themeKey = new Date().getTime() triggerVersionUpdated() debugLog('ska-blocks: Loaded DesignSystem', options.lastmod) } } else { this.#currentTheme = _prevTheme throw new Error('Invalid design system.') } } catch(e) { this.handleError({ error: e, message: `Failed loading design system`, detail: {code: ERROR.FAILED_LOADING_DESIGN_SYSTEM}, }) } } processCSS(css: string | string[], config: Partial = {}) { return typeof css === 'string' ? processCss(css, config) : processCssRules(css, config) } /** Compile utility classes such as `m-4 p-2` to CSS. */ compile(classNames: string): string { const candidates = classNames.split(' ') let css: string[] = [] try { css = this.designSystem.candidatesToCss(candidates).filter((v: string | null): v is string => v !== null) } catch(e) { this.handleError({error: e, message: `Failed compiling classes`}) return '' } return this.processCSS(css) } /** * Compile a theme from options, this compiled theme is then stored in options and can be loaded as a design system. */ async compileTheme(options: Tailwind4Options): Promise<[string, DesignSystem | null]> { const start = performance.now() const builtTheme = this.buildTheme(options) let css = '' try { const {build} = await compile(builtTheme, this.#compileOptions) css = build([]) as string } catch(e) { this.handleError({error: e, message: `Failed compiling theme`}) } const theme = this.processCSS(css, {clean: false}).replaceAll('@layer base', '@layer ska-base') let designSystem: DesignSystem | null = null try { designSystem = await loadDesignSystem(builtTheme, this.#compileOptions) if(!this.#isValidDesignSystem(designSystem)) { throw new Error('Invalid design system.') } } catch(e) { this.handleError({error: e, message: `Failed loading design system`}) } debugLog(`Compiled theme in ${performance.now() - start}ms!`) return [theme, designSystem] } /** Compile "Tailwind" CSS such as `.class { @apply m-4 p2; }` to regular CSS. */ async compileCSS(cssInput: string, opts?: CompileOpts): Promise { if(!cssInput.trim()) { return '' } const start = performance.now() const component = `@layer SkaBlocksLayer { ${cssInput} }` let compiled = '' try { const {build} = await compile(this.#currentTheme + component, this.#compileOptions) compiled = build([]) as string } catch(e) { this.handleError({error: e, message: `Failed compiling component`, detail: {cssInput}, opts}) return '' } const css = extractLayer('SkaBlocksLayer', compiled) if(css === false) { this.handleError({error: new Error(`SkaBlocksLayer not found`), message: `Failed compiling component`, opts}) return '' } const result = this.processCSS(css) debugLog(`Compiled CSS in ${performance.now() - start}ms!`) return result } async compileComponent(selector: string, classNames: string): Promise { const classesToApply = this.#processApplyClasses(classNames) if(!classesToApply) { return '' } return this.compileCSS(`${selector} { @apply ${classesToApply}; }`) } compileHTML(html: string): string { if(!html.trim()) { return '' } return this.compile(this.#getClassesFromHTML(html)) } async compileHTMLwithCSS(html: string, css: string, opts?: CompileOpts): Promise { const htmlCss = this.compileHTML(html) const cssCss = await this.compileCSS(css.replace(/@apply ([^;]+);/g, c => this.#processApplyClasses(c)), opts) return this.processCSS(`${htmlCss}${cssCss}`) } #getClassesFromHTML(html: string): string { const parser = new DOMParser() const doc = parser.parseFromString(html, 'text/html') const allClasses = new Set() doc.querySelectorAll('*').forEach(element => { element.classList.forEach(className => allClasses.add(className)) }) return Array.from(allClasses).join(' ') } /** * Process classes used in `@apply` statement. */ #processApplyClasses(classes: string): string { const processed = classes .split(' ') .map(c => c.trim()) .filter(c => { /** Utilities that can't be used in `@apply`. */ if( c.indexOf('group') === 0 || c.indexOf('peer') === 0 || c.indexOf('not-prose') === 0 ) { return false } return true }) .map(c => { /** Replace `ska-preset--{slug}` with preset's classes. */ if(c.indexOf('ska-preset--') === 0) { const slug = c.replace('ska-preset--', '').replaceAll(';', '') // Last class can have trailing `;`. const presets = getPresetsOption() if(slug in presets) { const {cx: presetClasses = ''} = presets[slug] return this.#processApplyClasses(presetClasses) } return '' } return c }) .map(v => v) .filter(ARRAY_UNIQUE) .join(' ') .trim() return processed } getThemeKey(themeKey: '--color', resolveValues?: boolean, designSystem?: DesignSystem): Map> getThemeKey(themeKey: string, resolveValues?: boolean, designSystem?: DesignSystem): Map getThemeKey(themeKey: string, resolveValues: boolean = true, designSystem?: DesignSystem) { const id = `${themeKey}-${this.t}-${this.themeKey}-${resolveValues ? '1' : '0'}` if(builtThemeKeys.has(id)) { return builtThemeKeys.get(id)! } const ds = designSystem || this.designSystem const map = new Map>() if(!ds?.theme) { debugLog('getThemeKey no DesignSystem theme found') return map } const resolve = resolveValues ? ds.theme.resolveValue.bind(ds.theme) : (v: string, tk: any) => `var(${themeKey}-${v})` switch(themeKey) { case '--color': map.set('inherit', 'inherit') map.set('current', 'currentColor') map.set('transparent', 'transparent') const splitColor = (key: string) => { const [root, ...rest] = key.split('-') return {root, color: rest.join('-')} } ds.theme.keysInNamespaces([themeKey]).forEach((key: string) => { if(key.indexOf('-') === -1) { if(!map.has(key)) { map.set(key, new Map([['DEFAULT', resolve(key, [themeKey])]])) } } else { const {root, color} = splitColor(key) if(!map.has(root)) { map.set(root, new Map([['DEFAULT', resolve(root, [themeKey])]])) } (map.get(root) as Map).set(color, resolve(key, [themeKey])) } }) // Update non-palettes to use `string` instead of `Map`. map.forEach((value, key, map) => { if(value instanceof Map && value.size === 1) { map.set(key, value.get(Array.from(value.keys())[0]) || '') } }) break case '--spacing': ds.theme.keysInNamespaces([themeKey]).forEach((key: string) => { map.set(key.replaceAll('_', '.'), resolve(key, [themeKey])) }) break default: ds.theme.keysInNamespaces([themeKey]).forEach((key: string) => { map.set(key, resolve(key, [themeKey])) }) } builtThemeKeys.set(id, map) return map } get options() { return this.#currentOptions! } get colors() { const id = `${this.t}-${this.themeKey}` if(builtColors.has(id)) { return builtColors.get(id)! } const themePaletteKeys = Object.keys(this.options.themePalette) const maps = { basicColors: new Map(), themeColors: new Map>(), paletteColors: new Map>(), } this.getThemeKey('--color').forEach((v, k) => { if(v instanceof Map) { if(themePaletteKeys.includes(k)) { maps.themeColors.set(k, v) } else { maps.paletteColors.set(k, v) } } else { maps.basicColors.set(k, v) } }) builtColors.set(id, maps) return maps } get opacities() { const id = `${this.t}-${this.themeKey}` if(builtOpacities.has(id)) { return builtOpacities.get(id)! } // ['0', '5', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55', '60', '65', '70', '75', '80', '85', '90', '95', '100'] const opacities = this.designSystem.utilities.getCompletions('caret')[0].modifiers as string[] builtOpacities.set(id, opacities) return opacities } get variantOptions() { const breakpoints = this.getThemeKey('--breakpoint') const screenVariants = Object.keys(breakpoints).concat(Object.keys(breakpoints).map(screen => `max-${screen}`)) const variants = screenVariants.concat([ 'hover', 'focus', 'focus-within', 'focus-visible', 'active', 'visited', 'target', 'first', 'last', 'only', 'odd', 'even', 'first-of-type', 'last-of-type', 'only-of-type', 'empty', 'disabled', 'enabled', 'checked', 'indeterminate', 'default', 'required', 'valid', 'invalid', 'in-range', 'out-of-range', 'placeholder-shown', 'autofill', 'read-only', 'open', 'before', 'after', 'first-letter', 'first-line', 'marker', 'selection', 'file', 'backdrop', 'placeholder', 'dark', 'portrait', 'landscape', 'motion-safe', 'motion-reduce', 'contrast-more', 'contrast-less', 'print', 'rtl', 'ltr', ]) return variants.map(variant => { return { label: variant, tooltip: screenVariants.includes(variant) ? `${variant.indexOf('max-') === 0 ? `<` : `>=`}${screenVariants[variant.replace('max-', '') as keyof typeof screenVariants]}` : undefined, value: variant, } }) } /** * Resolve color value, if it resolves to theme value, resolve that. */ resolveColorValue = (value?: string) => { if(!value) { return undefined } if( value.startsWith('#') || value.startsWith('oklch(') || value.startsWith('color-mix(') || value.startsWith('rgb(') || value.startsWith('rgba(') ) { return value } if(!this.designSystem) { return value } const prepareValue = (value: string) => { if(!value) { return value } // When arbitrary variable value is `(--color-primary-light)` (for a class like `text-(--color-primary-light)`) return just `primary-light` so it can be resolved. if(value.startsWith('(--color-')) { return value.replace('(--color-', '').replace(')', '') } if(value.startsWith('theme(colors.')) { return value .replace('theme(colors.', '') .replace(')', '') .replace('[', '') .replace(']', '') .replace('.', '-') } return value } const resolved = this.designSystem.theme.resolveValue(prepareValue(value), ['--color']) as string | null if(!resolved) { return value } if(resolved.startsWith('theme(colors.')) { const themeResolved = this.designSystem.theme.resolveValue(prepareValue(resolved), ['--color']) as string | null if(!themeResolved) { return value } return themeResolved } return resolved } /** Re-compile theme when related options change. */ recompileTheme() { const RECOMPILE_KEYS = [ 'theme', 'themeTheme', 'customTheme', 'fontFamily', 'themePalette', 'tailwindColors', 'preflight', 'plugins', 'themePlugins', 'editorPlugins', 'properties', 'compat', ] const RECOMPILE_NOTICE_ID = 'ska-blocks-tailwind-4-recompile' const RECOMPILE_DEBOUNCE = 3000 let failed = false const recompile = async () => { const { // @ts-ignore ska_blocks_tailwind4 = {}, } = select(coreDataStore).getEditedEntityRecord('root', 'site', undefined!) const { preflight, properties, compat, } = ska_blocks_tailwind4 const [theme, designSystem] = await this.compileTheme(ska_blocks_tailwind4) if(!designSystem) { return () => { dispatch(noticesStore).createInfoNotice(__('Failed recompiling Tailwind.', 'ska-blocks'), {id: RECOMPILE_NOTICE_ID, type: 'snackbar'}) failed = true } } return () => { dispatch(coreDataStore).editEntityRecord('root', 'site', undefined!, { ska_blocks_tailwind4: { ...ska_blocks_tailwind4, compiledTheme: theme, breakpoints: Object.fromEntries(this.getThemeKey('--breakpoint', true, designSystem).entries()), compiledPreflight: this.processCSS(preflight), compiledProperties: this.processCSS(properties, {clean: false}), compiledCompat: this.processCSS(compat), }, }) triggerCompiledEvent() if(failed) { dispatch(noticesStore).createInfoNotice(__('Successfully recompiled Tailwind.', 'ska-blocks'), {id: RECOMPILE_NOTICE_ID, type: 'snackbar'}) failed = false } } } let compileTimeout = 0 let compiledEdits: any = false let currentCompileToken = 0 subscribe(() => { if(this.t === -1) { return } const edits = select(coreDataStore).getEntityRecordEdits('root', 'site', undefined!) if(!edits || !('ska_blocks_tailwind4' in edits)) { return } const { ska_blocks_tailwind4, } = edits if(ska_blocks_tailwind4 === null) { return // Settings are being reset } if(!compiledEdits) { compiledEdits = ska_blocks_tailwind4 } else if(!RECOMPILE_KEYS.filter(key => ska_blocks_tailwind4[key] !== compiledEdits[key]).length) { return } debugLog('Re-compiling Tailwind due to changes to:', RECOMPILE_KEYS.filter(key => ska_blocks_tailwind4[key] !== compiledEdits[key])) dispatch(noticesStore).removeNotice(RECOMPILE_NOTICE_ID) clearTimeout(compileTimeout) const compileToken = ++currentCompileToken compiledEdits = ska_blocks_tailwind4 compileTimeout = setTimeout(async () => { const commit = await recompile() if(compileToken === currentCompileToken) { commit() } }, RECOMPILE_DEBOUNCE) }, coreDataStore.name) } }