|
| 1 | +package fr.fsh.tokendesigner.scanner |
| 2 | + |
| 3 | +/** |
| 4 | + * Value-shape heuristics that tell a *design-token tree* apart from a plain |
| 5 | + * application/config object. |
| 6 | + * |
| 7 | + * The TS/JS indexer is structural — it walks any `export const X = { … }` and |
| 8 | + * records every `path → value` leaf. That alone cannot distinguish a token |
| 9 | + * dictionary from arbitrary application data: a JSON-Schema definition, an |
| 10 | + * event-name enum (`{ CREATED: 'ENTITY_CREATED' }`) or a Storybook `args` |
| 11 | + * object are all *shaped* like token files. |
| 12 | + * |
| 13 | + * The discriminator used here is the **value**, not the shape: a design token, |
| 14 | + * by definition, holds a *style primitive* — a colour, a dimension, an alias |
| 15 | + * reference, or a bare number (unitless spacing / font-weight / line-height). |
| 16 | + * An object whose values are predominantly arbitrary strings |
| 17 | + * (`'ENTITY_CREATED'`, `'string'`, `'#/$defs/LayoutRow'`) is almost certainly |
| 18 | + * not a token dictionary and is dropped wholesale. |
| 19 | + * |
| 20 | + * Deliberately conservative: an object is kept whenever it carries a real |
| 21 | + * style signal, so false-negatives (dropping a genuine token object) are |
| 22 | + * limited to the rare pure-prose or single-font-family object. |
| 23 | + */ |
| 24 | +object StyleValueHeuristics { |
| 25 | + |
| 26 | + enum class ValueClass { STYLE, NUMERIC, NON_STYLE, EMPTY } |
| 27 | + |
| 28 | + /** Style-Dictionary alias literal: `{primitive.primary.500}`. */ |
| 29 | + private val ALIAS_STYLE_DICT = Regex("^\\{[A-Za-z_][\\w.\\-]*}$") |
| 30 | + |
| 31 | + /** Bare runtime property-access alias: `colors.PRIMARY_500` (≥ 1 dot). */ |
| 32 | + private val RUNTIME_REF = Regex("^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$0-9][\\w$]*)+$") |
| 33 | + |
| 34 | + /** Pure number, optionally signed / decimal: `8`, `-2`, `1.5`, `.25`. */ |
| 35 | + private val PURE_NUMBER = Regex("^-?\\d*\\.?\\d+$") |
| 36 | + |
| 37 | + /** Hex colour `#abc` … `#aabbcc88`, *not* preceded/followed by more hex. */ |
| 38 | + private val HEX_COLOR = Regex("(?<![0-9a-fA-F#])#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])") |
| 39 | + |
| 40 | + /** Colour functions: `rgb(`, `rgba(`, `hsl(`, `hsla(`. */ |
| 41 | + private val COLOR_FN = Regex("(?i)\\b(?:rgba?|hsla?)\\s*\\(") |
| 42 | + |
| 43 | + /** A number immediately followed by a CSS length / time / angle unit, or `%`. */ |
| 44 | + private val DIMENSION = Regex( |
| 45 | + "(?i)(?<![\\w.])\\d*\\.?\\d+" + |
| 46 | + "(?:px|rem|em|vh|vw|vmin|vmax|vb|vi|pt|pc|cm|mm|in|ex|ch|fr|deg|rad|grad|turn|ms|s|dpi|dpcm|dppx)(?![\\w])" + |
| 47 | + "|(?<![\\w.])\\d*\\.?\\d+%" |
| 48 | + ) |
| 49 | + |
| 50 | + /** Style-producing CSS functions (shadows, gradients, calc, transforms…). */ |
| 51 | + private val STYLE_FN = Regex( |
| 52 | + "(?i)\\b(?:var|calc|min|max|clamp|env|url|" + |
| 53 | + "linear-gradient|radial-gradient|conic-gradient|repeating-linear-gradient|" + |
| 54 | + "translate[xyz3d]*|rotate[xyz]?|scale[xyz]?|skew[xy]?|matrix|perspective|cubic-bezier)\\s*\\(" |
| 55 | + ) |
| 56 | + |
| 57 | + /** |
| 58 | + * Single-word values that are legitimate style keywords (font weights, |
| 59 | + * border styles, text transforms, common globals). Kept intentionally |
| 60 | + * narrow — layout words like `center`/`row`/`block` are *excluded* because |
| 61 | + * they show up just as often in application config and would let |
| 62 | + * non-token objects slip through. Their presence lets a numeric typography |
| 63 | + * object (`{ fontSize: 16, fontWeight: 'bold' }`) survive. |
| 64 | + */ |
| 65 | + private val STYLE_KEYWORDS = setOf( |
| 66 | + "bold", "bolder", "lighter", "normal", "italic", "oblique", |
| 67 | + "thin", "light", "regular", "medium", "semibold", "semi-bold", "heavy", "black", |
| 68 | + "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", |
| 69 | + "none", "auto", "transparent", "currentcolor", |
| 70 | + "inherit", "initial", "unset", |
| 71 | + "uppercase", "lowercase", "capitalize", |
| 72 | + "ease", "ease-in", "ease-out", "ease-in-out", "linear", |
| 73 | + ) |
| 74 | + |
| 75 | + /** Classifies a single leaf value (quotes already stripped by the parser). */ |
| 76 | + fun classify(raw: String): ValueClass { |
| 77 | + val v = raw.trim() |
| 78 | + if (v.isEmpty()) return ValueClass.EMPTY |
| 79 | + if (ALIAS_STYLE_DICT.matches(v) || RUNTIME_REF.matches(v)) return ValueClass.STYLE |
| 80 | + if (PURE_NUMBER.matches(v)) return ValueClass.NUMERIC |
| 81 | + if (HEX_COLOR.containsMatchIn(v) || |
| 82 | + COLOR_FN.containsMatchIn(v) || |
| 83 | + DIMENSION.containsMatchIn(v) || |
| 84 | + STYLE_FN.containsMatchIn(v) |
| 85 | + ) { |
| 86 | + return ValueClass.STYLE |
| 87 | + } |
| 88 | + if (v.lowercase() in STYLE_KEYWORDS) return ValueClass.STYLE |
| 89 | + return ValueClass.NON_STYLE |
| 90 | + } |
| 91 | + |
| 92 | + /** |
| 93 | + * Leaf-level gate applied *after* [looksLikeTokenObject] has accepted the |
| 94 | + * surrounding object. A token dictionary can still hold non-style metadata |
| 95 | + * next to its real values — e.g. a status-config map keyed by enum value |
| 96 | + * (`{ PICKING: { label: 'En picking', color: '#4563a0' } }`) carries both |
| 97 | + * a colour (a token) and a human label (not a token). We keep only the |
| 98 | + * leaves whose value is itself a style primitive or a number, dropping the |
| 99 | + * arbitrary-string siblings (`label`, `variant`, …). See issue #24. |
| 100 | + */ |
| 101 | + fun isIndexableLeafValue(raw: String): Boolean = |
| 102 | + when (classify(raw)) { |
| 103 | + ValueClass.STYLE, ValueClass.NUMERIC -> true |
| 104 | + ValueClass.NON_STYLE, ValueClass.EMPTY -> false |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Decides whether the leaf [values] of one exported object look like a |
| 109 | + * design-token dictionary. |
| 110 | + * |
| 111 | + * - Has at least one genuine style value → keep, *provided* style values |
| 112 | + * are not heavily outnumbered by arbitrary strings (`style >= nonStyle`). |
| 113 | + * This keeps colour/dimension/alias objects (with the odd stray label) |
| 114 | + * while rejecting a config object that merely contains one stray colour. |
| 115 | + * - No style value, but purely numeric (no arbitrary strings) → keep: |
| 116 | + * unitless spacing / weight / line-height scales are legitimate. |
| 117 | + * - Otherwise (arbitrary-string-dominated, e.g. an event-name enum or a |
| 118 | + * JSON-Schema body) → drop. |
| 119 | + */ |
| 120 | + fun looksLikeTokenObject(values: List<String>): Boolean { |
| 121 | + var style = 0 |
| 122 | + var numeric = 0 |
| 123 | + var nonStyle = 0 |
| 124 | + for (raw in values) { |
| 125 | + when (classify(raw)) { |
| 126 | + ValueClass.STYLE -> style++ |
| 127 | + ValueClass.NUMERIC -> numeric++ |
| 128 | + ValueClass.NON_STYLE -> nonStyle++ |
| 129 | + ValueClass.EMPTY -> {} |
| 130 | + } |
| 131 | + } |
| 132 | + if (style + numeric + nonStyle == 0) return false |
| 133 | + return if (style > 0) style >= nonStyle else (nonStyle == 0 && numeric > 0) |
| 134 | + } |
| 135 | +} |
0 commit comments