@jablonowski/dsb-tokens 1.0.2 → 1.0.3

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": "@jablonowski/dsb-tokens",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "",
5
5
  "license": "MIT",
6
6
  "author": "Mateusz Jablonowski",
@@ -9,7 +9,11 @@
9
9
  "scripts": {
10
10
  "build": "style-dictionary build --config config.js",
11
11
  "release": "npm run build && npm version patch && npm publish --access public",
12
- "test": "echo \"Error: no test specified\" && exit 1"
12
+ "lint:json": "node scripts/validate-json.js",
13
+ "lint:schema": "node scripts/validate-schema.js",
14
+ "lint": "npm run lint:json && npm run lint:schema",
15
+ "test": "node --test test/tokens.test.js",
16
+ "ci": "npm run lint && npm run build && npm test"
13
17
  },
14
18
  "repository": {
15
19
  "type": "git",
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Layer 1 — JSON syntax validation
6
+ * Checks that tokens/tokens.json is well-formed JSON.
7
+ * Exits with code 1 and prints the parse error on failure.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const FILE = path.resolve(__dirname, '../tokens/tokens.json');
14
+
15
+ let raw;
16
+ try {
17
+ raw = fs.readFileSync(FILE, 'utf8');
18
+ } catch (err) {
19
+ console.error(`[validate-json] Cannot read file: ${FILE}`);
20
+ console.error(err.message);
21
+ process.exit(1);
22
+ }
23
+
24
+ try {
25
+ JSON.parse(raw);
26
+ console.log('[validate-json] ✓ tokens.json is valid JSON');
27
+ } catch (err) {
28
+ console.error('[validate-json] ✗ tokens.json is not valid JSON');
29
+ console.error(err.message);
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Layer 2 — Token schema validation
6
+ *
7
+ * Recursively walks tokens.json and enforces the Style Dictionary
8
+ * three-tier token schema:
9
+ *
10
+ * Token leaf: { "value": string, "comment"?: string } — no other keys
11
+ * Group node: any key whose value is an object (not a leaf)
12
+ * Group comment: a "comment" string on a group node — allowed, skipped
13
+ *
14
+ * Additional format rules applied to leaf values:
15
+ * • References — must match {path.to.token} (curly-brace syntax)
16
+ * • Hex colors — must be #rrggbb (6-digit lowercase hex)
17
+ * • RGBA colors — must match rgba(r, g, b, a)
18
+ *
19
+ * Implements the validation step recommended by:
20
+ * https://martinfowler.com/articles/design-token-based-ui-architecture.html
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const FILE = path.resolve(__dirname, '../tokens/tokens.json');
27
+
28
+ // ─── Regex patterns ───────────────────────────────────────────────────────────
29
+
30
+ const RE_REFERENCE = /^\{[\w./#-]+\}$/;
31
+ const RE_HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
32
+ const RE_RGBA = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
33
+
34
+ // ─── Recursive validator ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * @param {unknown} node
38
+ * @param {string} nodePath Dot-notation path for error reporting
39
+ * @param {string[]} errors Accumulated error messages
40
+ */
41
+ function validateNode(node, nodePath, errors) {
42
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) {
43
+ errors.push(`${nodePath}: expected an object node, got ${Array.isArray(node) ? 'array' : typeof node}`);
44
+ return;
45
+ }
46
+
47
+ const obj = /** @type {Record<string, unknown>} */ (node);
48
+
49
+ if ('value' in obj) {
50
+ validateLeaf(obj, nodePath, errors);
51
+ } else {
52
+ // Group node — recurse into every child except the optional 'comment' string
53
+ for (const [key, child] of Object.entries(obj)) {
54
+ if (key === 'comment') {
55
+ if (typeof child !== 'string') {
56
+ errors.push(`${nodePath}.comment: group comment must be a string`);
57
+ }
58
+ continue;
59
+ }
60
+ validateNode(child, nodePath ? `${nodePath}.${key}` : key, errors);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param {Record<string, unknown>} token
67
+ * @param {string} tokenPath
68
+ * @param {string[]} errors
69
+ */
70
+ function validateLeaf(token, tokenPath, errors) {
71
+ // 1. Value must be a string (Style Dictionary uses string values for all types)
72
+ if (typeof token.value !== 'string') {
73
+ errors.push(`${tokenPath}: 'value' must be a string, got ${typeof token.value}`);
74
+ }
75
+
76
+ // 2. No unexpected keys — only 'value' and 'comment' are allowed on a leaf
77
+ const ALLOWED = new Set(['value', 'comment']);
78
+ for (const key of Object.keys(token)) {
79
+ if (!ALLOWED.has(key)) {
80
+ errors.push(`${tokenPath}: unexpected key '${key}' on token leaf (only 'value' and 'comment' are allowed)`);
81
+ }
82
+ }
83
+
84
+ if (typeof token.value !== 'string') return; // already reported above
85
+
86
+ const val = token.value;
87
+
88
+ // 3. Reference syntax: value is entirely a {path.to.token} reference
89
+ if (val.startsWith('{')) {
90
+ if (!RE_REFERENCE.test(val)) {
91
+ errors.push(`${tokenPath}: malformed reference '${val}' — expected format: {category.group.name}`);
92
+ }
93
+ return; // no further format checks on references
94
+ }
95
+
96
+ // 4. Hex color format
97
+ if (val.startsWith('#')) {
98
+ if (!RE_HEX_COLOR.test(val)) {
99
+ errors.push(`${tokenPath}: hex color '${val}' must be exactly 6 hex digits (e.g. #1a7f3c)`);
100
+ }
101
+ return;
102
+ }
103
+
104
+ // 5. RGBA color format
105
+ if (val.startsWith('rgba')) {
106
+ if (!RE_RGBA.test(val)) {
107
+ errors.push(`${tokenPath}: rgba value '${val}' is malformed`);
108
+ }
109
+ return;
110
+ }
111
+
112
+ // All other formats (px, %, s, plain numbers, keywords like 'ease', 'transparent') are valid as-is
113
+ }
114
+
115
+ // ─── Run ──────────────────────────────────────────────────────────────────────
116
+
117
+ let tokens;
118
+ try {
119
+ tokens = JSON.parse(fs.readFileSync(FILE, 'utf8'));
120
+ } catch (err) {
121
+ console.error(`[validate-schema] Cannot read/parse ${FILE}`);
122
+ console.error(err.message);
123
+ process.exit(1);
124
+ }
125
+
126
+ const errors = [];
127
+ validateNode(tokens, '', errors);
128
+
129
+ if (errors.length === 0) {
130
+ console.log(`[validate-schema] ✓ Token schema is valid`);
131
+ } else {
132
+ console.error(`[validate-schema] ✗ Found ${errors.length} schema error(s):\n`);
133
+ for (const e of errors) {
134
+ console.error(` • ${e}`);
135
+ }
136
+ process.exit(1);
137
+ }
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Layer 3 — Style Dictionary output tests
5
+ *
6
+ * Asserts that the built dist/css/variables.css contains the expected
7
+ * CSS custom property declarations. Requires `npm run build` to have
8
+ * run first (the CI workflow handles this ordering).
9
+ *
10
+ * Variable names are verified against the actual component CSS files in
11
+ * components/src/components/ — e.g. button.component.css uses
12
+ * var(--ds-component-button-border-radius) and var(--ds-decisions-font-weight-medium).
13
+ *
14
+ * Test selection covers:
15
+ * • A raw color option (tier 1)
16
+ * • A raw non-color option to verify unit conversion (border-radius full → 50%)
17
+ * • A decision alias resolved via var() (tier 2, with outputReferences: true)
18
+ * • A component float alias resolved via var() (tier 3)
19
+ * • A three-tier alias chain: component → decision → option
20
+ */
21
+
22
+ const { describe, it, before } = require('node:test');
23
+ const assert = require('node:assert/strict');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const CSS_FILE = path.resolve(__dirname, '../dist/css/variables.css');
28
+
29
+ let css = '';
30
+
31
+ describe('Style Dictionary output — dist/css/variables.css', () => {
32
+ before(() => {
33
+ assert.ok(
34
+ fs.existsSync(CSS_FILE),
35
+ `Build output not found at ${CSS_FILE}. Run 'npm run build' before running tests.`
36
+ );
37
+ css = fs.readFileSync(CSS_FILE, 'utf8');
38
+ assert.ok(css.length > 0, 'CSS output file must not be empty');
39
+ });
40
+
41
+ // ─── Tier 1 — Options: raw values ─────────────────────────────────────────
42
+
43
+ describe('Tier 1 — Options: raw values', () => {
44
+ it('exports neutral-900 as #111111 (darkest neutral)', () => {
45
+ assert.match(css, /--ds-color-options-neutral-900:\s*#111111/);
46
+ });
47
+
48
+ it('exports neutral-0 as #ffffff (white)', () => {
49
+ assert.match(css, /--ds-color-options-neutral-0:\s*#ffffff/);
50
+ });
51
+
52
+ it('exports error red-500 as #d93025', () => {
53
+ assert.match(css, /--ds-color-options-red-500:\s*#d93025/);
54
+ });
55
+
56
+ it('exports success green-700 as #1a7f3c', () => {
57
+ assert.match(css, /--ds-color-options-green-700:\s*#1a7f3c/);
58
+ });
59
+
60
+ it('exports rgba black overlay (a40) with correct alpha', () => {
61
+ // rgba(0, 0, 0, 0.4) — used for modal backdrop
62
+ assert.match(css, /--ds-color-options-black-a40:\s*rgba\(0,\s*0,\s*0,\s*0\.4\)/);
63
+ });
64
+
65
+ it('exports border-radius full as 50% (not 9999px)', () => {
66
+ assert.match(css, /--ds-border-radius-options-full:\s*50%/);
67
+ });
68
+
69
+ it('exports easing standard as "ease"', () => {
70
+ assert.match(css, /--ds-easing-options-standard:\s*ease/);
71
+ });
72
+ });
73
+
74
+ // ─── Tier 2 — Decisions: alias references via var() ───────────────────────
75
+
76
+ describe('Tier 2 — Decisions: var() alias references', () => {
77
+ it('color.text.primary references options neutral-900', () => {
78
+ // decisions.color.text.primary → {color.options.neutral.900}
79
+ assert.match(
80
+ css,
81
+ /--ds-decisions-color-text-primary:\s*var\(--ds-color-options-neutral-900\)/
82
+ );
83
+ });
84
+
85
+ it('color.text.inverse references options neutral-0', () => {
86
+ // decisions.color.text.inverse → {color.options.neutral.0}
87
+ assert.match(
88
+ css,
89
+ /--ds-decisions-color-text-inverse:\s*var\(--ds-color-options-neutral-0\)/
90
+ );
91
+ });
92
+
93
+ it('color.feedback.error.icon references options red-500', () => {
94
+ assert.match(
95
+ css,
96
+ /--ds-decisions-color-feedback-error-icon:\s*var\(--ds-color-options-red-500\)/
97
+ );
98
+ });
99
+
100
+ it('font.size.md references options font-size-14', () => {
101
+ // decisions.font.size.md → {font.size.options.14}
102
+ assert.match(
103
+ css,
104
+ /--ds-decisions-font-size-md:\s*var\(--ds-font-size-options-14\)/
105
+ );
106
+ });
107
+
108
+ it('font.weight.medium references options font-weight-medium', () => {
109
+ assert.match(
110
+ css,
111
+ /--ds-decisions-font-weight-medium:\s*var\(--ds-font-weight-options-medium\)/
112
+ );
113
+ });
114
+
115
+ it('border.radius.lg references options border-radius-6', () => {
116
+ // decisions.border.radius.lg → {border.radius.options.6}
117
+ assert.match(
118
+ css,
119
+ /--ds-decisions-border-radius-lg:\s*var\(--ds-border-radius-options-6\)/
120
+ );
121
+ });
122
+
123
+ it('border.radius.full references options border-radius-full', () => {
124
+ assert.match(
125
+ css,
126
+ /--ds-decisions-border-radius-full:\s*var\(--ds-border-radius-options-full\)/
127
+ );
128
+ });
129
+ });
130
+
131
+ // ─── Tier 3 — Components: three-tier alias chain ──────────────────────────
132
+
133
+ describe('Tier 3 — Components: three-tier var() chains', () => {
134
+ it('button.borderRadius references decisions border-radius-lg', () => {
135
+ // component.button.borderRadius → {decisions.border.radius.lg}
136
+ // camelCase borderRadius → kebab border-radius confirmed in button.component.css
137
+ assert.match(
138
+ css,
139
+ /--ds-component-button-border-radius:\s*var\(--ds-decisions-border-radius-lg\)/
140
+ );
141
+ });
142
+
143
+ it('button.primary.background references decisions color.text.primary', () => {
144
+ // component.button.primary.background → {decisions.color.text.primary}
145
+ assert.match(
146
+ css,
147
+ /--ds-component-button-primary-background:\s*var\(--ds-decisions-color-text-primary\)/
148
+ );
149
+ });
150
+
151
+ it('button.primary.text references decisions color.text.inverse', () => {
152
+ assert.match(
153
+ css,
154
+ /--ds-component-button-primary-text:\s*var\(--ds-decisions-color-text-inverse\)/
155
+ );
156
+ });
157
+
158
+ it('input.borderRadius references decisions border-radius-lg', () => {
159
+ assert.match(
160
+ css,
161
+ /--ds-component-input-border-radius:\s*var\(--ds-decisions-border-radius-lg\)/
162
+ );
163
+ });
164
+
165
+ it('modal.backdrop references decisions color.overlay.backdrop', () => {
166
+ assert.match(
167
+ css,
168
+ /--ds-component-modal-backdrop:\s*var\(--ds-decisions-color-overlay-backdrop\)/
169
+ );
170
+ });
171
+
172
+ it('tag.success.background references decisions feedback.success.surface', () => {
173
+ assert.match(
174
+ css,
175
+ /--ds-component-tag-success-background:\s*var\(--ds-decisions-color-feedback-success-surface\)/
176
+ );
177
+ });
178
+ });
179
+ });