@echelon-foundry/design-system 0.12.0 → 0.13.0-main.264.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 +60 -0
- package/contracts/color-contract.json +1 -1
- package/dist/all.css +80 -1
- package/dist/build-metrics.json +4 -4
- 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 +81 -2
- package/dist/marketing/forma-echelon-marketing.css +69 -1
- package/dist/marketing/forma-marketing-theme-echelon.css +1 -1
- package/dist/marketing/forma-marketing.css +69 -1
- package/dist/marketing/forma-marketing.manifest.json +9 -9
- package/dist/marketing/forma-marketing.sha256 +4 -4
- package/dist/marketing.css +21 -0
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Forma 0.
|
|
1
|
+
/*! Forma 0.13.0 | forma-marketing.css | bundle echelon-marketing
|
|
2
2
|
Brand-neutral marketing layer: core tokens, role defaults, Forma foundations, marketing foundations, components, and layouts.
|
|
3
3
|
Generated by tools/PresentationBundler. Do not edit; pin by version and sha256. */
|
|
4
4
|
|
|
@@ -902,6 +902,20 @@
|
|
|
902
902
|
outline: 3px solid var(--ef-color-focus-gap, #f2efe7);
|
|
903
903
|
outline-offset: 2px;
|
|
904
904
|
box-shadow: 0 0 0 5px var(--ef-color-focus-ring, #171a18);
|
|
905
|
+
/* Focus scrolling brings the whole indicator into view, not only the
|
|
906
|
+
element's border box (LRP-001). Changes scroll position only. */
|
|
907
|
+
scroll-margin: 0.5rem;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/* Keyboard focus on a date or time control's own picker button leaves the
|
|
911
|
+
host input :focus-within but neither :focus nor :focus-visible
|
|
912
|
+
(Chromium), which painted no indicator at all (LRP-001). :not(:focus)
|
|
913
|
+
limits this to that case: pointer focus on the input itself (WebKit)
|
|
914
|
+
keeps the native behaviour. */
|
|
915
|
+
:where(input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"]):focus-within:not(:focus) {
|
|
916
|
+
outline: 3px solid var(--ef-color-focus-gap, #f2efe7);
|
|
917
|
+
outline-offset: 2px;
|
|
918
|
+
box-shadow: 0 0 0 5px var(--ef-color-focus-ring, #171a18);
|
|
905
919
|
}
|
|
906
920
|
|
|
907
921
|
.ef-stack { display: flex; flex-direction: column; gap: var(--ef-stack-gap, var(--ef-primitive-spacing-4, 1rem)); }
|
|
@@ -919,6 +933,39 @@
|
|
|
919
933
|
overflow-wrap: anywhere;
|
|
920
934
|
}
|
|
921
935
|
|
|
936
|
+
/*
|
|
937
|
+
* Layout reliability (LRP-001, docs/reliability/layout-baseline.md).
|
|
938
|
+
* Forma containers that hold arbitrary application content pass
|
|
939
|
+
* overflow-wrap: break-word to that content by inheritance, so a long token
|
|
940
|
+
* in a span, strong, em, div or li breaks instead of escaping the card,
|
|
941
|
+
* dialog or field. break-word, unlike anywhere, leaves min-content sizes
|
|
942
|
+
* alone: a word that fits is never split, and auto-sized flex and grid
|
|
943
|
+
* items keep whole words. Inheritance never overrides a declared value:
|
|
944
|
+
* text elements above keep anywhere, character-grid runs keep normal, and
|
|
945
|
+
* white-space: pre or nowrap content (code blocks, truncating selects,
|
|
946
|
+
* scrollers) does not wrap.
|
|
947
|
+
*/
|
|
948
|
+
:where(
|
|
949
|
+
.ef-surface, .ef-stack, .ef-cluster, .ef-actions, .ef-grid, .ef-responsive-grid,
|
|
950
|
+
.ef-rail, .ef-mosaic, .ef-priority-stack, .ef-focus-stage, .ef-sidebar, .ef-switcher, .ef-split-pane,
|
|
951
|
+
.ef-dialog, .ef-popover, .ef-menu, .ef-form, .ef-field, .ef-validation-summary, .ef-record-header
|
|
952
|
+
) {
|
|
953
|
+
overflow-wrap: break-word;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/*
|
|
957
|
+
* A wrapping row's item can be no wider than the row (LRP-001), which also
|
|
958
|
+
* caps its automatic flex minimum, so a long label or an inline-block item
|
|
959
|
+
* cannot push the row past its container; the item's text then breaks
|
|
960
|
+
* inside it. Items keep their content size whenever it fits: nothing here
|
|
961
|
+
* shrinks a control below its label. Top-layer children (a dialog or
|
|
962
|
+
* popover authored inside the row) keep their own sizing. A full-bleed
|
|
963
|
+
* child with negative margins is capped at the row's width.
|
|
964
|
+
*/
|
|
965
|
+
:where(.ef-cluster, .ef-actions) > :not(dialog, [popover]) {
|
|
966
|
+
max-inline-size: 100%;
|
|
967
|
+
}
|
|
968
|
+
|
|
922
969
|
/* Action labels never split mid-word because their container allows it
|
|
923
970
|
(a link button inherits the rule above; a button inherits it from a
|
|
924
971
|
table cell). A single word wider than its container may still break
|
|
@@ -1305,6 +1352,23 @@
|
|
|
1305
1352
|
overflow-wrap: anywhere;
|
|
1306
1353
|
}
|
|
1307
1354
|
|
|
1355
|
+
/*
|
|
1356
|
+
* Marketing containment (MKT-CONTAIN, WI-0043). LRP-001 passes
|
|
1357
|
+
* overflow-wrap: break-word to arbitrary content inside the application
|
|
1358
|
+
* containers; marketing components hold the same kind of content (list
|
|
1359
|
+
* items, spans and divs that the element rule above does not reach), so
|
|
1360
|
+
* they pass it on too. break-word never splits a word that fits and leaves
|
|
1361
|
+
* min-content sizes alone; declared values (badges, steps, code) win over
|
|
1362
|
+
* inheritance. Found by the EDF site at 320px: list words escaped a card at
|
|
1363
|
+
* 200% text and a release URL in a span escaped it at every width.
|
|
1364
|
+
*/
|
|
1365
|
+
.ef-site :where(
|
|
1366
|
+
.ef-hero, .ef-section-heading, .ef-card, .ef-facts, .ef-index, .ef-callout,
|
|
1367
|
+
.ef-cta, .ef-split, .ef-doc, .ef-prose, .ef-statement, .ef-site-footer
|
|
1368
|
+
) {
|
|
1369
|
+
overflow-wrap: break-word;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1308
1372
|
.ef-site :where(hr) {
|
|
1309
1373
|
margin-block: var(--ef-layout-section-space);
|
|
1310
1374
|
border: 0;
|
|
@@ -1887,6 +1951,10 @@
|
|
|
1887
1951
|
|
|
1888
1952
|
.ef-cta[data-ef-tone]:not([data-ef-tone="page"]) {
|
|
1889
1953
|
padding: calc(var(--ef-layout-block-space) * 2);
|
|
1954
|
+
/* Same cap as a toned .ef-section (ULI-L05): at 320px and 200% text the
|
|
1955
|
+
uncapped inline padding left a 96px content box (MKT-CONTAIN). It
|
|
1956
|
+
changes nothing at the default text size. */
|
|
1957
|
+
padding-inline: min(calc(var(--ef-layout-block-space) * 2), 12.5vw);
|
|
1890
1958
|
border-block-start: 0;
|
|
1891
1959
|
}
|
|
1892
1960
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"forma": "0.
|
|
3
|
+
"forma": "0.13.0",
|
|
4
4
|
"bundle": "echelon-marketing",
|
|
5
5
|
"description": "Forma presentation artifacts for Echelon marketing sites. Every artifact is a single self-contained CSS file with no imports or relative URLs.",
|
|
6
|
-
"releaseTag": "v0.
|
|
6
|
+
"releaseTag": "v0.13.0",
|
|
7
7
|
"artifacts": [
|
|
8
8
|
{
|
|
9
9
|
"file": "forma-marketing.css",
|
|
10
10
|
"description": "Brand-neutral marketing layer: core tokens, role defaults, Forma foundations, marketing foundations, components, and layouts.",
|
|
11
|
-
"bytes":
|
|
12
|
-
"sha256": "
|
|
11
|
+
"bytes": 96137,
|
|
12
|
+
"sha256": "08c70e579ad93f28d2dedf5d4c5e1b7d455773acff50b19a2a688f132123b9df",
|
|
13
13
|
"sources": [
|
|
14
14
|
"dist/tokens.css",
|
|
15
15
|
"src/marketing/roles.css",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"file": "forma-marketing-theme-echelon.css",
|
|
24
24
|
"description": "Echelon Marketing Theme: compiled theme tokens and theme hooks. Load after forma-marketing.css.",
|
|
25
25
|
"bytes": 6296,
|
|
26
|
-
"sha256": "
|
|
26
|
+
"sha256": "0b803cade4de294a2e15ac8a8482cb2448617a2558d70fc6542a4b3b9a357bf2",
|
|
27
27
|
"sources": [
|
|
28
28
|
"build/marketing/echelon-marketing.tokens.css",
|
|
29
29
|
"themes/echelon/marketing.css"
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
{
|
|
33
33
|
"file": "forma-echelon-marketing.css",
|
|
34
34
|
"description": "Single-file Echelon marketing presentation: forma-marketing.css followed by the Echelon theme. Most sites load only this file.",
|
|
35
|
-
"bytes":
|
|
36
|
-
"sha256": "
|
|
35
|
+
"bytes": 102277,
|
|
36
|
+
"sha256": "d440c969a3defb5e8eb51043da8b66d3faa29094a429ece6a046deeea98377cb",
|
|
37
37
|
"sources": [
|
|
38
38
|
"artifact:forma-marketing.css",
|
|
39
39
|
"artifact:forma-marketing-theme-echelon.css"
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
{
|
|
43
43
|
"file": "forma-all.css",
|
|
44
44
|
"description": "Complete Forma surface (application components, assessment, and marketing layers) for sites that also present Forma application patterns such as data grids or status lozenges.",
|
|
45
|
-
"bytes":
|
|
46
|
-
"sha256": "
|
|
45
|
+
"bytes": 346402,
|
|
46
|
+
"sha256": "0ba4ce2e6ef70b94000a725780d90e0061b312960265387bc3e2e06c34b92484",
|
|
47
47
|
"sources": [
|
|
48
48
|
"dist/all.css"
|
|
49
49
|
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
08c70e579ad93f28d2dedf5d4c5e1b7d455773acff50b19a2a688f132123b9df forma-marketing.css
|
|
2
|
+
0b803cade4de294a2e15ac8a8482cb2448617a2558d70fc6542a4b3b9a357bf2 forma-marketing-theme-echelon.css
|
|
3
|
+
d440c969a3defb5e8eb51043da8b66d3faa29094a429ece6a046deeea98377cb forma-echelon-marketing.css
|
|
4
|
+
0ba4ce2e6ef70b94000a725780d90e0061b312960265387bc3e2e06c34b92484 forma-all.css
|
package/dist/marketing.css
CHANGED
|
@@ -285,6 +285,23 @@
|
|
|
285
285
|
overflow-wrap: anywhere;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/*
|
|
289
|
+
* Marketing containment (MKT-CONTAIN, WI-0043). LRP-001 passes
|
|
290
|
+
* overflow-wrap: break-word to arbitrary content inside the application
|
|
291
|
+
* containers; marketing components hold the same kind of content (list
|
|
292
|
+
* items, spans and divs that the element rule above does not reach), so
|
|
293
|
+
* they pass it on too. break-word never splits a word that fits and leaves
|
|
294
|
+
* min-content sizes alone; declared values (badges, steps, code) win over
|
|
295
|
+
* inheritance. Found by the EDF site at 320px: list words escaped a card at
|
|
296
|
+
* 200% text and a release URL in a span escaped it at every width.
|
|
297
|
+
*/
|
|
298
|
+
.ef-site :where(
|
|
299
|
+
.ef-hero, .ef-section-heading, .ef-card, .ef-facts, .ef-index, .ef-callout,
|
|
300
|
+
.ef-cta, .ef-split, .ef-doc, .ef-prose, .ef-statement, .ef-site-footer
|
|
301
|
+
) {
|
|
302
|
+
overflow-wrap: break-word;
|
|
303
|
+
}
|
|
304
|
+
|
|
288
305
|
.ef-site :where(hr) {
|
|
289
306
|
margin-block: var(--ef-layout-section-space);
|
|
290
307
|
border: 0;
|
|
@@ -866,6 +883,10 @@
|
|
|
866
883
|
|
|
867
884
|
.ef-cta[data-ef-tone]:not([data-ef-tone="page"]) {
|
|
868
885
|
padding: calc(var(--ef-layout-block-space) * 2);
|
|
886
|
+
/* Same cap as a toned .ef-section (ULI-L05): at 320px and 200% text the
|
|
887
|
+
uncapped inline padding left a 96px content box (MKT-CONTAIN). It
|
|
888
|
+
changes nothing at the default text size. */
|
|
889
|
+
padding-inline: min(calc(var(--ef-layout-block-space) * 2), 12.5vw);
|
|
869
890
|
border-block-start: 0;
|
|
870
891
|
}
|
|
871
892
|
|
package/dist/runtime.js
CHANGED
|
@@ -52,6 +52,6 @@ export function registerEchelonRuntime(name, metadata, target = globalThis) {
|
|
|
52
52
|
return installEchelonRuntimeRegistrar(target)?.(name, metadata);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export const FORMA_VERSION = "0.
|
|
55
|
+
export const FORMA_VERSION = "0.13.0";
|
|
56
56
|
|
|
57
57
|
if (typeof window !== "undefined") registerEchelonRuntime("forma", { version: FORMA_VERSION }, window);
|
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.264.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>
|