@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echelon-foundry/design-system",
3
- "version": "0.12.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>