@echelon-foundry/design-system 0.12.0 → 0.13.0-main.263.1
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/CHANGELOG.md +49 -0
- package/contracts/color-contract.json +1 -1
- package/dist/all.css +59 -1
- package/dist/build-metrics.json +3 -3
- package/dist/components.css +12 -1
- package/dist/foundations.css +47 -0
- package/dist/icons/registry.json +1 -1
- package/dist/marketing/forma-all.css +60 -2
- package/dist/marketing/forma-echelon-marketing.css +48 -1
- package/dist/marketing/forma-marketing-theme-echelon.css +1 -1
- package/dist/marketing/forma-marketing.css +48 -1
- package/dist/marketing/forma-marketing.manifest.json +9 -9
- package/dist/marketing/forma-marketing.sha256 +4 -4
- package/dist/runtime.js +1 -1
- package/package.json +7 -4
- package/tools/TokenValidator/Css.fs +171 -0
- package/tools/TokenValidator/Program.fs +104 -0
- package/tools/TokenValidator/TokenValidator.fsproj +12 -0
- package/tools/TokenValidator/Validation.fs +395 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@echelon-foundry/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0-main.263.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Zero-component-runtime HTML and CSS design system for Echelon Foundry applications.",
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"docs/MARKETING-SITES.md",
|
|
48
48
|
"tokens/echelon.tokens.json",
|
|
49
49
|
"tools/color-contract",
|
|
50
|
+
"tools/TokenValidator/*.fs",
|
|
51
|
+
"tools/TokenValidator/TokenValidator.fsproj",
|
|
50
52
|
"contracts/color-contract.json",
|
|
51
53
|
"docs/COLOR.md",
|
|
52
54
|
"figma/component-contracts.json",
|
|
@@ -71,7 +73,8 @@
|
|
|
71
73
|
"assets:build": "node tools/build-assets.mjs",
|
|
72
74
|
"build": "npm run clean && npm run tokens:build && npm run brands:build && npm run color:build && npm run marketing:theme && npm run runtime:build && npm run assets:build && npm run marketing:build && npm run example:install",
|
|
73
75
|
"runtime:build": "node tools/build-runtime.mjs",
|
|
74
|
-
"test:tokens": "node --test tests/token-compiler.test.mjs",
|
|
76
|
+
"test:tokens": "node --test tests/token-compiler.test.mjs tests/token-validator.test.mjs",
|
|
77
|
+
"tokens:validate": "dotnet run --project tools/TokenValidator/TokenValidator.fsproj -c Release -- --self",
|
|
75
78
|
"test:browser": "playwright test",
|
|
76
79
|
"test:a11y": "playwright test tests/browser/accessibility.spec.mjs",
|
|
77
80
|
"test:runtime": "node --test tests/zero-runtime.test.mjs",
|
|
@@ -79,7 +82,7 @@
|
|
|
79
82
|
"test:color": "node --test tests/color-contract.test.mjs",
|
|
80
83
|
"motion:audit": "node tools/motion-audit.mjs",
|
|
81
84
|
"test:motion": "node --test tests/motion-audit.test.mjs tests/motion-gate.test.mjs && node tools/motion-audit.mjs --strict",
|
|
82
|
-
"check": "npm run build && npm run icons:check && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:color && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:figma && npm run test:css && npm run test:package && npm run workflow:test && npm run workflow:build && npm run test:workflow-package && npm run test:browser",
|
|
85
|
+
"check": "npm run build && npm run icons:check && npm run tokens:validate && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:color && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:figma && npm run test:css && npm run test:package && npm run workflow:test && npm run workflow:build && npm run test:workflow-package && npm run test:browser",
|
|
83
86
|
"site:build": "npm run build && node tools/build-site.mjs",
|
|
84
87
|
"site:test": "node --test tests/site-build.test.mjs tests/catalog-coverage.test.mjs tests/icon-docs.test.mjs tests/catalog-interaction.test.mjs",
|
|
85
88
|
"site:check": "npm run site:build && npm run catalog:check && npm run site:test && npm run site:mobile",
|
|
@@ -97,7 +100,7 @@
|
|
|
97
100
|
"test:marketing": "node --test tests/marketing-tokens.test.mjs tests/marketing-distribution.test.mjs tests/marketing-structure.test.mjs tests/site-css-policy.test.mjs",
|
|
98
101
|
"example:install": "mkdir -p build/example && cp examples/echelon-marketing-site/forma.lock build/example/forma.lock && bash actions/install-presentation/install.sh --lock build/example/forma.lock --source dist/marketing --update --output examples/echelon-marketing-site/assets/forma",
|
|
99
102
|
"policy:check": "dotnet run --project tools/SiteCssPolicy/SiteCssPolicy.fsproj --",
|
|
100
|
-
"release:check": "npm run build && npm run icons:check && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:css && npm run test:package && npm run test:machine && playwright test tests/browser/marketing.spec.mjs && npm run site:check",
|
|
103
|
+
"release:check": "npm run build && npm run icons:check && npm run tokens:validate && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:css && npm run test:package && npm run test:machine && playwright test tests/browser/marketing.spec.mjs && npm run site:check",
|
|
101
104
|
"test:figma": "node --test tests/figma-contract.test.mjs",
|
|
102
105
|
"catalog:check": "node tools/catalog/validate.mjs && node tools/catalog/inventory.mjs --check",
|
|
103
106
|
"catalog:inventory": "node tools/catalog/inventory.mjs",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/// A minimal, dependency-free CSS reader: enough structure to find custom
|
|
2
|
+
/// property declarations and var() references with their selector, file and
|
|
3
|
+
/// line. It is not a validating parser; malformed CSS still yields whatever
|
|
4
|
+
/// declarations can be recognised.
|
|
5
|
+
module Forma.TokenValidator.Css
|
|
6
|
+
|
|
7
|
+
open System
|
|
8
|
+
open System.Text
|
|
9
|
+
|
|
10
|
+
type VarReference =
|
|
11
|
+
{ Name: string
|
|
12
|
+
HasFallback: bool }
|
|
13
|
+
|
|
14
|
+
type Declaration =
|
|
15
|
+
{ File: string
|
|
16
|
+
Line: int
|
|
17
|
+
Selector: string
|
|
18
|
+
/// Every enclosing prelude, outermost first (at-rules and selectors).
|
|
19
|
+
Context: string
|
|
20
|
+
Property: string
|
|
21
|
+
Value: string
|
|
22
|
+
References: VarReference list }
|
|
23
|
+
|
|
24
|
+
/// Replace comments with spaces and blank the contents of quoted strings,
|
|
25
|
+
/// keeping their quotes and every newline, so offsets and line numbers still
|
|
26
|
+
/// match the source. Braces, comment markers and var() inside a string
|
|
27
|
+
/// (content: "}") are then inert.
|
|
28
|
+
let normalise (text: string) =
|
|
29
|
+
let builder = StringBuilder(text.Length)
|
|
30
|
+
let blank (c: char) = if c = '\n' then '\n' else ' '
|
|
31
|
+
let rec go index (state: char option) inComment =
|
|
32
|
+
if index >= text.Length then ()
|
|
33
|
+
elif inComment then
|
|
34
|
+
if index + 1 < text.Length && text[index] = '*' && text[index + 1] = '/' then
|
|
35
|
+
builder.Append(" ") |> ignore
|
|
36
|
+
go (index + 2) None false
|
|
37
|
+
else
|
|
38
|
+
builder.Append(blank text[index]) |> ignore
|
|
39
|
+
go (index + 1) None true
|
|
40
|
+
else
|
|
41
|
+
match state with
|
|
42
|
+
| Some _ when text[index] = '\\' && index + 1 < text.Length ->
|
|
43
|
+
builder.Append(blank text[index]).Append(blank text[index + 1]) |> ignore
|
|
44
|
+
go (index + 2) state false
|
|
45
|
+
| Some quote when text[index] = quote ->
|
|
46
|
+
builder.Append(quote) |> ignore
|
|
47
|
+
go (index + 1) None false
|
|
48
|
+
| Some _ ->
|
|
49
|
+
builder.Append(blank text[index]) |> ignore
|
|
50
|
+
go (index + 1) state false
|
|
51
|
+
| None when index + 1 < text.Length && text[index] = '/' && text[index + 1] = '*' ->
|
|
52
|
+
builder.Append(" ") |> ignore
|
|
53
|
+
go (index + 2) None true
|
|
54
|
+
| None when text[index] = '"' || text[index] = '\'' ->
|
|
55
|
+
builder.Append(text[index]) |> ignore
|
|
56
|
+
go (index + 1) (Some text[index]) false
|
|
57
|
+
| None ->
|
|
58
|
+
builder.Append(text[index]) |> ignore
|
|
59
|
+
go (index + 1) None false
|
|
60
|
+
go 0 None false
|
|
61
|
+
builder.ToString()
|
|
62
|
+
|
|
63
|
+
let private isNameChar (c: char) = Char.IsLetterOrDigit c || c = '-' || c = '_'
|
|
64
|
+
|
|
65
|
+
/// Index of the parenthesis closing the one opened just before `start`.
|
|
66
|
+
let private closingParen (value: string) start =
|
|
67
|
+
let rec go index depth =
|
|
68
|
+
if index >= value.Length then value.Length
|
|
69
|
+
else
|
|
70
|
+
match value[index] with
|
|
71
|
+
| '(' -> go (index + 1) (depth + 1)
|
|
72
|
+
| ')' when depth = 0 -> index
|
|
73
|
+
| ')' -> go (index + 1) (depth - 1)
|
|
74
|
+
| _ -> go (index + 1) depth
|
|
75
|
+
go start 0
|
|
76
|
+
|
|
77
|
+
/// Every var(--name[, fallback]) in a value, including those nested in
|
|
78
|
+
/// fallbacks, in source order.
|
|
79
|
+
let rec references (value: string) : VarReference list =
|
|
80
|
+
let index = value.IndexOf("var(", StringComparison.OrdinalIgnoreCase)
|
|
81
|
+
if index < 0 then []
|
|
82
|
+
else
|
|
83
|
+
let start = index + 4
|
|
84
|
+
let close = closingParen value start
|
|
85
|
+
let inner = value.Substring(start, close - start)
|
|
86
|
+
let nameStart = inner.Length - inner.TrimStart().Length
|
|
87
|
+
let name = inner.Substring(nameStart) |> Seq.takeWhile isNameChar |> Seq.toArray |> String
|
|
88
|
+
let rest = inner.Substring(nameStart + name.Length).TrimStart()
|
|
89
|
+
let fallback = if rest.StartsWith(",") then rest.Substring(1) else ""
|
|
90
|
+
let own =
|
|
91
|
+
if name.StartsWith("--") then [ { Name = name; HasFallback = rest.StartsWith(",") } ] else []
|
|
92
|
+
own @ references fallback @ references (value.Substring(min value.Length (close + 1)))
|
|
93
|
+
|
|
94
|
+
/// Offsets at which each line starts, for line lookup by binary search.
|
|
95
|
+
let private lineStarts (text: string) =
|
|
96
|
+
Array.append [| 0 |] (text |> Seq.indexed |> Seq.filter (fun (_, c) -> c = '\n') |> Seq.map (fun (i, _) -> i + 1) |> Seq.toArray)
|
|
97
|
+
|
|
98
|
+
let private lineAt (starts: int array) offset =
|
|
99
|
+
let found = Array.BinarySearch(starts, offset)
|
|
100
|
+
if found >= 0 then found + 1 else ~~~found
|
|
101
|
+
|
|
102
|
+
/// Split a block body at top-level semicolons, keeping each piece's offset.
|
|
103
|
+
let private pieces (body: string) (offset: int) =
|
|
104
|
+
let rec go index depth quote start acc =
|
|
105
|
+
if index >= body.Length then List.rev ((start, body.Substring(start)) :: acc)
|
|
106
|
+
else
|
|
107
|
+
let c = body[index]
|
|
108
|
+
match quote with
|
|
109
|
+
| Some q when c = q -> go (index + 1) depth None start acc
|
|
110
|
+
| Some _ -> go (index + 1) depth quote start acc
|
|
111
|
+
| None ->
|
|
112
|
+
match c with
|
|
113
|
+
| '"' | '\'' -> go (index + 1) depth (Some c) start acc
|
|
114
|
+
| '(' -> go (index + 1) (depth + 1) None start acc
|
|
115
|
+
| ')' -> go (index + 1) (max 0 (depth - 1)) None start acc
|
|
116
|
+
| ';' when depth = 0 -> go (index + 1) depth None (index + 1) ((start, body.Substring(start, index - start)) :: acc)
|
|
117
|
+
| _ -> go (index + 1) depth None start acc
|
|
118
|
+
go 0 0 None 0 [] |> List.map (fun (start, piece) -> offset + start, piece)
|
|
119
|
+
|
|
120
|
+
/// Declarations of one file. Nested blocks (@media, @layer, nesting) are
|
|
121
|
+
/// walked; a declaration's selector is the innermost enclosing prelude.
|
|
122
|
+
let declarations (file: string) (source: string) : Declaration list =
|
|
123
|
+
let text = normalise source
|
|
124
|
+
let starts = lineStarts text
|
|
125
|
+
let declarationOf (stack: string list) (offset: int, piece: string) =
|
|
126
|
+
let selector = match stack with innermost :: _ -> innermost | [] -> ""
|
|
127
|
+
let colon = piece.IndexOf(':')
|
|
128
|
+
let property = if colon > 0 then piece.Substring(0, colon).Trim() else ""
|
|
129
|
+
if colon <= 0 || property.Length = 0 || property.Contains("{") || property.Contains("}") || property.StartsWith("@") then None
|
|
130
|
+
else
|
|
131
|
+
let value = piece.Substring(colon + 1).Trim()
|
|
132
|
+
let leading = piece.Length - piece.TrimStart().Length
|
|
133
|
+
Some
|
|
134
|
+
{ File = file
|
|
135
|
+
Line = lineAt starts (offset + leading)
|
|
136
|
+
Selector = selector
|
|
137
|
+
Context = stack |> List.rev |> String.concat " » "
|
|
138
|
+
Property = property
|
|
139
|
+
Value = value
|
|
140
|
+
References = references value }
|
|
141
|
+
// Walk the text, splitting at braces. Text between braces at one level is
|
|
142
|
+
// either declarations (inside a block) or a prelude (before '{').
|
|
143
|
+
let rec walk index segmentStart stack acc =
|
|
144
|
+
if index >= text.Length then
|
|
145
|
+
let tail =
|
|
146
|
+
match stack with
|
|
147
|
+
| _ :: _ -> pieces (text.Substring(segmentStart)) segmentStart |> List.choose (declarationOf stack)
|
|
148
|
+
| [] -> []
|
|
149
|
+
List.rev acc @ tail
|
|
150
|
+
else
|
|
151
|
+
match text[index] with
|
|
152
|
+
| '{' ->
|
|
153
|
+
// The segment before '{' may hold declarations followed by a nested prelude.
|
|
154
|
+
let segment = text.Substring(segmentStart, index - segmentStart)
|
|
155
|
+
let lastSemicolon = segment.LastIndexOf(';')
|
|
156
|
+
let declared, prelude =
|
|
157
|
+
if lastSemicolon >= 0 then segment.Substring(0, lastSemicolon + 1), segment.Substring(lastSemicolon + 1)
|
|
158
|
+
else "", segment
|
|
159
|
+
let found =
|
|
160
|
+
match stack with
|
|
161
|
+
| _ :: _ when declared.Length > 0 -> pieces declared segmentStart |> List.choose (declarationOf stack)
|
|
162
|
+
| _ -> []
|
|
163
|
+
walk (index + 1) (index + 1) (prelude.Trim() :: stack) (List.rev found @ acc)
|
|
164
|
+
| '}' ->
|
|
165
|
+
let found =
|
|
166
|
+
match stack with
|
|
167
|
+
| _ :: _ -> pieces (text.Substring(segmentStart, index - segmentStart)) segmentStart |> List.choose (declarationOf stack)
|
|
168
|
+
| [] -> []
|
|
169
|
+
walk (index + 1) (index + 1) (match stack with _ :: rest -> rest | [] -> []) (List.rev found @ acc)
|
|
170
|
+
| _ -> walk (index + 1) segmentStart stack acc
|
|
171
|
+
walk 0 0 [] []
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Forma.TokenValidator.Program
|
|
2
|
+
|
|
3
|
+
open System
|
|
4
|
+
open System.IO
|
|
5
|
+
open System.Text.Json
|
|
6
|
+
open Forma.TokenValidator.Validation
|
|
7
|
+
|
|
8
|
+
let private usage =
|
|
9
|
+
"""usage: TokenValidator [--forma DIR] [--json] (--self | PATH...)
|
|
10
|
+
|
|
11
|
+
--forma DIR Forma root to validate against: an installed package
|
|
12
|
+
(node_modules/@echelon-foundry/design-system) or a built
|
|
13
|
+
checkout. Default: the current directory.
|
|
14
|
+
--self Validate Forma itself: token sources (aliases, cycles, theme
|
|
15
|
+
completeness), brand manifests, and every var() in the built
|
|
16
|
+
stylesheets.
|
|
17
|
+
PATH... Application CSS files or directories (searched for *.css,
|
|
18
|
+
skipping node_modules) to validate against that Forma version.
|
|
19
|
+
--json Print findings as JSON.
|
|
20
|
+
|
|
21
|
+
Exit status: 0 no errors, 1 errors found, 2 invalid arguments or unreadable input."""
|
|
22
|
+
|
|
23
|
+
type private Options =
|
|
24
|
+
{ Forma: string
|
|
25
|
+
Self: bool
|
|
26
|
+
Json: bool
|
|
27
|
+
Paths: string list }
|
|
28
|
+
|
|
29
|
+
let rec private parse (options: Options) (args: string list) =
|
|
30
|
+
match args with
|
|
31
|
+
| [] -> Ok { options with Paths = List.rev options.Paths }
|
|
32
|
+
| "--forma" :: dir :: rest -> parse { options with Forma = dir } rest
|
|
33
|
+
| "--self" :: rest -> parse { options with Self = true } rest
|
|
34
|
+
| "--json" :: rest -> parse { options with Json = true } rest
|
|
35
|
+
| ("--help" | "-h") :: _ -> Error ""
|
|
36
|
+
| flag :: _ when flag.StartsWith("--") -> Error $"unknown option {flag}"
|
|
37
|
+
| path :: rest -> parse { options with Paths = path :: options.Paths } rest
|
|
38
|
+
|
|
39
|
+
let private cssFiles (path: string) =
|
|
40
|
+
if Directory.Exists path then
|
|
41
|
+
Directory.EnumerateFiles(path, "*.css", SearchOption.AllDirectories)
|
|
42
|
+
|> Seq.filter (fun file -> not (file.Split(Path.DirectorySeparatorChar) |> Array.contains "node_modules"))
|
|
43
|
+
|> Seq.sort
|
|
44
|
+
|> Seq.toList
|
|
45
|
+
elif File.Exists path then [ path ]
|
|
46
|
+
else failwith $"no such file or directory: {path}"
|
|
47
|
+
|
|
48
|
+
let private relative (file: string) =
|
|
49
|
+
if String.IsNullOrEmpty file then file else Path.GetRelativePath(Environment.CurrentDirectory, file)
|
|
50
|
+
|
|
51
|
+
let private render json (findings: Finding list) =
|
|
52
|
+
if json then
|
|
53
|
+
let rows =
|
|
54
|
+
findings
|
|
55
|
+
|> List.map (fun f ->
|
|
56
|
+
dict [ "code", box f.Code
|
|
57
|
+
"severity", box (match f.Severity with Blocking -> "error" | Advisory -> "warning")
|
|
58
|
+
"file", box (relative f.File)
|
|
59
|
+
"line", box f.Line
|
|
60
|
+
"name", box f.Name
|
|
61
|
+
"message", box f.Message ])
|
|
62
|
+
printfn "%s" (JsonSerializer.Serialize(rows, JsonSerializerOptions(WriteIndented = true)))
|
|
63
|
+
else
|
|
64
|
+
findings
|
|
65
|
+
|> List.iter (fun f ->
|
|
66
|
+
let location = if f.Line > 0 then $"{relative f.File}:{f.Line}" else relative f.File
|
|
67
|
+
let severity = match f.Severity with Blocking -> "error" | Advisory -> "warning"
|
|
68
|
+
printfn $"{location}: {severity} [{f.Code}] {f.Message}")
|
|
69
|
+
|
|
70
|
+
[<EntryPoint>]
|
|
71
|
+
let main argv =
|
|
72
|
+
match parse { Forma = "."; Self = false; Json = false; Paths = [] } (List.ofArray argv) with
|
|
73
|
+
| Error message ->
|
|
74
|
+
if message <> "" then eprintfn "%s" message
|
|
75
|
+
eprintfn "%s" usage
|
|
76
|
+
if message = "" then 0 else 2
|
|
77
|
+
| Ok options when not options.Self && options.Paths.IsEmpty ->
|
|
78
|
+
eprintfn "%s" usage
|
|
79
|
+
2
|
|
80
|
+
| Ok options ->
|
|
81
|
+
try
|
|
82
|
+
let root = Path.GetFullPath options.Forma
|
|
83
|
+
let registry = registryOf root
|
|
84
|
+
let selfFindings =
|
|
85
|
+
if options.Self then
|
|
86
|
+
let tokens = validateTokens registry.Tokens
|
|
87
|
+
let brands =
|
|
88
|
+
let directory = Path.Combine(root, "brands")
|
|
89
|
+
if Directory.Exists directory then
|
|
90
|
+
Directory.EnumerateFiles(directory, "*.brand.json") |> Seq.sort |> Seq.toList |> List.collect validateBrand
|
|
91
|
+
else []
|
|
92
|
+
tokens @ brands @ validateFormaCss registry
|
|
93
|
+
else []
|
|
94
|
+
let appFindings =
|
|
95
|
+
options.Paths |> List.collect cssFiles |> declarationsOf |> validateCss registry
|
|
96
|
+
let findings = (selfFindings @ appFindings) |> List.distinct |> List.sortBy (fun f -> f.File, f.Line, f.Name, f.Code)
|
|
97
|
+
render options.Json findings
|
|
98
|
+
let errors = findings |> List.filter (fun f -> f.Severity = Blocking) |> List.length
|
|
99
|
+
if not options.Json then
|
|
100
|
+
eprintfn $"token validation: {errors} error(s); {registry.Defined.Count} defined Forma properties and {registry.Hooks.Count} documented inputs in {relative root}"
|
|
101
|
+
if errors > 0 then 1 else 0
|
|
102
|
+
with ex ->
|
|
103
|
+
eprintfn $"ERROR {ex.Message}"
|
|
104
|
+
2
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<OutputType>Exe</OutputType>
|
|
4
|
+
<TargetFramework>net10.0</TargetFramework>
|
|
5
|
+
<AssemblyName>forma-token-validator</AssemblyName>
|
|
6
|
+
</PropertyGroup>
|
|
7
|
+
<ItemGroup>
|
|
8
|
+
<Compile Include="Css.fs" />
|
|
9
|
+
<Compile Include="Validation.fs" />
|
|
10
|
+
<Compile Include="Program.fs" />
|
|
11
|
+
</ItemGroup>
|
|
12
|
+
</Project>
|