tjs-lang 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CLAUDE.md +12 -4
  2. package/bin/docs.js +3 -2
  3. package/demo/docs.json +2 -2
  4. package/dist/experiments/ambient/probe.d.ts +52 -0
  5. package/dist/index.js +115 -171
  6. package/dist/index.js.map +4 -4
  7. package/dist/scripts/build-editors.d.ts +19 -0
  8. package/dist/src/css/dimensions.d.ts +23 -0
  9. package/dist/src/css/index.d.ts +85 -0
  10. package/dist/src/css/predicates.d.ts +38 -0
  11. package/dist/src/css/shorthands.d.ts +31 -0
  12. package/dist/src/css/style.d.ts +48 -0
  13. package/dist/src/lang/emitters/js.d.ts +9 -1
  14. package/dist/src/lang/index.d.ts +2 -2
  15. package/dist/src/lang/parser-transforms.d.ts +9 -5
  16. package/dist/src/lang/parser.d.ts +2 -0
  17. package/dist/src/lang/predicate.d.ts +60 -0
  18. package/dist/src/lang/transpiler.d.ts +3 -2
  19. package/dist/src/lang/types.d.ts +16 -0
  20. package/dist/src/schema/index.d.ts +12 -0
  21. package/dist/tjs-browser-from-ts.js +20 -20
  22. package/dist/tjs-browser-from-ts.js.map +3 -3
  23. package/dist/tjs-browser.js +100 -90
  24. package/dist/tjs-browser.js.map +4 -4
  25. package/dist/tjs-css.js +193 -0
  26. package/dist/tjs-css.js.map +7 -0
  27. package/dist/tjs-eval.js +43 -42
  28. package/dist/tjs-eval.js.map +4 -4
  29. package/dist/tjs-from-ts.js +13 -13
  30. package/dist/tjs-from-ts.js.map +2 -2
  31. package/dist/tjs-lang.js +102 -92
  32. package/dist/tjs-lang.js.map +4 -4
  33. package/dist/tjs-runtime.js +10 -0
  34. package/dist/tjs-runtime.js.map +7 -0
  35. package/dist/tjs-schema.js +6 -0
  36. package/dist/tjs-schema.js.map +7 -0
  37. package/dist/tjs-vm.js +55 -54
  38. package/dist/tjs-vm.js.map +4 -4
  39. package/docs/ambient-contracts.md +261 -0
  40. package/editors/ace/ajs-mode.js +214 -233
  41. package/editors/codemirror/ajs-language.js +1570 -233
  42. package/editors/editors-build.test.ts +29 -0
  43. package/editors/monaco/ajs-monarch.js +239 -195
  44. package/llms.txt +4 -0
  45. package/package.json +35 -4
  46. package/src/css/css.test.ts +122 -0
  47. package/src/css/dimensions.test.ts +112 -0
  48. package/src/css/dimensions.ts +146 -0
  49. package/src/css/index.ts +243 -0
  50. package/src/css/perf.bench.test.ts +109 -0
  51. package/src/css/predicates.ts +232 -0
  52. package/src/css/property-aware.test.ts +84 -0
  53. package/src/css/shorthands.test.ts +90 -0
  54. package/src/css/shorthands.ts +113 -0
  55. package/src/css/style.test.ts +125 -0
  56. package/src/css/style.ts +134 -0
  57. package/src/index-tsfree.test.ts +58 -0
  58. package/src/lang/bare-assignments.test.ts +48 -0
  59. package/src/lang/doc-comment-position.test.ts +32 -0
  60. package/src/lang/docs.ts +6 -2
  61. package/src/lang/emit-verified-predicate.test.ts +95 -0
  62. package/src/lang/emitters/dts.test.ts +31 -0
  63. package/src/lang/emitters/dts.ts +23 -4
  64. package/src/lang/emitters/from-ts.ts +4 -1
  65. package/src/lang/emitters/js.ts +33 -1
  66. package/src/lang/from-ts.test.ts +1 -1
  67. package/src/lang/generic-verified-predicate.test.ts +39 -0
  68. package/src/lang/index.ts +14 -5
  69. package/src/lang/parser-transforms.ts +120 -18
  70. package/src/lang/parser.ts +22 -6
  71. package/src/lang/predicate-evaluator.test.ts +49 -0
  72. package/src/lang/predicate-report.test.ts +54 -0
  73. package/src/lang/predicate.ts +268 -0
  74. package/src/lang/redos-lint.test.ts +81 -0
  75. package/src/lang/transpiler.ts +13 -0
  76. package/src/lang/type-verified-predicate.test.ts +83 -0
  77. package/src/lang/types.ts +17 -0
  78. package/src/lang/typescript-syntax.test.ts +2 -1
  79. package/src/schema/index.ts +62 -0
  80. package/src/schema/schema.test.ts +66 -0
  81. package/src/use-cases/bootstrap.test.ts +14 -4
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `tjs-lang/css` phase 5 — the "safe is fast" data point.
3
+ *
4
+ * Validates a theme-sized style object (à la a real design-system theme: a
5
+ * `:root` variable block plus dozens of component rules with hover/focus states
6
+ * and a media query) with the COMPLETE predicate set, and times it. Confirms the
7
+ * PoC ballpark (~0.1ms/theme) now that colors + dimensions + shorthands +
8
+ * recursive structure are all real.
9
+ *
10
+ * Gated by SKIP_BENCHMARKS (so `test:fast` skips it). Timing assertions are a
11
+ * loose catastrophic-regression ceiling only — the printed numbers are the point;
12
+ * hard thresholds are flaky under load (see vector-search.bench).
13
+ */
14
+ import { describe, it, expect } from 'bun:test'
15
+ import {
16
+ isStyleObject,
17
+ isColor,
18
+ isColorValue,
19
+ isDimension,
20
+ isAnimation,
21
+ } from './index'
22
+
23
+ /** A deterministic, realistic theme-sized style object. */
24
+ function makeTheme(components: number): Record<string, any> {
25
+ const palette = [
26
+ '#0b5fff',
27
+ 'rgb(16, 185, 129)',
28
+ 'hsl(280 60% 55%)',
29
+ 'oklch(0.7 0.15 200)',
30
+ 'rebeccapurple',
31
+ 'var(--brand)',
32
+ ]
33
+ const lengths = ['0', '4px', '0.5rem', '1rem', '2rem', 'calc(100% - 1rem)']
34
+ const theme: Record<string, any> = {
35
+ ':root': {
36
+ '--brand': '#0b5fff',
37
+ '--surface': 'hsl(220 20% 98%)',
38
+ '--text': 'rgb(17, 24, 39)',
39
+ '--radius': '8px',
40
+ '--gap': '1rem',
41
+ '--shadow': '0 1px 3px rgba(0,0,0,0.2)',
42
+ '--font': 'system-ui, sans-serif',
43
+ },
44
+ }
45
+ for (let i = 0; i < components; i++) {
46
+ theme[`.c${i}`] = {
47
+ color: palette[i % palette.length],
48
+ backgroundColor: palette[(i + 1) % palette.length],
49
+ padding: lengths[i % lengths.length],
50
+ margin: lengths[(i + 2) % lengths.length],
51
+ borderRadius: 'var(--radius)',
52
+ boxShadow: 'var(--shadow)',
53
+ transition: 'color 200ms ease, background 0.3s ease-in-out',
54
+ animation: 'spin 1s cubic-bezier(0.1, 0.7, 1, 0.1) infinite',
55
+ fontSize: '1rem',
56
+ lineHeight: '1.5',
57
+ '&:hover': {
58
+ color: palette[(i + 3) % palette.length],
59
+ transform: 'scale(1.02)',
60
+ },
61
+ '&:focus-visible': { outline: '2px solid var(--brand)' },
62
+ '@media (min-width: 640px)': { padding: '2rem' },
63
+ }
64
+ }
65
+ return theme
66
+ }
67
+
68
+ const bench = (label: string, iters: number, fn: () => void): number => {
69
+ for (let i = 0; i < Math.min(iters, 1000); i++) fn() // warm up / JIT
70
+ const t0 = performance.now()
71
+ for (let i = 0; i < iters; i++) fn()
72
+ const perOp = (performance.now() - t0) / iters
73
+ console.log(` ${label.padEnd(34)} ${(perOp * 1000).toFixed(2)} µs/op`)
74
+ return perOp
75
+ }
76
+
77
+ describe.skipIf(!!process.env.SKIP_BENCHMARKS)(
78
+ 'CSS validation perf (safe is fast)',
79
+ () => {
80
+ it('validates a theme-sized style object well under a frame', () => {
81
+ const theme = makeTheme(50) // ~50 rules × ~12 leaves ≈ 600 values
82
+ const leafCount = Object.keys(theme).length
83
+
84
+ // Correctness first — the validator must actually accept the theme…
85
+ expect(isStyleObject(theme)).toBe(true)
86
+ // …and reject a corrupted one (a selector whose value isn't a nested rule).
87
+ expect(isStyleObject({ ...theme, '.bad': 'not-an-object' })).toBe(false)
88
+
89
+ console.log(`\n CSS validation (${leafCount} top-level rules):`)
90
+ const themeMs = bench('validate whole theme', 2000, () =>
91
+ isStyleObject(theme)
92
+ )
93
+ bench('isColor (per value)', 200_000, () => isColor('rgb(16, 185, 129)'))
94
+ bench('isColorValue (+!important)', 200_000, () =>
95
+ isColorValue('#3a3 !important')
96
+ )
97
+ bench('isDimension (per value)', 200_000, () => isDimension('1.5rem'))
98
+ bench('isAnimation (tokenize+classify)', 100_000, () =>
99
+ isAnimation('spin 1s cubic-bezier(0.1, 0.7, 1, 0.1) infinite')
100
+ )
101
+ // themeMs is milliseconds per theme → themes/sec = 1000 / themeMs.
102
+ console.log(` → ~${Math.round(1000 / themeMs)} themes/sec\n`)
103
+
104
+ // Loose ceiling: a whole theme must validate far under one 16ms frame.
105
+ // (Generous 8ms tolerates CI/load; observed is a fraction of a ms.)
106
+ expect(themeMs).toBeLessThan(8)
107
+ })
108
+ }
109
+ )
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The CSS predicate library — canonical **source** form.
3
+ *
4
+ * Predicates are authored as source strings, not native functions, because the
5
+ * source is the serializable, portable artifact: it feeds the verifier
6
+ * (`verifyPredicate` — certifies pure/synchronous/ReDoS-clean), compiles to the
7
+ * native fast path (`compilePredicate`), mines autocomplete (`suggest`), and
8
+ * embeds into JSON Schema's `$predicate` keyword. This is the "computational
9
+ * half" JSON Schema and TS structural types can't express — CSS is the torture
10
+ * test (open value grammars, order-flexible shorthands, recursive structure).
11
+ *
12
+ * Style rules for anything added here (so it stays verifiable):
13
+ * - pure & synchronous: no IO, no `await`, no `new`, no loops (use
14
+ * `every`/`some`/`map`/recursion — fuel bounds them at function entry);
15
+ * - regexes must be ReDoS-clean (no nested unbounded quantifiers — the
16
+ * verifier rejects `(a+)+` and friends, so keep character classes flat);
17
+ * - compose with `||` and named predicates as callbacks (`toks.every(isX)`).
18
+ *
19
+ * Phase 1 ships the color grammar. Later phases add lengths/dimensions,
20
+ * shorthands, and the recursive style-object structure — see TODO.md (#4).
21
+ */
22
+
23
+ /**
24
+ * The CSS Color Module Level 4 named colors (+ the CSS-wide `transparent` and
25
+ * `currentcolor`), lower-cased. Data, kept in the source so the whole cluster
26
+ * stays serializable as one artifact.
27
+ */
28
+ export const CSS_NAMED_COLORS = [
29
+ 'transparent',
30
+ 'currentcolor',
31
+ 'aliceblue',
32
+ 'antiquewhite',
33
+ 'aqua',
34
+ 'aquamarine',
35
+ 'azure',
36
+ 'beige',
37
+ 'bisque',
38
+ 'black',
39
+ 'blanchedalmond',
40
+ 'blue',
41
+ 'blueviolet',
42
+ 'brown',
43
+ 'burlywood',
44
+ 'cadetblue',
45
+ 'chartreuse',
46
+ 'chocolate',
47
+ 'coral',
48
+ 'cornflowerblue',
49
+ 'cornsilk',
50
+ 'crimson',
51
+ 'cyan',
52
+ 'darkblue',
53
+ 'darkcyan',
54
+ 'darkgoldenrod',
55
+ 'darkgray',
56
+ 'darkgreen',
57
+ 'darkgrey',
58
+ 'darkkhaki',
59
+ 'darkmagenta',
60
+ 'darkolivegreen',
61
+ 'darkorange',
62
+ 'darkorchid',
63
+ 'darkred',
64
+ 'darksalmon',
65
+ 'darkseagreen',
66
+ 'darkslateblue',
67
+ 'darkslategray',
68
+ 'darkslategrey',
69
+ 'darkturquoise',
70
+ 'darkviolet',
71
+ 'deeppink',
72
+ 'deepskyblue',
73
+ 'dimgray',
74
+ 'dimgrey',
75
+ 'dodgerblue',
76
+ 'firebrick',
77
+ 'floralwhite',
78
+ 'forestgreen',
79
+ 'fuchsia',
80
+ 'gainsboro',
81
+ 'ghostwhite',
82
+ 'gold',
83
+ 'goldenrod',
84
+ 'gray',
85
+ 'green',
86
+ 'greenyellow',
87
+ 'grey',
88
+ 'honeydew',
89
+ 'hotpink',
90
+ 'indianred',
91
+ 'indigo',
92
+ 'ivory',
93
+ 'khaki',
94
+ 'lavender',
95
+ 'lavenderblush',
96
+ 'lawngreen',
97
+ 'lemonchiffon',
98
+ 'lightblue',
99
+ 'lightcoral',
100
+ 'lightcyan',
101
+ 'lightgoldenrodyellow',
102
+ 'lightgray',
103
+ 'lightgreen',
104
+ 'lightgrey',
105
+ 'lightpink',
106
+ 'lightsalmon',
107
+ 'lightseagreen',
108
+ 'lightskyblue',
109
+ 'lightslategray',
110
+ 'lightslategrey',
111
+ 'lightsteelblue',
112
+ 'lightyellow',
113
+ 'lime',
114
+ 'limegreen',
115
+ 'linen',
116
+ 'magenta',
117
+ 'maroon',
118
+ 'mediumaquamarine',
119
+ 'mediumblue',
120
+ 'mediumorchid',
121
+ 'mediumpurple',
122
+ 'mediumseagreen',
123
+ 'mediumslateblue',
124
+ 'mediumspringgreen',
125
+ 'mediumturquoise',
126
+ 'mediumvioletred',
127
+ 'midnightblue',
128
+ 'mintcream',
129
+ 'mistyrose',
130
+ 'moccasin',
131
+ 'navajowhite',
132
+ 'navy',
133
+ 'oldlace',
134
+ 'olive',
135
+ 'olivedrab',
136
+ 'orange',
137
+ 'orangered',
138
+ 'orchid',
139
+ 'palegoldenrod',
140
+ 'palegreen',
141
+ 'paleturquoise',
142
+ 'palevioletred',
143
+ 'papayawhip',
144
+ 'peachpuff',
145
+ 'peru',
146
+ 'pink',
147
+ 'plum',
148
+ 'powderblue',
149
+ 'purple',
150
+ 'rebeccapurple',
151
+ 'red',
152
+ 'rosybrown',
153
+ 'royalblue',
154
+ 'saddlebrown',
155
+ 'salmon',
156
+ 'sandybrown',
157
+ 'seagreen',
158
+ 'seashell',
159
+ 'sienna',
160
+ 'silver',
161
+ 'skyblue',
162
+ 'slateblue',
163
+ 'slategray',
164
+ 'slategrey',
165
+ 'snow',
166
+ 'springgreen',
167
+ 'steelblue',
168
+ 'tan',
169
+ 'teal',
170
+ 'thistle',
171
+ 'tomato',
172
+ 'turquoise',
173
+ 'violet',
174
+ 'wheat',
175
+ 'white',
176
+ 'whitesmoke',
177
+ 'yellow',
178
+ 'yellowgreen',
179
+ ]
180
+
181
+ /**
182
+ * Color predicate cluster (source). Entry points: `isColor` (any CSS color) and
183
+ * `isColorValue` (a color, tolerating a trailing `!important`). Regexes are
184
+ * flat-class (ReDoS-clean). The functional-color forms (`hwb`/`lab`/`lch`/
185
+ * `oklab`/`oklch`/`color`/`color-mix`) are matched by name + balanced parens
186
+ * rather than validating their args — precise enough for validation, open
187
+ * enough not to reject valid modern CSS.
188
+ */
189
+ export const CSS_COLOR_SOURCE = `
190
+ var CSS_NAMED_COLORS = ${JSON.stringify(CSS_NAMED_COLORS)}
191
+
192
+ function isNamedColor(v) {
193
+ return typeof v === 'string' && CSS_NAMED_COLORS.includes(v.toLowerCase())
194
+ }
195
+ function isHexColor(v) {
196
+ return typeof v === 'string' && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v)
197
+ }
198
+ function isRgbColor(v) {
199
+ return typeof v === 'string' && /^rgba?\\(\\s*[-0-9.,%\\s/]+\\)$/i.test(v)
200
+ }
201
+ function isHslColor(v) {
202
+ return typeof v === 'string' && /^hsla?\\(\\s*[-0-9.,%\\s/adegr]+\\)$/i.test(v)
203
+ }
204
+ function isColorFn(v) {
205
+ if (typeof v !== 'string' || !v.endsWith(')')) { return false }
206
+ return v.startsWith('hwb(') || v.startsWith('lab(') || v.startsWith('lch(')
207
+ || v.startsWith('oklab(') || v.startsWith('oklch(')
208
+ || v.startsWith('color(') || v.startsWith('color-mix(')
209
+ }
210
+ function isColorVar(v) {
211
+ return typeof v === 'string' && v.startsWith('var(--') && v.endsWith(')')
212
+ }
213
+ function isColor(v) {
214
+ return isNamedColor(v) || isHexColor(v) || isRgbColor(v)
215
+ || isHslColor(v) || isColorFn(v) || isColorVar(v)
216
+ }
217
+ function isColorValue(v) {
218
+ if (typeof v !== 'string') { return false }
219
+ var bare = v.replace(/\\s*!important\\s*$/i, '')
220
+ return isColor(bare)
221
+ }
222
+ `
223
+
224
+ /** Entry predicates exported by the color cluster (for compile/suggest). */
225
+ export const CSS_COLOR_ENTRIES = [
226
+ 'isColor',
227
+ 'isColorValue',
228
+ 'isNamedColor',
229
+ 'isHexColor',
230
+ 'isRgbColor',
231
+ 'isHslColor',
232
+ ] as const
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Property-aware validation — `isStyleValueFor(prop, val)` tightens the closed
3
+ * value grammars (color/animation/transition) so `isStyleObject` catches real
4
+ * value errors (`color: 'notacolor'`), while keyword-heavy properties stay
5
+ * permissive (no false rejections). This is the "validates real style objects"
6
+ * payoff over structure-only checking.
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { isStyleValueFor, isStyleObject } from './index'
10
+
11
+ describe('isStyleValueFor — closed grammars are enforced', () => {
12
+ it('color: precise (rejects a non-color)', () => {
13
+ expect(isStyleValueFor('color', 'red')).toBe(true)
14
+ expect(isStyleValueFor('color', '#3a3')).toBe(true)
15
+ expect(isStyleValueFor('color', 'notacolor')).toBe(false)
16
+ expect(isStyleValueFor('color', 5)).toBe(false)
17
+ })
18
+ it('color: kebab-case and camelCase both normalize', () => {
19
+ expect(isStyleValueFor('background-color', 'blue')).toBe(true)
20
+ expect(isStyleValueFor('backgroundColor', 'blue')).toBe(true)
21
+ expect(isStyleValueFor('borderTopColor', 'nope')).toBe(false)
22
+ })
23
+ it('animation / transition: precise', () => {
24
+ expect(isStyleValueFor('animation', 'spin 1s ease infinite')).toBe(true)
25
+ expect(isStyleValueFor('animation', 'spin 1s @@@')).toBe(false)
26
+ expect(isStyleValueFor('transition', 'all 0.3s ease')).toBe(true)
27
+ expect(isStyleValueFor('transition', 'color 200ms @@@')).toBe(false)
28
+ })
29
+ })
30
+
31
+ describe('isStyleValueFor — universal escapes valid on any property', () => {
32
+ for (const v of [
33
+ 'inherit',
34
+ 'initial',
35
+ 'unset',
36
+ 'var(--x)',
37
+ 'calc(1px + 2px)',
38
+ ]) {
39
+ it(`color accepts ${v}`, () =>
40
+ expect(isStyleValueFor('color', v)).toBe(true))
41
+ }
42
+ })
43
+
44
+ describe('isStyleValueFor — keyword-heavy properties stay permissive', () => {
45
+ it('width/padding/display accept idents we do not enumerate', () => {
46
+ expect(isStyleValueFor('width', 'fit-content')).toBe(true)
47
+ expect(isStyleValueFor('padding', '10px 1rem')).toBe(true)
48
+ expect(isStyleValueFor('display', 'flex')).toBe(true)
49
+ expect(isStyleValueFor('fontWeight', 'bold')).toBe(true)
50
+ expect(isStyleValueFor('zIndex', 10)).toBe(true)
51
+ })
52
+ it('but still rejects empty / non-primitive', () => {
53
+ expect(isStyleValueFor('padding', '')).toBe(false)
54
+ expect(isStyleValueFor('padding', {})).toBe(false)
55
+ })
56
+ })
57
+
58
+ describe('isStyleObject now catches value errors, not just structure', () => {
59
+ it('rejects a bad color where structure is fine', () => {
60
+ expect(isStyleObject({ color: 'red', padding: '10px' })).toBe(true)
61
+ expect(isStyleObject({ color: 'notacolor' })).toBe(false)
62
+ expect(isStyleObject({ backgroundColor: 'bogus' })).toBe(false)
63
+ })
64
+ it('catches a bad color inside a nested selector', () => {
65
+ expect(isStyleObject({ '&:hover': { color: 'nope' } })).toBe(false)
66
+ expect(isStyleObject({ '&:hover': { color: 'blue' } })).toBe(true)
67
+ })
68
+ it('catches a bad animation shorthand', () => {
69
+ expect(isStyleObject({ animation: 'spin 1s @@@' })).toBe(false)
70
+ expect(isStyleObject({ animation: 'spin 1s ease' })).toBe(true)
71
+ })
72
+ it('still accepts a full realistic spec (permissive where it should be)', () => {
73
+ expect(
74
+ isStyleObject({
75
+ color: 'var(--text)',
76
+ padding: '1rem 2rem',
77
+ display: 'grid',
78
+ boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
79
+ transition: 'color 200ms ease',
80
+ '&:hover': { color: '#00f' },
81
+ })
82
+ ).toBe(true)
83
+ })
84
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `tjs-lang/css` phase 3 — order-flexible shorthands (animation, transition).
3
+ * The torture case: tokens in any order. Same vertical slice — the source
4
+ * verifies safe (via `verifyCss`, all clusters) and compiles to correct
5
+ * validators; the `$predicate` schemas validate the shorthand grammar.
6
+ */
7
+ import { describe, it, expect } from 'bun:test'
8
+ import { validatePredicateSchema } from '../lang/predicate-schema'
9
+ import {
10
+ isAnimation,
11
+ isTransition,
12
+ isTimingFunction,
13
+ cssAnimationSchema,
14
+ cssTransitionSchema,
15
+ verifyCss,
16
+ } from './index'
17
+
18
+ describe('all clusters (incl. shorthands) are predicate-safe', () => {
19
+ it('verifyCss covers the shorthand cluster', () => {
20
+ const r = verifyCss()
21
+ if (!r.safe) console.error(r.diagnostics)
22
+ expect(r.safe).toBe(true)
23
+ })
24
+ })
25
+
26
+ describe('isAnimation — order-free tokens', () => {
27
+ const valid = [
28
+ '3s ease-in 1s infinite alternate slidein', // the PoC example
29
+ 'slidein 3s ease-in', // reordered
30
+ 'infinite alternate 3s slidein', // heavily reordered
31
+ '1s linear',
32
+ '200ms',
33
+ 'spin 1s cubic-bezier(0.1, 0.7, 1, 0.1) infinite',
34
+ 'spin 1s steps(4, end)',
35
+ 'var(--motion)',
36
+ 'slide 2s, fade 1s ease-out', // comma-separated list
37
+ ]
38
+ for (const v of valid)
39
+ it(`accepts ${v}`, () => expect(isAnimation(v)).toBe(true))
40
+
41
+ const invalid = [
42
+ '3s @@@', // junk token
43
+ '', // empty
44
+ 'slide 2s,', // trailing empty layer
45
+ 42,
46
+ null,
47
+ ]
48
+ for (const v of invalid)
49
+ it(`rejects ${JSON.stringify(v)}`, () => expect(isAnimation(v)).toBe(false))
50
+ })
51
+
52
+ describe('isTransition', () => {
53
+ it('accepts', () => {
54
+ expect(isTransition('all 0.3s ease')).toBe(true)
55
+ expect(isTransition('color 200ms linear, background 1s')).toBe(true)
56
+ expect(isTransition('opacity 0.2s ease-in-out 0.1s')).toBe(true)
57
+ expect(isTransition('none')).toBe(true)
58
+ })
59
+ it('rejects', () => {
60
+ expect(isTransition('color 200ms @@@')).toBe(false)
61
+ expect(isTransition('')).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('isTimingFunction', () => {
66
+ it('keywords + functions', () => {
67
+ for (const t of ['linear', 'ease-in-out', 'step-start'])
68
+ expect(isTimingFunction(t)).toBe(true)
69
+ expect(isTimingFunction('cubic-bezier(0.1, 0.7, 1, 0.1)')).toBe(true)
70
+ expect(isTimingFunction('steps(4, end)')).toBe(true)
71
+ expect(isTimingFunction('linear(0, 0.5, 1)')).toBe(true)
72
+ expect(isTimingFunction('wobble')).toBe(false)
73
+ })
74
+ })
75
+
76
+ describe('$predicate schemas', () => {
77
+ it('cssAnimationSchema validates via the entry alias', () => {
78
+ expect(
79
+ validatePredicateSchema(cssAnimationSchema(), '1s ease infinite').valid
80
+ ).toBe(true)
81
+ expect(validatePredicateSchema(cssAnimationSchema(), '1s @@@').valid).toBe(
82
+ false
83
+ )
84
+ })
85
+ it('cssTransitionSchema', () => {
86
+ expect(
87
+ validatePredicateSchema(cssTransitionSchema(), 'all 0.3s ease').valid
88
+ ).toBe(true)
89
+ })
90
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * CSS order-flexible shorthands (phase 3) — the second torture case from the
3
+ * PoC: a shorthand like `animation` or `transition` is a comma-separated list of
4
+ * layers, each a whitespace-separated bag of tokens whose *order is free*
5
+ * (`3s ease-in 1s infinite alternate slidein` ≡ any permutation). TS unions and
6
+ * JSON Schema can't express "a set of tokens, each classifiable, in any order";
7
+ * a predicate tokenizes and classifies (`toks.every(isAnimToken)`).
8
+ *
9
+ * Built on the dimension leaf cluster (for `isTime`/`isCssVar`/`isCssCalc`).
10
+ * Same rules as the other clusters: pure, synchronous, ReDoS-clean (the split
11
+ * regexes are single-quantifier `\s+` / `,`; classifier regexes are flat).
12
+ */
13
+ import type { PredicateSchema } from '../lang/predicate-schema'
14
+ import { CSS_DIMENSION_SOURCE } from './dimensions'
15
+
16
+ /**
17
+ * The shorthand classifiers alone (no leaf cluster prepended). Exported so the
18
+ * combined style cluster (`style.ts`) can compose them for property-aware
19
+ * validation without re-including the dimension source.
20
+ */
21
+ export const CSS_SHORTHAND_FRAGMENT = `
22
+ var ANIM_TIMING = ['linear','ease','ease-in','ease-out','ease-in-out','step-start','step-end']
23
+ var ANIM_DIRECTION = ['normal','reverse','alternate','alternate-reverse']
24
+ var ANIM_FILLMODE = ['none','forwards','backwards','both']
25
+ var ANIM_PLAYSTATE = ['running','paused']
26
+
27
+ function isTimingFunction(t) {
28
+ if (typeof t !== 'string') { return false }
29
+ if (ANIM_TIMING.includes(t)) { return true }
30
+ return (t.startsWith('cubic-bezier(') || t.startsWith('steps(') || t.startsWith('linear(')) && t.endsWith(')')
31
+ }
32
+ function isIterationCount(t) {
33
+ return t === 'infinite' || (typeof t === 'string' && /^[0-9]*\\.?[0-9]+$/.test(t))
34
+ }
35
+ function isAnimName(t) {
36
+ // any custom-ident (also matches keywords, which classify earlier anyway)
37
+ return typeof t === 'string' && /^-?[a-z_][a-z0-9_-]*$/i.test(t)
38
+ }
39
+ // Tokenize a whole shorthand value. A function token like cubic-bezier(a, b) is
40
+ // kept whole (arg spaces/commas must not split it); top-level commas (layer
41
+ // separators) and whitespace are delimiters, so they're dropped. Loops are
42
+ // banned in predicates, so this is one .match with a FLAT (ReDoS-clean) regex —
43
+ // note a paren-aware comma *splitter* would be a nested quantifier the verifier
44
+ // rejects, so we tokenize instead and guard empty layers separately.
45
+ function cssTokens(v) {
46
+ return typeof v === 'string' ? v.match(/[a-z-]+\\([^)]*\\)|[^\\s,]+/gi) : null
47
+ }
48
+ // Reject leading/trailing/double commas (empty layers). Flat, ReDoS-clean.
49
+ function hasEmptyLayer(v) {
50
+ return typeof v === 'string' && /^\\s*,|,\\s*$|,\\s*,/.test(v)
51
+ }
52
+ function isAnimToken(t) {
53
+ return isTime(t) || isIterationCount(t) || isTimingFunction(t)
54
+ || ANIM_DIRECTION.includes(t) || ANIM_FILLMODE.includes(t) || ANIM_PLAYSTATE.includes(t)
55
+ || isCssVar(t) || isCssCalc(t) || isAnimName(t)
56
+ }
57
+ function isAnimation(v) {
58
+ if (typeof v !== 'string' || v.length === 0 || hasEmptyLayer(v)) { return false }
59
+ var toks = cssTokens(v)
60
+ return toks !== null && toks.length > 0 && toks.every(isAnimToken)
61
+ }
62
+
63
+ function isTransitionProperty(t) {
64
+ return t === 'all' || t === 'none' || isAnimName(t)
65
+ }
66
+ function isTransitionToken(t) {
67
+ return isTime(t) || isTimingFunction(t) || isCssVar(t) || isCssCalc(t) || isTransitionProperty(t)
68
+ }
69
+ function isTransition(v) {
70
+ if (typeof v !== 'string' || v.length === 0 || hasEmptyLayer(v)) { return false }
71
+ var toks = cssTokens(v)
72
+ return toks !== null && toks.length > 0 && toks.every(isTransitionToken)
73
+ }
74
+ `
75
+
76
+ /**
77
+ * Shorthand predicate cluster (source) = dimension leaves + the shorthand
78
+ * classifiers. Entry predicates: `isAnimation`, `isTransition` (each accepts a
79
+ * comma-separated list of order-free token layers).
80
+ */
81
+ export const CSS_SHORTHAND_SOURCE =
82
+ CSS_DIMENSION_SOURCE + '\n' + CSS_SHORTHAND_FRAGMENT
83
+
84
+ /** Entry predicates exported by the shorthand cluster (for compile/verify). */
85
+ export const CSS_SHORTHAND_ENTRIES = [
86
+ 'isTimingFunction',
87
+ 'isAnimation',
88
+ 'isTransition',
89
+ ] as const
90
+
91
+ /**
92
+ * A `$predicate` schema for a CSS `animation` shorthand value (entry
93
+ * `isAnimation`). Uses the full cluster source so the entry (last function) is
94
+ * `isTransition` — so pin the entry via a wrapper source ending in `isAnimation`.
95
+ */
96
+ function schemaFor(entry: string): PredicateSchema {
97
+ // The `$predicate` entry is the LAST function; append a thin alias so `entry`
98
+ // is last regardless of its position in the cluster.
99
+ return {
100
+ type: 'string',
101
+ $predicate: `${CSS_SHORTHAND_SOURCE}\nfunction __entry(v) { return ${entry}(v) }`,
102
+ }
103
+ }
104
+
105
+ /** `$predicate` schema for a CSS `animation` shorthand value. */
106
+ export function cssAnimationSchema(): PredicateSchema {
107
+ return { ...schemaFor('isAnimation'), title: 'CSSAnimation' }
108
+ }
109
+
110
+ /** `$predicate` schema for a CSS `transition` shorthand value. */
111
+ export function cssTransitionSchema(): PredicateSchema {
112
+ return { ...schemaFor('isTransition'), title: 'CSSTransition' }
113
+ }