|
| 1 | +import { canonicalJson } from '#design-diff/ast' |
| 2 | +import type { Data, Definition } from '#design-diff/types' |
| 3 | + |
| 4 | +export const appearanceAttributes = |
| 5 | + /^(?:className|.*ClassName|class|style|width|height|minWidth|minHeight|maxWidth|maxHeight|size|rows|cols|color|backgroundColor|opacity|variant|orientation|animate|initial|exit|transition|whileHover|whileTap)$/ |
| 6 | +export const mediaElement = |
| 7 | + /^(?:svg|img|image|picture|video|audio|source|canvas|Image|Video|Icon|.*Icon)$/ |
| 8 | + |
| 9 | +function object(value: Data | undefined): value is Record<string, Data> { |
| 10 | + return !!value && typeof value === 'object' && !Array.isArray(value) |
| 11 | +} |
| 12 | + |
| 13 | +/** Ignore asset identity and generated copy while preserving gradients and other CSS effects. */ |
| 14 | +function cssAppearance(value: string): string { |
| 15 | + return value.replace(/url\([^)]*\)/g, 'url(asset)') |
| 16 | +} |
| 17 | + |
| 18 | +/** Retain supported style values, never arbitrary captured inputs inside opaque expressions. */ |
| 19 | +function literal(value: Data, depth = 0, args?: Data[]): Data | undefined { |
| 20 | + if (depth > 32) return undefined |
| 21 | + if (value === null || typeof value !== 'object') return value |
| 22 | + if (Array.isArray(value)) { |
| 23 | + const values = value.map((item) => literal(item, depth + 1)) |
| 24 | + return values.every((item) => item !== undefined) ? (values as Data[]) : undefined |
| 25 | + } |
| 26 | + if ('$await' in value) return literal(value.$await, depth + 1, args) |
| 27 | + if (Array.isArray(value.$parameter) && args) { |
| 28 | + let selected: Data = args |
| 29 | + for (const key of value.$parameter) { |
| 30 | + if (typeof key !== 'number' && typeof key !== 'string') return undefined |
| 31 | + if (Array.isArray(selected) && typeof key === 'number') selected = selected[key] |
| 32 | + else if (object(selected)) selected = selected[String(key)] |
| 33 | + else return undefined |
| 34 | + if (selected === undefined) return undefined |
| 35 | + } |
| 36 | + return literal(selected, depth + 1) |
| 37 | + } |
| 38 | + if ('$default' in value) { |
| 39 | + const input = literal(value.input, depth + 1, args) |
| 40 | + const fallback = literal(value.$default, depth + 1, args) |
| 41 | + return fallback === undefined ? undefined : { input: input ?? null, fallback } |
| 42 | + } |
| 43 | + if ('$selectedCall' in value && object(value.$selectedCall)) |
| 44 | + return literal( |
| 45 | + value.$selectedCall, |
| 46 | + depth + 1, |
| 47 | + Array.isArray(value.arguments) ? value.arguments : args |
| 48 | + ) |
| 49 | + if (object(value.$call) && Array.isArray(value.$call.$function)) |
| 50 | + return literal(value.$call, depth + 1, Array.isArray(value.arguments) ? value.arguments : args) |
| 51 | + if ('$reactState' in value && Array.isArray(value.updates)) { |
| 52 | + const initial = literal(value.$reactState, depth + 1) |
| 53 | + const updates = value.updates.flatMap((entry) => { |
| 54 | + if (!object(entry)) return [] |
| 55 | + const result = literal(entry.value, depth + 1) |
| 56 | + return result === undefined ? [] : [result] |
| 57 | + }) |
| 58 | + return initial !== undefined || updates.length |
| 59 | + ? { initial: initial ?? null, updates } |
| 60 | + : undefined |
| 61 | + } |
| 62 | + if (Array.isArray(value.$function)) { |
| 63 | + const values = value.$function.map((item) => |
| 64 | + object(item) && 'value' in item ? literal(item.value, depth + 1, args) : undefined |
| 65 | + ) |
| 66 | + return values.length && values.every((item) => item !== undefined) |
| 67 | + ? { returns: values as Data[] } |
| 68 | + : undefined |
| 69 | + } |
| 70 | + if ('$condition' in value) { |
| 71 | + const a = literal(value.then, depth + 1) |
| 72 | + const b = literal(value.else, depth + 1) |
| 73 | + return a !== undefined && b !== undefined ? { branches: [a, b] } : undefined |
| 74 | + } |
| 75 | + if (Object.keys(value).some((key) => key.startsWith('$'))) return undefined |
| 76 | + const result: Record<string, Data> = {} |
| 77 | + for (const [key, item] of Object.entries(value)) { |
| 78 | + const supported = literal(item, depth + 1) |
| 79 | + if (supported === undefined) return undefined |
| 80 | + result[key] = supported |
| 81 | + } |
| 82 | + return result |
| 83 | +} |
| 84 | + |
| 85 | +/** Generated CSS is evidence; helper fingerprints, runtime predicates and diagnostics are not. */ |
| 86 | +function classes(value: Data): Data | undefined { |
| 87 | + if (value === null || value === false) return null |
| 88 | + if (Array.isArray(value)) { |
| 89 | + const items = value.map(classes).filter((item): item is Data => item !== undefined) |
| 90 | + return items.length ? items : undefined |
| 91 | + } |
| 92 | + if (!object(value)) return undefined |
| 93 | + if (Array.isArray(value.css) && Array.isArray(value.order)) { |
| 94 | + const css = value.css |
| 95 | + .filter((item): item is string => typeof item === 'string') |
| 96 | + .map((item) => { |
| 97 | + const declarations = JSON.parse(item) as [string[], string, string, boolean][] |
| 98 | + return JSON.stringify( |
| 99 | + declarations |
| 100 | + .filter(([, property]) => property !== 'content') |
| 101 | + .map(([conditions, property, data, important]) => [ |
| 102 | + conditions.map((condition) => (condition.startsWith('.') ? '.utility' : condition)), |
| 103 | + property, |
| 104 | + cssAppearance(data), |
| 105 | + important, |
| 106 | + ]) |
| 107 | + ) |
| 108 | + }) |
| 109 | + if (!css.length && value.order.length) return undefined |
| 110 | + const variables = Array.isArray(value.variables) |
| 111 | + ? value.variables.map((item) => { |
| 112 | + if (!object(item) || !object(item.value)) return item |
| 113 | + const { order: _order, ...data } = item.value |
| 114 | + return { ...item, value: data } |
| 115 | + }) |
| 116 | + : [] |
| 117 | + return { css, variables } |
| 118 | + } |
| 119 | + if ('$classes' in value) return classes(value.$classes) |
| 120 | + if (Array.isArray(value.$cva)) { |
| 121 | + const [base, options] = value.$cva |
| 122 | + const variants: Record<string, Data> = {} |
| 123 | + if (object(options) && object(options.variants)) |
| 124 | + for (const [name, choices] of Object.entries(options.variants)) { |
| 125 | + const supported: Record<string, Data> = {} |
| 126 | + if (object(choices)) |
| 127 | + for (const [choice, definition] of Object.entries(choices)) { |
| 128 | + const result = classes(definition) |
| 129 | + if (result !== undefined) supported[choice] = result |
| 130 | + } |
| 131 | + variants[name] = supported |
| 132 | + } |
| 133 | + return { |
| 134 | + base: classes(base) ?? null, |
| 135 | + variants, |
| 136 | + defaults: object(options) ? (literal(options.defaultVariants ?? null) ?? null) : null, |
| 137 | + compoundVariants: object(options) |
| 138 | + ? (literal(options.compoundVariants ?? null) ?? null) |
| 139 | + : null, |
| 140 | + } |
| 141 | + } |
| 142 | + if ('$variant' in value) return classes(value.$variant) |
| 143 | + if ('$condition' in value) return classes([value.then ?? null, value.else ?? null]) |
| 144 | + if (value.$operator === '&&') return classes(value.right) |
| 145 | + return undefined |
| 146 | +} |
| 147 | + |
| 148 | +/** The notification policy requires concrete authored appearance, independent of render guards. */ |
| 149 | +export function appearanceValue(definition: Definition): Data | undefined { |
| 150 | + if (definition.appearance?.media) return undefined |
| 151 | + if (definition.kind === 'class') { |
| 152 | + if (!object(definition.value) || !Array.isArray(definition.value.normalized)) return undefined |
| 153 | + const values = definition.value.normalized.flatMap((entry) => { |
| 154 | + if (!object(entry)) return [] |
| 155 | + const value = classes(entry.value) |
| 156 | + return value === undefined ? [] : [value] |
| 157 | + }) |
| 158 | + return values.length ? values : undefined |
| 159 | + } |
| 160 | + if (definition.kind === 'css') { |
| 161 | + if (definition.property.startsWith('@')) return undefined |
| 162 | + if (definition.property === 'content') return undefined |
| 163 | + if (typeof definition.value === 'string') return definition.value |
| 164 | + if (!object(definition.value)) return undefined |
| 165 | + const { order: _order, ...value } = definition.value |
| 166 | + if (typeof value.value === 'string' && /url\(/.test(value.value)) |
| 167 | + value.value = cssAppearance(value.value) |
| 168 | + return { ...value, context: definition.conditions } |
| 169 | + } |
| 170 | + if ( |
| 171 | + !['style', 'native'].includes(definition.kind) && |
| 172 | + !(definition.kind === 'attribute' && appearanceAttributes.test(definition.property)) |
| 173 | + ) |
| 174 | + return undefined |
| 175 | + if (/^(?:icon|setIcon|setImage|trafficLightPosition|setPosition)$/.test(definition.property)) |
| 176 | + return undefined |
| 177 | + if (definition.property === 'content') return undefined |
| 178 | + if (object(definition.value) && Array.isArray(definition.value.normalized)) { |
| 179 | + const source = literal(definition.value.source) |
| 180 | + if (source === undefined) return undefined |
| 181 | + return { source, normalized: canonicalJson(definition.value.normalized) } |
| 182 | + } |
| 183 | + const value = literal(definition.value) |
| 184 | + return typeof value === 'string' ? cssAppearance(value) : value |
| 185 | +} |
| 186 | + |
| 187 | +export function sameAppearance(a: Definition, b: Definition): boolean { |
| 188 | + return JSON.stringify(appearanceValue(a)) === JSON.stringify(appearanceValue(b)) |
| 189 | +} |
0 commit comments