tjs-lang 0.8.7 → 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.
- package/CLAUDE.md +12 -4
- package/dist/experiments/ambient/probe.d.ts +52 -0
- package/dist/index.js +115 -171
- package/dist/index.js.map +4 -4
- package/dist/scripts/build-editors.d.ts +19 -0
- package/dist/src/css/dimensions.d.ts +23 -0
- package/dist/src/css/index.d.ts +85 -0
- package/dist/src/css/predicates.d.ts +38 -0
- package/dist/src/css/shorthands.d.ts +31 -0
- package/dist/src/css/style.d.ts +48 -0
- package/dist/src/lang/emitters/js.d.ts +9 -1
- package/dist/src/lang/index.d.ts +2 -2
- package/dist/src/lang/parser-transforms.d.ts +9 -5
- package/dist/src/lang/parser.d.ts +2 -0
- package/dist/src/lang/predicate.d.ts +60 -0
- package/dist/src/lang/transpiler.d.ts +3 -2
- package/dist/src/lang/types.d.ts +16 -0
- package/dist/src/schema/index.d.ts +12 -0
- package/dist/tjs-browser-from-ts.js +20 -20
- package/dist/tjs-browser-from-ts.js.map +3 -3
- package/dist/tjs-browser.js +100 -90
- package/dist/tjs-browser.js.map +4 -4
- package/dist/tjs-css.js +193 -0
- package/dist/tjs-css.js.map +7 -0
- package/dist/tjs-eval.js +43 -42
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +13 -13
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +102 -92
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-runtime.js +10 -0
- package/dist/tjs-runtime.js.map +7 -0
- package/dist/tjs-schema.js +6 -0
- package/dist/tjs-schema.js.map +7 -0
- package/dist/tjs-vm.js +55 -54
- package/dist/tjs-vm.js.map +4 -4
- package/docs/ambient-contracts.md +261 -0
- package/editors/ace/ajs-mode.js +214 -233
- package/editors/codemirror/ajs-language.js +1570 -233
- package/editors/editors-build.test.ts +29 -0
- package/editors/monaco/ajs-monarch.js +239 -195
- package/llms.txt +4 -0
- package/package.json +35 -4
- package/src/css/css.test.ts +122 -0
- package/src/css/dimensions.test.ts +112 -0
- package/src/css/dimensions.ts +146 -0
- package/src/css/index.ts +243 -0
- package/src/css/perf.bench.test.ts +109 -0
- package/src/css/predicates.ts +232 -0
- package/src/css/property-aware.test.ts +84 -0
- package/src/css/shorthands.test.ts +90 -0
- package/src/css/shorthands.ts +113 -0
- package/src/css/style.test.ts +125 -0
- package/src/css/style.ts +134 -0
- package/src/index-tsfree.test.ts +58 -0
- package/src/lang/emit-verified-predicate.test.ts +95 -0
- package/src/lang/emitters/dts.test.ts +31 -0
- package/src/lang/emitters/dts.ts +23 -4
- package/src/lang/emitters/js.ts +33 -1
- package/src/lang/from-ts.test.ts +1 -1
- package/src/lang/generic-verified-predicate.test.ts +39 -0
- package/src/lang/index.ts +14 -5
- package/src/lang/parser-transforms.ts +109 -14
- package/src/lang/parser.ts +9 -2
- package/src/lang/predicate-evaluator.test.ts +49 -0
- package/src/lang/predicate-report.test.ts +54 -0
- package/src/lang/predicate.ts +268 -0
- package/src/lang/redos-lint.test.ts +81 -0
- package/src/lang/transpiler.ts +13 -0
- package/src/lang/type-verified-predicate.test.ts +83 -0
- package/src/lang/types.ts +17 -0
- package/src/lang/typescript-syntax.test.ts +2 -1
- package/src/schema/index.ts +62 -0
- package/src/schema/schema.test.ts +66 -0
- package/src/use-cases/bootstrap.test.ts +14 -4
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/css` phase 1 — the color grammar. Proves the full vertical slice:
|
|
3
|
+
* the predicate source verifies safe (pure + ReDoS-clean), compiles to correct
|
|
4
|
+
* native validators, and mines valid autocomplete suggestions.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'bun:test'
|
|
7
|
+
import {
|
|
8
|
+
isColor,
|
|
9
|
+
isColorValue,
|
|
10
|
+
isNamedColor,
|
|
11
|
+
isHexColor,
|
|
12
|
+
verifyCss,
|
|
13
|
+
suggestColor,
|
|
14
|
+
CSS_NAMED_COLORS,
|
|
15
|
+
} from './index'
|
|
16
|
+
|
|
17
|
+
describe('CSS color source is predicate-safe', () => {
|
|
18
|
+
it('verifies pure, synchronous, ReDoS-clean', () => {
|
|
19
|
+
const r = verifyCss()
|
|
20
|
+
if (!r.safe) console.error(r.diagnostics)
|
|
21
|
+
expect(r.safe).toBe(true)
|
|
22
|
+
expect(r.diagnostics).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('isColor accepts valid CSS colors', () => {
|
|
27
|
+
const valid = [
|
|
28
|
+
'red',
|
|
29
|
+
'REBECCAPURPLE', // case-insensitive named
|
|
30
|
+
'transparent',
|
|
31
|
+
'currentColor',
|
|
32
|
+
'#f00',
|
|
33
|
+
'#ff0000',
|
|
34
|
+
'#ff0000aa', // 8-digit
|
|
35
|
+
'#abcd', // 4-digit
|
|
36
|
+
'rgb(255, 0, 0)',
|
|
37
|
+
'rgb(255 0 0 / 0.5)', // modern slash syntax
|
|
38
|
+
'rgba(0,0,0,0.2)',
|
|
39
|
+
'hsl(120, 50%, 50%)',
|
|
40
|
+
'hsl(120deg 50% 50% / 0.5)',
|
|
41
|
+
'hwb(194 0% 0%)',
|
|
42
|
+
'oklch(0.7 0.15 200)',
|
|
43
|
+
'lab(50% 40 59.5)',
|
|
44
|
+
'color-mix(in oklch, red, blue)',
|
|
45
|
+
'color(display-p3 1 0.5 0)',
|
|
46
|
+
'var(--brand)',
|
|
47
|
+
]
|
|
48
|
+
for (const c of valid) {
|
|
49
|
+
it(`accepts ${c}`, () => expect(isColor(c)).toBe(true))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('isColor rejects non-colors', () => {
|
|
54
|
+
const invalid = [
|
|
55
|
+
'notacolor',
|
|
56
|
+
'#gg0000', // non-hex digits
|
|
57
|
+
'#12345', // 5 digits (not 3/4/6/8)
|
|
58
|
+
'reddish',
|
|
59
|
+
'',
|
|
60
|
+
'rgb', // no parens
|
|
61
|
+
'linear-gradient(red, blue)', // a gradient, not a color
|
|
62
|
+
42,
|
|
63
|
+
null,
|
|
64
|
+
undefined,
|
|
65
|
+
{},
|
|
66
|
+
]
|
|
67
|
+
for (const c of invalid) {
|
|
68
|
+
it(`rejects ${JSON.stringify(c)}`, () => expect(isColor(c)).toBe(false))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('isColorValue tolerates !important', () => {
|
|
73
|
+
it('accepts a color with !important', () => {
|
|
74
|
+
expect(isColorValue('#3a3 !important')).toBe(true)
|
|
75
|
+
expect(isColorValue('red !important')).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
it('still rejects a non-color with !important', () => {
|
|
78
|
+
expect(isColorValue('notacolor !important')).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
it('isColor (strict) does not accept !important', () => {
|
|
81
|
+
expect(isColor('red !important')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('leaf predicates', () => {
|
|
86
|
+
it('isNamedColor is case-insensitive and complete-ish', () => {
|
|
87
|
+
expect(isNamedColor('RebeccaPurple')).toBe(true)
|
|
88
|
+
expect(isNamedColor('cornflowerblue')).toBe(true)
|
|
89
|
+
expect(isNamedColor('bluish')).toBe(false)
|
|
90
|
+
// sanity: the full named set is present
|
|
91
|
+
expect(CSS_NAMED_COLORS.length).toBeGreaterThan(140)
|
|
92
|
+
})
|
|
93
|
+
it('isHexColor validates digit counts', () => {
|
|
94
|
+
expect(isHexColor('#abc')).toBe(true)
|
|
95
|
+
expect(isHexColor('#abcd')).toBe(true)
|
|
96
|
+
expect(isHexColor('#aabbcc')).toBe(true)
|
|
97
|
+
expect(isHexColor('#aabbccdd')).toBe(true)
|
|
98
|
+
expect(isHexColor('#ab')).toBe(false)
|
|
99
|
+
expect(isHexColor('#abcde')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('suggestColor mines valid completions', () => {
|
|
104
|
+
it('suggests named colors for a prefix, all valid', () => {
|
|
105
|
+
const s = suggestColor('red')
|
|
106
|
+
const values = s.filter((x) => x.kind === 'value').map((x) => x.value)
|
|
107
|
+
expect(values).toContain('red')
|
|
108
|
+
// every suggested value actually passes the predicate (mined + validated)
|
|
109
|
+
for (const v of values) expect(isColor(v)).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
it('offers the open var(-- stub for a relevant prefix', () => {
|
|
112
|
+
// Empty-prefix suggest floods with named colors (correctly); autocomplete is
|
|
113
|
+
// prefix-driven, so the open functional stubs surface once you start typing.
|
|
114
|
+
const s = suggestColor('var')
|
|
115
|
+
const stubs = s.filter((x) => x.kind === 'stub').map((x) => x.value)
|
|
116
|
+
expect(stubs).toContain('var(--')
|
|
117
|
+
})
|
|
118
|
+
it('offers color-mix( for the color- prefix', () => {
|
|
119
|
+
const s = suggestColor('color-')
|
|
120
|
+
expect(s.some((x) => x.value === 'color-mix(')).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/css` phase 2 — dimensions / numbers / angles / times / keywords.
|
|
3
|
+
* Same vertical slice as colors: the source verifies safe (via `verifyCss`,
|
|
4
|
+
* which now covers all clusters) and compiles to correct native validators.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'bun:test'
|
|
7
|
+
import {
|
|
8
|
+
isLength,
|
|
9
|
+
isPercentage,
|
|
10
|
+
isNumber,
|
|
11
|
+
isInteger,
|
|
12
|
+
isAngle,
|
|
13
|
+
isTime,
|
|
14
|
+
isDimension,
|
|
15
|
+
isGlobalKeyword,
|
|
16
|
+
verifyCss,
|
|
17
|
+
} from './index'
|
|
18
|
+
|
|
19
|
+
describe('all CSS clusters are predicate-safe (incl. dimensions)', () => {
|
|
20
|
+
it('verifyCss covers color + dimension, all safe', () => {
|
|
21
|
+
const r = verifyCss()
|
|
22
|
+
if (!r.safe) console.error(r.diagnostics)
|
|
23
|
+
expect(r.safe).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('isLength', () => {
|
|
28
|
+
const valid = [
|
|
29
|
+
'0', // unitless zero
|
|
30
|
+
'10px',
|
|
31
|
+
'1.5rem',
|
|
32
|
+
'-2em',
|
|
33
|
+
'100vh',
|
|
34
|
+
'50vmax',
|
|
35
|
+
'3ch',
|
|
36
|
+
'2.5cqw', // container query unit
|
|
37
|
+
'1dvh', // dynamic viewport
|
|
38
|
+
'10Q',
|
|
39
|
+
'.5in',
|
|
40
|
+
'calc(100% - 10px)',
|
|
41
|
+
'var(--gap)',
|
|
42
|
+
]
|
|
43
|
+
for (const v of valid)
|
|
44
|
+
it(`accepts ${v}`, () => expect(isLength(v)).toBe(true))
|
|
45
|
+
|
|
46
|
+
const invalid = ['10', '10foo', 'px', '10 px', 'red', '', {}, null]
|
|
47
|
+
for (const v of invalid)
|
|
48
|
+
it(`rejects ${JSON.stringify(v)}`, () => expect(isLength(v)).toBe(false))
|
|
49
|
+
|
|
50
|
+
it('accepts numeric 0 (unitless zero) but not other bare numbers', () => {
|
|
51
|
+
expect(isLength(0)).toBe(true)
|
|
52
|
+
expect(isLength(10)).toBe(false) // a bare number isn't a length
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('isPercentage', () => {
|
|
57
|
+
it('accepts', () => {
|
|
58
|
+
expect(isPercentage('50%')).toBe(true)
|
|
59
|
+
expect(isPercentage('-12.5%')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
it('rejects', () => {
|
|
62
|
+
expect(isPercentage('50')).toBe(false)
|
|
63
|
+
expect(isPercentage('50px')).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('isNumber / isInteger', () => {
|
|
68
|
+
it('isNumber accepts numbers and numeric strings', () => {
|
|
69
|
+
expect(isNumber(42)).toBe(true)
|
|
70
|
+
expect(isNumber(3.14)).toBe(true)
|
|
71
|
+
expect(isNumber('42')).toBe(true)
|
|
72
|
+
expect(isNumber('-.5')).toBe(true)
|
|
73
|
+
expect(isNumber('4px')).toBe(false)
|
|
74
|
+
expect(isNumber(Infinity)).toBe(false)
|
|
75
|
+
expect(isNumber(NaN)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
it('isInteger', () => {
|
|
78
|
+
expect(isInteger(5)).toBe(true)
|
|
79
|
+
expect(isInteger('5')).toBe(true)
|
|
80
|
+
expect(isInteger(5.5)).toBe(false)
|
|
81
|
+
expect(isInteger('5.0')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('isAngle / isTime', () => {
|
|
86
|
+
it('angles', () => {
|
|
87
|
+
for (const a of ['45deg', '1turn', '1.5rad', '100grad', '-90deg'])
|
|
88
|
+
expect(isAngle(a)).toBe(true)
|
|
89
|
+
expect(isAngle('45')).toBe(false)
|
|
90
|
+
expect(isAngle('45px')).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
it('times', () => {
|
|
93
|
+
expect(isTime('200ms')).toBe(true)
|
|
94
|
+
expect(isTime('1s')).toBe(true)
|
|
95
|
+
expect(isTime('1.5s')).toBe(true)
|
|
96
|
+
expect(isTime('1')).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('isDimension (any numeric family) + isGlobalKeyword', () => {
|
|
101
|
+
it('isDimension unifies the families', () => {
|
|
102
|
+
for (const v of ['10px', '50%', '45deg', '1s', '42', 'calc(1px + 2px)'])
|
|
103
|
+
expect(isDimension(v)).toBe(true)
|
|
104
|
+
expect(isDimension('red')).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
it('isGlobalKeyword', () => {
|
|
107
|
+
for (const k of ['inherit', 'initial', 'unset', 'revert', 'revert-layer'])
|
|
108
|
+
expect(isGlobalKeyword(k)).toBe(true)
|
|
109
|
+
expect(isGlobalKeyword('INHERIT')).toBe(true) // case-insensitive
|
|
110
|
+
expect(isGlobalKeyword('auto')).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS dimension / numeric value grammar (phase 2) — the other big family of leaf
|
|
3
|
+
* values: lengths, percentages, angles, times, resolutions, plain numbers, and
|
|
4
|
+
* the CSS-wide global keywords. Same rules as the color cluster: pure,
|
|
5
|
+
* synchronous, ReDoS-clean (flat character classes / bounded alternation only),
|
|
6
|
+
* with `var(--…)`/`calc(…)` as the open escapes.
|
|
7
|
+
*
|
|
8
|
+
* The numeric core `[+-]?(\d*\.\d+|\d+)` is a bounded alternation (each branch a
|
|
9
|
+
* single quantifier — no nesting), so it passes the verifier's ReDoS lint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Absolute + relative + container + viewport length units (CSS Values 4). */
|
|
13
|
+
export const CSS_LENGTH_UNITS = [
|
|
14
|
+
// font-relative
|
|
15
|
+
'cap',
|
|
16
|
+
'ch',
|
|
17
|
+
'em',
|
|
18
|
+
'ex',
|
|
19
|
+
'ic',
|
|
20
|
+
'lh',
|
|
21
|
+
'rcap',
|
|
22
|
+
'rch',
|
|
23
|
+
'rem',
|
|
24
|
+
'rex',
|
|
25
|
+
'ric',
|
|
26
|
+
'rlh',
|
|
27
|
+
// viewport
|
|
28
|
+
'vw',
|
|
29
|
+
'vh',
|
|
30
|
+
'vi',
|
|
31
|
+
'vb',
|
|
32
|
+
'vmin',
|
|
33
|
+
'vmax',
|
|
34
|
+
'svw',
|
|
35
|
+
'svh',
|
|
36
|
+
'svi',
|
|
37
|
+
'svb',
|
|
38
|
+
'svmin',
|
|
39
|
+
'svmax',
|
|
40
|
+
'lvw',
|
|
41
|
+
'lvh',
|
|
42
|
+
'lvi',
|
|
43
|
+
'lvb',
|
|
44
|
+
'lvmin',
|
|
45
|
+
'lvmax',
|
|
46
|
+
'dvw',
|
|
47
|
+
'dvh',
|
|
48
|
+
'dvi',
|
|
49
|
+
'dvb',
|
|
50
|
+
'dvmin',
|
|
51
|
+
'dvmax',
|
|
52
|
+
// container query
|
|
53
|
+
'cqw',
|
|
54
|
+
'cqh',
|
|
55
|
+
'cqi',
|
|
56
|
+
'cqb',
|
|
57
|
+
'cqmin',
|
|
58
|
+
'cqmax',
|
|
59
|
+
// absolute
|
|
60
|
+
'px',
|
|
61
|
+
'cm',
|
|
62
|
+
'mm',
|
|
63
|
+
'q',
|
|
64
|
+
'in',
|
|
65
|
+
'pt',
|
|
66
|
+
'pc',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** CSS-wide keywords valid on any property. */
|
|
70
|
+
export const CSS_GLOBAL_KEYWORDS = [
|
|
71
|
+
'inherit',
|
|
72
|
+
'initial',
|
|
73
|
+
'unset',
|
|
74
|
+
'revert',
|
|
75
|
+
'revert-layer',
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Dimension predicate cluster (source). Entry points cover each numeric family
|
|
80
|
+
* plus `isDimension` (any of them) and `isGlobalKeyword`. Units are ordered
|
|
81
|
+
* longest-first inside the alternation so e.g. `rem` isn't shadowed by `em`
|
|
82
|
+
* (belt-and-suspenders — the `$` anchor already forces a full match).
|
|
83
|
+
*/
|
|
84
|
+
export const CSS_DIMENSION_SOURCE = `
|
|
85
|
+
var CSS_LENGTH_UNITS = ${JSON.stringify(
|
|
86
|
+
// longest-first for safe alternation ordering
|
|
87
|
+
[...CSS_LENGTH_UNITS].sort((a, b) => b.length - a.length)
|
|
88
|
+
)}
|
|
89
|
+
var CSS_GLOBAL_KEYWORDS = ${JSON.stringify(CSS_GLOBAL_KEYWORDS)}
|
|
90
|
+
|
|
91
|
+
function isCssVar(v) {
|
|
92
|
+
return typeof v === 'string' && v.startsWith('var(--') && v.endsWith(')')
|
|
93
|
+
}
|
|
94
|
+
function isCssCalc(v) {
|
|
95
|
+
return typeof v === 'string' && v.startsWith('calc(') && v.endsWith(')')
|
|
96
|
+
}
|
|
97
|
+
function isNumber(v) {
|
|
98
|
+
if (typeof v === 'number') { return isFinite(v) }
|
|
99
|
+
return typeof v === 'string' && /^[+-]?(\\d*\\.\\d+|\\d+)$/.test(v)
|
|
100
|
+
}
|
|
101
|
+
function isInteger(v) {
|
|
102
|
+
if (typeof v === 'number') { return Number.isInteger(v) }
|
|
103
|
+
return typeof v === 'string' && /^[+-]?\\d+$/.test(v)
|
|
104
|
+
}
|
|
105
|
+
function isPercentage(v) {
|
|
106
|
+
return typeof v === 'string' && /^[+-]?(\\d*\\.\\d+|\\d+)%$/.test(v)
|
|
107
|
+
}
|
|
108
|
+
function isLength(v) {
|
|
109
|
+
if (v === 0 || v === '0') { return true }
|
|
110
|
+
if (isCssVar(v) || isCssCalc(v)) { return true }
|
|
111
|
+
if (typeof v !== 'string') { return false }
|
|
112
|
+
var m = v.match(/^[+-]?(\\d*\\.\\d+|\\d+)([a-z%]+)$/i)
|
|
113
|
+
if (m === null) { return false }
|
|
114
|
+
return CSS_LENGTH_UNITS.includes(m[2].toLowerCase())
|
|
115
|
+
}
|
|
116
|
+
function isAngle(v) {
|
|
117
|
+
return typeof v === 'string' && /^[+-]?(\\d*\\.\\d+|\\d+)(deg|grad|rad|turn)$/i.test(v)
|
|
118
|
+
}
|
|
119
|
+
function isTime(v) {
|
|
120
|
+
return typeof v === 'string' && /^[+-]?(\\d*\\.\\d+|\\d+)(ms|s)$/i.test(v)
|
|
121
|
+
}
|
|
122
|
+
function isResolution(v) {
|
|
123
|
+
return typeof v === 'string' && /^[+-]?(\\d*\\.\\d+|\\d+)(dpi|dpcm|dppx|x)$/i.test(v)
|
|
124
|
+
}
|
|
125
|
+
function isGlobalKeyword(v) {
|
|
126
|
+
return typeof v === 'string' && CSS_GLOBAL_KEYWORDS.includes(v.toLowerCase())
|
|
127
|
+
}
|
|
128
|
+
function isDimension(v) {
|
|
129
|
+
return isLength(v) || isPercentage(v) || isAngle(v)
|
|
130
|
+
|| isTime(v) || isResolution(v) || isNumber(v)
|
|
131
|
+
|| isCssVar(v) || isCssCalc(v)
|
|
132
|
+
}
|
|
133
|
+
`
|
|
134
|
+
|
|
135
|
+
/** Entry predicates exported by the dimension cluster (for compile/suggest). */
|
|
136
|
+
export const CSS_DIMENSION_ENTRIES = [
|
|
137
|
+
'isNumber',
|
|
138
|
+
'isInteger',
|
|
139
|
+
'isPercentage',
|
|
140
|
+
'isLength',
|
|
141
|
+
'isAngle',
|
|
142
|
+
'isTime',
|
|
143
|
+
'isResolution',
|
|
144
|
+
'isGlobalKeyword',
|
|
145
|
+
'isDimension',
|
|
146
|
+
] as const
|
package/src/css/index.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tjs-lang/css` — CSS validation built from verified-safe predicates.
|
|
3
|
+
*
|
|
4
|
+
* The "computational half" of a CSS schema: precise, total validators for the
|
|
5
|
+
* open value grammars TS/JSON-Schema cave to `string` on (colors here in
|
|
6
|
+
* phase 1; lengths, shorthands and the recursive style-object structure land in
|
|
7
|
+
* later phases — see TODO.md #4). Every validator is compiled from a
|
|
8
|
+
* predicate-safe source cluster (`./predicates`), so it is pure, synchronous,
|
|
9
|
+
* fuel-bounded, and ReDoS-clean — safe to run on untrusted input.
|
|
10
|
+
*
|
|
11
|
+
* Autocomplete rides the same source: `suggestColor()` mines the named-color set
|
|
12
|
+
* and the open functional stubs (`var(--`, `color-mix(`, …), validating mined
|
|
13
|
+
* values through the compiled predicate so completions are guaranteed valid.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
compilePredicate,
|
|
17
|
+
verifyPredicate,
|
|
18
|
+
suggest,
|
|
19
|
+
type PredicateVerifyResult,
|
|
20
|
+
type Suggestion,
|
|
21
|
+
} from '../lang/predicate'
|
|
22
|
+
import {
|
|
23
|
+
CSS_COLOR_SOURCE,
|
|
24
|
+
CSS_COLOR_ENTRIES,
|
|
25
|
+
CSS_NAMED_COLORS,
|
|
26
|
+
} from './predicates'
|
|
27
|
+
import {
|
|
28
|
+
CSS_DIMENSION_SOURCE,
|
|
29
|
+
CSS_DIMENSION_ENTRIES,
|
|
30
|
+
CSS_LENGTH_UNITS,
|
|
31
|
+
CSS_GLOBAL_KEYWORDS,
|
|
32
|
+
} from './dimensions'
|
|
33
|
+
import { CSS_STYLE_SOURCE, CSS_STYLE_ENTRIES } from './style'
|
|
34
|
+
import { CSS_SHORTHAND_SOURCE, CSS_SHORTHAND_ENTRIES } from './shorthands'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
CSS_COLOR_SOURCE,
|
|
38
|
+
CSS_COLOR_ENTRIES,
|
|
39
|
+
CSS_NAMED_COLORS,
|
|
40
|
+
} from './predicates'
|
|
41
|
+
export {
|
|
42
|
+
CSS_DIMENSION_SOURCE,
|
|
43
|
+
CSS_DIMENSION_ENTRIES,
|
|
44
|
+
CSS_LENGTH_UNITS,
|
|
45
|
+
CSS_GLOBAL_KEYWORDS,
|
|
46
|
+
} from './dimensions'
|
|
47
|
+
export {
|
|
48
|
+
CSS_STYLE_SOURCE,
|
|
49
|
+
CSS_STYLE_ENTRIES,
|
|
50
|
+
cssStyleSchema,
|
|
51
|
+
cssColorSchema,
|
|
52
|
+
cssDimensionSchema,
|
|
53
|
+
} from './style'
|
|
54
|
+
export {
|
|
55
|
+
CSS_SHORTHAND_SOURCE,
|
|
56
|
+
CSS_SHORTHAND_ENTRIES,
|
|
57
|
+
cssAnimationSchema,
|
|
58
|
+
cssTransitionSchema,
|
|
59
|
+
} from './shorthands'
|
|
60
|
+
|
|
61
|
+
/** Every predicate-source cluster the library ships, keyed for verify/drift. */
|
|
62
|
+
const CSS_SOURCES: Record<string, string> = {
|
|
63
|
+
color: CSS_COLOR_SOURCE,
|
|
64
|
+
dimension: CSS_DIMENSION_SOURCE,
|
|
65
|
+
style: CSS_STYLE_SOURCE,
|
|
66
|
+
shorthand: CSS_SHORTHAND_SOURCE,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ColorValidators = Record<
|
|
70
|
+
(typeof CSS_COLOR_ENTRIES)[number],
|
|
71
|
+
(v: unknown) => boolean
|
|
72
|
+
>
|
|
73
|
+
type DimensionValidators = Record<
|
|
74
|
+
(typeof CSS_DIMENSION_ENTRIES)[number],
|
|
75
|
+
(v: unknown) => boolean
|
|
76
|
+
>
|
|
77
|
+
|
|
78
|
+
// Compiled once, on first use (verification + `new Function` are one-time).
|
|
79
|
+
let _color: ColorValidators | null = null
|
|
80
|
+
function colorValidators(): ColorValidators {
|
|
81
|
+
if (!_color) {
|
|
82
|
+
_color = compilePredicate(
|
|
83
|
+
CSS_COLOR_SOURCE,
|
|
84
|
+
CSS_COLOR_ENTRIES as unknown as string[]
|
|
85
|
+
) as ColorValidators
|
|
86
|
+
}
|
|
87
|
+
return _color
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let _dimension: DimensionValidators | null = null
|
|
91
|
+
function dimensionValidators(): DimensionValidators {
|
|
92
|
+
if (!_dimension) {
|
|
93
|
+
_dimension = compilePredicate(
|
|
94
|
+
CSS_DIMENSION_SOURCE,
|
|
95
|
+
CSS_DIMENSION_ENTRIES as unknown as string[]
|
|
96
|
+
) as DimensionValidators
|
|
97
|
+
}
|
|
98
|
+
return _dimension
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type StyleValidators = Record<
|
|
102
|
+
(typeof CSS_STYLE_ENTRIES)[number],
|
|
103
|
+
(v: unknown) => boolean
|
|
104
|
+
>
|
|
105
|
+
let _style: StyleValidators | null = null
|
|
106
|
+
function styleValidators(): StyleValidators {
|
|
107
|
+
if (!_style) {
|
|
108
|
+
_style = compilePredicate(
|
|
109
|
+
CSS_STYLE_SOURCE,
|
|
110
|
+
CSS_STYLE_ENTRIES as unknown as string[]
|
|
111
|
+
) as StyleValidators
|
|
112
|
+
}
|
|
113
|
+
return _style
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type ShorthandValidators = Record<
|
|
117
|
+
(typeof CSS_SHORTHAND_ENTRIES)[number],
|
|
118
|
+
(v: unknown) => boolean
|
|
119
|
+
>
|
|
120
|
+
let _shorthand: ShorthandValidators | null = null
|
|
121
|
+
function shorthandValidators(): ShorthandValidators {
|
|
122
|
+
if (!_shorthand) {
|
|
123
|
+
_shorthand = compilePredicate(
|
|
124
|
+
CSS_SHORTHAND_SOURCE,
|
|
125
|
+
CSS_SHORTHAND_ENTRIES as unknown as string[]
|
|
126
|
+
) as ShorthandValidators
|
|
127
|
+
}
|
|
128
|
+
return _shorthand
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Is `v` a valid CSS color (named, hex, rgb/hsl, modern fn, or `var(--…)`)? */
|
|
132
|
+
export const isColor = (v: unknown): boolean => colorValidators().isColor(v)
|
|
133
|
+
/** Like {@link isColor}, but tolerates a trailing `!important`. */
|
|
134
|
+
export const isColorValue = (v: unknown): boolean =>
|
|
135
|
+
colorValidators().isColorValue(v)
|
|
136
|
+
/** Is `v` a CSS named color (incl. `transparent`/`currentcolor`)? */
|
|
137
|
+
export const isNamedColor = (v: unknown): boolean =>
|
|
138
|
+
colorValidators().isNamedColor(v)
|
|
139
|
+
/** Is `v` a CSS hex color (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`)? */
|
|
140
|
+
export const isHexColor = (v: unknown): boolean =>
|
|
141
|
+
colorValidators().isHexColor(v)
|
|
142
|
+
|
|
143
|
+
// --- dimensions / numbers / keywords (phase 2) ------------------------------
|
|
144
|
+
|
|
145
|
+
/** Is `v` a CSS `<length>` (unit'd, unitless `0`, or `var()`/`calc()`)? */
|
|
146
|
+
export const isLength = (v: unknown): boolean =>
|
|
147
|
+
dimensionValidators().isLength(v)
|
|
148
|
+
/** Is `v` a CSS `<percentage>` (`50%`)? */
|
|
149
|
+
export const isPercentage = (v: unknown): boolean =>
|
|
150
|
+
dimensionValidators().isPercentage(v)
|
|
151
|
+
/** Is `v` a CSS `<number>` (numeric, or a numeric string)? */
|
|
152
|
+
export const isNumber = (v: unknown): boolean =>
|
|
153
|
+
dimensionValidators().isNumber(v)
|
|
154
|
+
/** Is `v` a CSS `<integer>`? */
|
|
155
|
+
export const isInteger = (v: unknown): boolean =>
|
|
156
|
+
dimensionValidators().isInteger(v)
|
|
157
|
+
/** Is `v` a CSS `<angle>` (`45deg`, `1turn`, `1.5rad`, `100grad`)? */
|
|
158
|
+
export const isAngle = (v: unknown): boolean => dimensionValidators().isAngle(v)
|
|
159
|
+
/** Is `v` a CSS `<time>` (`200ms`, `1s`)? */
|
|
160
|
+
export const isTime = (v: unknown): boolean => dimensionValidators().isTime(v)
|
|
161
|
+
/** Is `v` any CSS dimension/number (length, %, angle, time, resolution, number)? */
|
|
162
|
+
export const isDimension = (v: unknown): boolean =>
|
|
163
|
+
dimensionValidators().isDimension(v)
|
|
164
|
+
/** Is `v` a CSS-wide keyword (`inherit`/`initial`/`unset`/`revert`/`revert-layer`)? */
|
|
165
|
+
export const isGlobalKeyword = (v: unknown): boolean =>
|
|
166
|
+
dimensionValidators().isGlobalKeyword(v)
|
|
167
|
+
|
|
168
|
+
// --- recursive style-object structure (phase 4) -----------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Is `v` a valid CSS **style object** — an open, recursive map of CSS properties
|
|
172
|
+
* to values and selectors/at-rules to nested style objects? Validates the whole
|
|
173
|
+
* structure (keys are properties or selectors; property values are style values,
|
|
174
|
+
* selector values are nested rules), recursively and fuel-bounded. This is the
|
|
175
|
+
* shape TS/JSON-Schema can't express.
|
|
176
|
+
*/
|
|
177
|
+
export const isStyleObject = (v: unknown): boolean =>
|
|
178
|
+
styleValidators().isStyleObject(v)
|
|
179
|
+
/** Is `v` a valid leaf CSS value (a known color/dimension/keyword, else a non-empty string / finite number)? */
|
|
180
|
+
export const isStyleValue = (v: unknown): boolean =>
|
|
181
|
+
styleValidators().isStyleValue(v)
|
|
182
|
+
/**
|
|
183
|
+
* Property-AWARE leaf check: given the property name, tightens the closed value
|
|
184
|
+
* grammars (color/animation/transition) so `isStyleValueFor('color', 'notacolor')`
|
|
185
|
+
* is `false` while `isStyleValueFor('padding', 'anything')` stays permissive.
|
|
186
|
+
* This is what makes {@link isStyleObject} catch real value errors.
|
|
187
|
+
*/
|
|
188
|
+
export const isStyleValueFor = (prop: unknown, val: unknown): boolean =>
|
|
189
|
+
(styleValidators().isStyleValueFor as (p: unknown, v: unknown) => boolean)(
|
|
190
|
+
prop,
|
|
191
|
+
val
|
|
192
|
+
)
|
|
193
|
+
/** Is `k` a CSS property name (`color`, `--custom`, `-webkit-foo`)? */
|
|
194
|
+
export const isCssProperty = (k: unknown): boolean =>
|
|
195
|
+
styleValidators().isCssProperty(k)
|
|
196
|
+
/** Is `k` a selector or at-rule key (nests a rule) rather than a property? */
|
|
197
|
+
export const isSelectorOrAtRule = (k: unknown): boolean =>
|
|
198
|
+
styleValidators().isSelectorOrAtRule(k)
|
|
199
|
+
|
|
200
|
+
// --- order-flexible shorthands (phase 3) ------------------------------------
|
|
201
|
+
|
|
202
|
+
/** Is `v` a valid CSS `animation` shorthand (comma-separated, order-free tokens)? */
|
|
203
|
+
export const isAnimation = (v: unknown): boolean =>
|
|
204
|
+
shorthandValidators().isAnimation(v)
|
|
205
|
+
/** Is `v` a valid CSS `transition` shorthand (comma-separated layers)? */
|
|
206
|
+
export const isTransition = (v: unknown): boolean =>
|
|
207
|
+
shorthandValidators().isTransition(v)
|
|
208
|
+
/** Is `v` a CSS `<easing-function>` (keyword, `cubic-bezier(…)`, `steps(…)`, `linear(…)`)? */
|
|
209
|
+
export const isTimingFunction = (v: unknown): boolean =>
|
|
210
|
+
shorthandValidators().isTimingFunction(v)
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Verify every CSS predicate-source cluster is predicate-safe (pure,
|
|
214
|
+
* synchronous, ReDoS-clean). Returns the combined result (safe iff all clusters
|
|
215
|
+
* are; diagnostics carry the cluster name). Always safe for the shipped source —
|
|
216
|
+
* exposed so downstream tools (and the drift test) can assert it, and so a
|
|
217
|
+
* consumer embedding a source in a `$predicate` schema can re-check before
|
|
218
|
+
* trusting it.
|
|
219
|
+
*/
|
|
220
|
+
export function verifyCss(): PredicateVerifyResult {
|
|
221
|
+
const predicates: string[] = []
|
|
222
|
+
const diagnostics: PredicateVerifyResult['diagnostics'] = []
|
|
223
|
+
for (const [name, source] of Object.entries(CSS_SOURCES)) {
|
|
224
|
+
const r = verifyPredicate(source)
|
|
225
|
+
predicates.push(...r.predicates)
|
|
226
|
+
for (const d of r.diagnostics)
|
|
227
|
+
diagnostics.push({ ...d, predicate: `${name}:${d.predicate}` })
|
|
228
|
+
}
|
|
229
|
+
return { safe: diagnostics.length === 0, predicates, diagnostics }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Autocomplete candidates for a CSS color value. Returns concrete named colors
|
|
234
|
+
* (validated through the compiled predicate) plus open-ended stubs mined from
|
|
235
|
+
* the functional forms (`var(--`, `color-mix(`, `oklch(`, …). Prefix-filtered.
|
|
236
|
+
*/
|
|
237
|
+
export function suggestColor(prefix = '', limit = 50): Suggestion[] {
|
|
238
|
+
return suggest(CSS_COLOR_SOURCE, {
|
|
239
|
+
entry: 'isColor',
|
|
240
|
+
prefix,
|
|
241
|
+
limit,
|
|
242
|
+
})
|
|
243
|
+
}
|