@servicetitan/stylelint-config 40.1.0 → 40.2.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/dist/function-url-no-absolute.d.ts.map +1 -1
- package/dist/function-url-no-absolute.js +1 -7
- package/dist/function-url-no-absolute.js.map +1 -1
- package/dist/use-tokens/index.d.ts +2 -7
- package/dist/use-tokens/index.d.ts.map +1 -1
- package/dist/use-tokens/index.js +2 -114
- package/dist/use-tokens/index.js.map +1 -1
- package/dist/use-tokens/load-tokens.d.ts +11 -0
- package/dist/use-tokens/load-tokens.d.ts.map +1 -0
- package/dist/use-tokens/load-tokens.js +97 -0
- package/dist/use-tokens/load-tokens.js.map +1 -0
- package/dist/use-tokens/use-tokens.d.ts +9 -0
- package/dist/use-tokens/use-tokens.d.ts.map +1 -0
- package/dist/use-tokens/use-tokens.js +169 -0
- package/dist/use-tokens/use-tokens.js.map +1 -0
- package/dist/use-tokens/values.d.ts +11 -9
- package/dist/use-tokens/values.d.ts.map +1 -1
- package/dist/use-tokens/values.js +59 -22
- package/dist/use-tokens/values.js.map +1 -1
- package/package.json +12 -4
- package/src/__tests__/function-url-no-absolute.test.ts +84 -0
- package/src/function-url-no-absolute.ts +1 -2
- package/src/use-tokens/__tests__/load-tokens.test.ts +135 -0
- package/src/use-tokens/__tests__/use-tokens.test.ts +414 -0
- package/src/use-tokens/index.ts +2 -162
- package/src/use-tokens/load-tokens.ts +103 -0
- package/src/use-tokens/use-tokens.ts +214 -0
- package/src/use-tokens/values.ts +55 -37
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import postcss from 'postcss';
|
|
2
|
+
import type { Token } from '../load-tokens';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Stylelint is ESM-only, and Jest can only load ESM-only packages on Node 24.9+ with
|
|
6
|
+
* --experimental-vm-modules, so the rule runs directly against a postcss tree with stylelint's
|
|
7
|
+
* plugin utilities replaced.
|
|
8
|
+
*/
|
|
9
|
+
jest.mock('stylelint', () => ({
|
|
10
|
+
createPlugin: (ruleName: string, rule: unknown) => ({ ruleName, rule }),
|
|
11
|
+
utils: {
|
|
12
|
+
ruleMessages: (_ruleName: string, messages: unknown) => messages,
|
|
13
|
+
report: mockReport,
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
jest.mock('../load-tokens', () => ({
|
|
17
|
+
...jest.requireActual('../load-tokens'),
|
|
18
|
+
loadTokens: () => mockTokens,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const mockReport = jest.fn();
|
|
22
|
+
const mockTokens: Token[] = [];
|
|
23
|
+
|
|
24
|
+
describe('[stylelint-config] @servicetitan/use-tokens rule', () => {
|
|
25
|
+
const value = '#0265dc';
|
|
26
|
+
const designSystemToken: Token = {
|
|
27
|
+
name: 'color-blue-500',
|
|
28
|
+
customProperty: '--color-blue-500',
|
|
29
|
+
source: '@servicetitan/tokens',
|
|
30
|
+
value,
|
|
31
|
+
};
|
|
32
|
+
const anvil2Token: Token = {
|
|
33
|
+
name: 'color-blue-600',
|
|
34
|
+
customProperty: '--a2-color-blue-600',
|
|
35
|
+
source: '@servicetitan/anvil2',
|
|
36
|
+
value,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let declaration: string;
|
|
40
|
+
let codeFilename: string | undefined;
|
|
41
|
+
let fix: boolean;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.clearAllMocks();
|
|
45
|
+
mockTokens.splice(0);
|
|
46
|
+
declaration = `color: ${value};`;
|
|
47
|
+
codeFilename = 'styles.css';
|
|
48
|
+
fix = false;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const subject = () => {
|
|
52
|
+
let rule: any;
|
|
53
|
+
// Rule initializes state when it loads, so load it after tests have set up tokens.
|
|
54
|
+
jest.isolateModules(() => (rule = require('../use-tokens').rule));
|
|
55
|
+
const root = postcss.parse(`a { ${declaration} }`);
|
|
56
|
+
rule(true, {}, { fix })(root, { opts: { from: codeFilename } });
|
|
57
|
+
return {
|
|
58
|
+
messages: mockReport.mock.calls.map(([{ message }]) => message),
|
|
59
|
+
words: mockReport.mock.calls.map(([{ word }]) => word),
|
|
60
|
+
code: root.toString(),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function itReportsNothing() {
|
|
65
|
+
test('reports nothing', () => {
|
|
66
|
+
expect(subject().messages).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function itNegatesCustomPropertyWithCalc(customProperty: string) {
|
|
71
|
+
test('negates its custom property with calc()', () => {
|
|
72
|
+
expect(subject().messages[0]).toContain(`"calc(-1 * var(${customProperty}))"`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
itReportsNothing();
|
|
77
|
+
|
|
78
|
+
describe('when the value matches a design-system token', () => {
|
|
79
|
+
beforeEach(() => mockTokens.push({ ...designSystemToken }));
|
|
80
|
+
|
|
81
|
+
test('reports its custom property', () => {
|
|
82
|
+
expect(subject().messages).toEqual([
|
|
83
|
+
`You should use "var(${designSystemToken.customProperty})" from \`${designSystemToken.source}\` instead of "${value}".`,
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('with a Less file', () => {
|
|
88
|
+
beforeEach(() => (codeFilename = 'styles.less'));
|
|
89
|
+
|
|
90
|
+
test('reports its Less variable', () => {
|
|
91
|
+
expect(subject().messages[0]).toContain(`"@${designSystemToken.name}"`);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe.each(['styles.scss', 'styles.sass'])('with %s', filename => {
|
|
96
|
+
beforeEach(() => (codeFilename = filename));
|
|
97
|
+
|
|
98
|
+
test('reports its Sass variable', () => {
|
|
99
|
+
expect(subject().messages[0]).toContain(`"$${designSystemToken.name}"`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('without a file name', () => {
|
|
104
|
+
beforeEach(() => (codeFilename = undefined));
|
|
105
|
+
|
|
106
|
+
test('reports its custom property', () => {
|
|
107
|
+
expect(subject().messages[0]).toContain(
|
|
108
|
+
`"var(${designSystemToken.customProperty})"`
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('when fixing', () => {
|
|
114
|
+
beforeEach(() => (fix = true));
|
|
115
|
+
|
|
116
|
+
test('replaces the value', () => {
|
|
117
|
+
expect(subject().code).toBe(
|
|
118
|
+
`a { color: var(${designSystemToken.customProperty}); }`
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe.each([
|
|
124
|
+
['an uppercase hex color', '#0265DC'],
|
|
125
|
+
['an rgb() color', 'rgb(2, 101, 220)'],
|
|
126
|
+
['a space-separated rgb() color', 'rgb(2 101 220)'],
|
|
127
|
+
['an rgb() color among other values', '1px solid rgb(2, 101, 220)'],
|
|
128
|
+
['a hex color in a gradient', 'linear-gradient(90deg, #0265dc 0%, #fff 100%)'],
|
|
129
|
+
[
|
|
130
|
+
'an rgb() color in a gradient',
|
|
131
|
+
'linear-gradient(90deg, rgb(2, 101, 220) 0%, #fff 100%)',
|
|
132
|
+
],
|
|
133
|
+
])('with %s', (_description, color) => {
|
|
134
|
+
beforeEach(() => (declaration = `color: ${color};`));
|
|
135
|
+
|
|
136
|
+
test('reports its custom property', () => {
|
|
137
|
+
expect(subject().messages[0]).toContain(
|
|
138
|
+
`"var(${designSystemToken.customProperty})"`
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe.each([
|
|
144
|
+
['a translucent rgba() color', 'rgba(2, 101, 220, 0.5)'],
|
|
145
|
+
['a translucent hex color', '#0265dc80'],
|
|
146
|
+
])('with %s', (_description, color) => {
|
|
147
|
+
beforeEach(() => (declaration = `color: ${color};`));
|
|
148
|
+
|
|
149
|
+
itReportsNothing();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('with a color among other values', () => {
|
|
153
|
+
beforeEach(() => (declaration = `border: 1px solid ${value};`));
|
|
154
|
+
|
|
155
|
+
test('reports only the color', () => {
|
|
156
|
+
expect(subject().messages).toEqual([
|
|
157
|
+
expect.stringContaining(`"var(${designSystemToken.customProperty})"`),
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('reports the color as written', () => {
|
|
162
|
+
expect(subject().words).toEqual([value]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('when fixing', () => {
|
|
166
|
+
beforeEach(() => (fix = true));
|
|
167
|
+
|
|
168
|
+
function itReplacesOnlyTheColor() {
|
|
169
|
+
test('replaces only the color', () => {
|
|
170
|
+
expect(subject().code).toBe(
|
|
171
|
+
`a { border: 1px solid var(${designSystemToken.customProperty}); }`
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
itReplacesOnlyTheColor();
|
|
177
|
+
|
|
178
|
+
describe('with an rgb() color', () => {
|
|
179
|
+
beforeEach(() => (declaration = 'border: 1px solid rgb(2, 101, 220);'));
|
|
180
|
+
|
|
181
|
+
itReplacesOnlyTheColor();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('with several matching values', () => {
|
|
187
|
+
const spacing1: Token = {
|
|
188
|
+
...designSystemToken,
|
|
189
|
+
name: 'spacing-1',
|
|
190
|
+
customProperty: '--spacing-1',
|
|
191
|
+
value: '8px',
|
|
192
|
+
};
|
|
193
|
+
const spacing2: Token = {
|
|
194
|
+
...designSystemToken,
|
|
195
|
+
name: 'spacing-2',
|
|
196
|
+
customProperty: '--spacing-2',
|
|
197
|
+
value: '16px',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
mockTokens.push(spacing1, spacing2);
|
|
202
|
+
declaration = `margin: ${spacing1.value} ${spacing2.value};`;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('reports each value', () => {
|
|
206
|
+
expect(subject().messages).toEqual([
|
|
207
|
+
expect.stringContaining(`"var(${spacing1.customProperty})"`),
|
|
208
|
+
expect.stringContaining(`"var(${spacing2.customProperty})"`),
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('when fixing', () => {
|
|
213
|
+
beforeEach(() => (fix = true));
|
|
214
|
+
|
|
215
|
+
test('replaces each value', () => {
|
|
216
|
+
expect(subject().code).toBe(
|
|
217
|
+
`a { margin: var(${spacing1.customProperty}) var(${spacing2.customProperty}); }`
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('with a negative value', () => {
|
|
223
|
+
beforeEach(() => (declaration = `margin: -${spacing2.value};`));
|
|
224
|
+
|
|
225
|
+
itNegatesCustomPropertyWithCalc(spacing2.customProperty);
|
|
226
|
+
|
|
227
|
+
test('reports the value with its minus sign', () => {
|
|
228
|
+
expect(subject().words).toEqual([`-${spacing2.value}`]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('with a Sass file', () => {
|
|
232
|
+
beforeEach(() => (codeFilename = 'styles.scss'));
|
|
233
|
+
|
|
234
|
+
test('negates its Sass variable', () => {
|
|
235
|
+
expect(subject().messages[0]).toContain(`"-$${spacing2.name}"`);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('with a Less file', () => {
|
|
240
|
+
beforeEach(() => (codeFilename = 'styles.less'));
|
|
241
|
+
|
|
242
|
+
test('negates its Less variable', () => {
|
|
243
|
+
expect(subject().messages[0]).toContain(`"-@${spacing2.name}"`);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('when fixing', () => {
|
|
248
|
+
beforeEach(() => (fix = true));
|
|
249
|
+
|
|
250
|
+
test('replaces the value', () => {
|
|
251
|
+
expect(subject().code).toBe(
|
|
252
|
+
`a { margin: calc(-1 * var(${spacing2.customProperty})); }`
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe.each([
|
|
260
|
+
['typescale-2', 'font-size', '14px', '14px'],
|
|
261
|
+
['border-radius-1', 'border-radius', '3px', '3px'],
|
|
262
|
+
['border-radius-0', 'border-radius', '0px', '0'],
|
|
263
|
+
['border-radius-1', 'border-top-left-radius', '3px', '3px'],
|
|
264
|
+
['spacing-1', 'margin', '8px', '8px'],
|
|
265
|
+
['spacing-0', 'margin', '0px', '0'],
|
|
266
|
+
['font-weight-semibold', 'font-weight', '600', '600'],
|
|
267
|
+
['opacity-disabled', 'opacity', '0.6', '.6'],
|
|
268
|
+
['opacity-disabled', 'opacity', '.6', '.6'],
|
|
269
|
+
['z-index-drawer', 'z-index', '1100', '1100'],
|
|
270
|
+
['base-line-height', 'line-height', '1.5', '1.5'],
|
|
271
|
+
[
|
|
272
|
+
'base-font-family',
|
|
273
|
+
'font-family',
|
|
274
|
+
"'Nunito Sans', sans-serif",
|
|
275
|
+
"'Nunito Sans', sans-serif",
|
|
276
|
+
],
|
|
277
|
+
])('when %s replaces %s: %s', (name, property, literal, tokenValue) => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
Object.assign(mockTokens[0], {
|
|
280
|
+
name,
|
|
281
|
+
customProperty: `--${name}`,
|
|
282
|
+
value: tokenValue,
|
|
283
|
+
});
|
|
284
|
+
declaration = `${property}: ${literal};`;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test(`reports var(--${name})`, () => {
|
|
288
|
+
expect(subject().messages[0]).toContain(`"var(--${name})"`);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('when the value matches an anvil2 token', () => {
|
|
294
|
+
beforeEach(() => mockTokens.push({ ...anvil2Token }));
|
|
295
|
+
|
|
296
|
+
test('reports its custom property', () => {
|
|
297
|
+
expect(subject().messages).toEqual([
|
|
298
|
+
`You should use "var(${anvil2Token.customProperty})" from \`${anvil2Token.source}\` instead of "${value}".`,
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('with a Less file', () => {
|
|
303
|
+
beforeEach(() => (codeFilename = 'styles.less'));
|
|
304
|
+
|
|
305
|
+
test('reports its custom property', () => {
|
|
306
|
+
expect(subject().messages[0]).toContain(`"var(${anvil2Token.customProperty})"`);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('with a Sass file', () => {
|
|
311
|
+
beforeEach(() => (codeFilename = 'styles.scss'));
|
|
312
|
+
|
|
313
|
+
test('reports its Sass variable', () => {
|
|
314
|
+
expect(subject().messages[0]).toContain(`"$${anvil2Token.name}"`);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('with an unsupported color format', () => {
|
|
319
|
+
beforeEach(() => (mockTokens[0].value = 'oklch(70% 0.1 200)'));
|
|
320
|
+
|
|
321
|
+
itReportsNothing();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('with a negative spacing value', () => {
|
|
325
|
+
const spacing: Pick<Token, 'name' | 'customProperty' | 'value'> = {
|
|
326
|
+
name: 'size-1',
|
|
327
|
+
customProperty: '--a2-size-1',
|
|
328
|
+
value: '0.25rem',
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
Object.assign(mockTokens[0], spacing);
|
|
333
|
+
declaration = `margin: -${spacing.value};`;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
itNegatesCustomPropertyWithCalc(spacing.customProperty);
|
|
337
|
+
|
|
338
|
+
describe('with a Sass file', () => {
|
|
339
|
+
beforeEach(() => (codeFilename = 'styles.scss'));
|
|
340
|
+
|
|
341
|
+
test('negates its Sass variable with calc()', () => {
|
|
342
|
+
expect(subject().messages[0]).toContain(`"calc(-1 * $${spacing.name})"`);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('with a Less file', () => {
|
|
347
|
+
beforeEach(() => (codeFilename = 'styles.less'));
|
|
348
|
+
|
|
349
|
+
itNegatesCustomPropertyWithCalc(spacing.customProperty);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe.each([
|
|
354
|
+
['font-size-300', 'font-size', '0.875rem'],
|
|
355
|
+
['radius-1', 'border-radius', '0.1875rem'],
|
|
356
|
+
['size-1', 'padding', '0.25rem'],
|
|
357
|
+
['font-weight-bold', 'font-weight', '700'],
|
|
358
|
+
['font-line-height-base', 'line-height', '1.5'],
|
|
359
|
+
['font-family-base', 'font-family', "'Nunito Sans', sans-serif"],
|
|
360
|
+
])('when %s replaces %s: %s', (name, property, value) => {
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
Object.assign(mockTokens[0], { name, customProperty: `--a2-${name}`, value });
|
|
363
|
+
declaration = `${property}: ${value};`;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test(`reports var(--a2-${name})`, () => {
|
|
367
|
+
expect(subject().messages[0]).toContain(`"var(--a2-${name})"`);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('when the value matches tokens from both packages', () => {
|
|
373
|
+
beforeEach(() => mockTokens.push({ ...designSystemToken }, { ...anvil2Token }));
|
|
374
|
+
|
|
375
|
+
test('reports every match', () => {
|
|
376
|
+
expect(subject().messages).toEqual([
|
|
377
|
+
`You should use "var(${designSystemToken.customProperty})" from \`${designSystemToken.source}\`` +
|
|
378
|
+
` or "var(${anvil2Token.customProperty})" from \`${anvil2Token.source}\` instead of "${value}".`,
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('when fixing', () => {
|
|
383
|
+
beforeEach(() => (fix = true));
|
|
384
|
+
|
|
385
|
+
test('leaves the value', () => {
|
|
386
|
+
expect(subject().code).toBe(`a { ${declaration} }`);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('when the tokens share a name', () => {
|
|
391
|
+
const name = 'color-shared';
|
|
392
|
+
|
|
393
|
+
beforeEach(() => mockTokens.forEach(token => (token.name = name)));
|
|
394
|
+
|
|
395
|
+
describe('with a Sass file', () => {
|
|
396
|
+
beforeEach(() => (codeFilename = 'styles.scss'));
|
|
397
|
+
|
|
398
|
+
test('reports the shared variable once', () => {
|
|
399
|
+
expect(subject().messages).toEqual([
|
|
400
|
+
`You should use "$${name}" from \`${mockTokens[0].source}\` instead of "${value}".`,
|
|
401
|
+
]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('when fixing', () => {
|
|
405
|
+
beforeEach(() => (fix = true));
|
|
406
|
+
|
|
407
|
+
test('replaces the value', () => {
|
|
408
|
+
expect(subject().code).toBe(`a { color: $${name}; }`);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
package/src/use-tokens/index.ts
CHANGED
|
@@ -1,163 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Rule, createPlugin, utils } from 'stylelint';
|
|
3
|
-
import {
|
|
4
|
-
borderRadiuses,
|
|
5
|
-
colors,
|
|
6
|
-
fontFamilies,
|
|
7
|
-
fontWeights,
|
|
8
|
-
lineHeights,
|
|
9
|
-
opacities,
|
|
10
|
-
spacings,
|
|
11
|
-
typescales,
|
|
12
|
-
zIndexes,
|
|
13
|
-
} from './values';
|
|
1
|
+
import plugin from './use-tokens';
|
|
14
2
|
|
|
15
|
-
|
|
16
|
-
const messages = utils.ruleMessages(ruleName, {
|
|
17
|
-
variableExpected: (expected: string, found: string) =>
|
|
18
|
-
`You should use "${expected}" variable from the \`@servicetitan/tokens\` instead of "${found}".`,
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
const plugin: Rule = (_0, _1, context) => {
|
|
22
|
-
return (root, result) => {
|
|
23
|
-
const extension = result.opts?.from?.split('.').pop()?.toLowerCase();
|
|
24
|
-
|
|
25
|
-
function convertToVariable(key: string) {
|
|
26
|
-
switch (extension) {
|
|
27
|
-
case 'less':
|
|
28
|
-
return `@${key}`;
|
|
29
|
-
|
|
30
|
-
case 'sass':
|
|
31
|
-
case 'scss':
|
|
32
|
-
return `$${key}`;
|
|
33
|
-
|
|
34
|
-
default:
|
|
35
|
-
return `var(--${key})`;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
root.walkDecls(decl => {
|
|
40
|
-
function lint(
|
|
41
|
-
collection: Record<string, string>,
|
|
42
|
-
{
|
|
43
|
-
condition,
|
|
44
|
-
transformer,
|
|
45
|
-
solid,
|
|
46
|
-
}: {
|
|
47
|
-
condition?: (property: string) => boolean;
|
|
48
|
-
transformer?: (value: string) => string;
|
|
49
|
-
solid?: boolean;
|
|
50
|
-
} = {}
|
|
51
|
-
) {
|
|
52
|
-
if (condition && !condition(decl.prop)) {
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const values = !solid ? decl.value.split(' ') : [decl.value];
|
|
57
|
-
|
|
58
|
-
let reported = false;
|
|
59
|
-
for (const rawValue of values) {
|
|
60
|
-
const value = transformer?.(rawValue) ?? rawValue;
|
|
61
|
-
if (collection[value]) {
|
|
62
|
-
const variable =
|
|
63
|
-
(rawValue.startsWith('-') ? '-' : '') +
|
|
64
|
-
convertToVariable(collection[value]);
|
|
65
|
-
|
|
66
|
-
if (context.fix) {
|
|
67
|
-
values[values.indexOf(rawValue)] = variable;
|
|
68
|
-
} else {
|
|
69
|
-
utils.report({
|
|
70
|
-
node: decl,
|
|
71
|
-
message: messages.variableExpected(variable, rawValue),
|
|
72
|
-
word: value,
|
|
73
|
-
ruleName,
|
|
74
|
-
result,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
reported = true;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (context.fix) {
|
|
82
|
-
decl.value = values.join(' ');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return !reported;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const linters = {
|
|
89
|
-
colors: () =>
|
|
90
|
-
lint(colors, {
|
|
91
|
-
transformer: value => {
|
|
92
|
-
try {
|
|
93
|
-
return new Color(value).hex();
|
|
94
|
-
} catch {
|
|
95
|
-
return '';
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
}),
|
|
99
|
-
|
|
100
|
-
typescales: () =>
|
|
101
|
-
lint(typescales, {
|
|
102
|
-
condition: property => property === 'font-size',
|
|
103
|
-
solid: true,
|
|
104
|
-
}),
|
|
105
|
-
|
|
106
|
-
borderRadiuses: () =>
|
|
107
|
-
lint(borderRadiuses, {
|
|
108
|
-
condition: property =>
|
|
109
|
-
property.startsWith('border-') && property.endsWith('-radius'),
|
|
110
|
-
transformer: value => (value === '0px' ? '0' : value),
|
|
111
|
-
}),
|
|
112
|
-
|
|
113
|
-
lintSpacings: () =>
|
|
114
|
-
lint(spacings, {
|
|
115
|
-
// other spacings like "top"?
|
|
116
|
-
condition: property =>
|
|
117
|
-
property.startsWith('margin') || property.startsWith('padding'),
|
|
118
|
-
transformer: value =>
|
|
119
|
-
value === '0px' ? '0' : value.startsWith('-') ? value.slice(1) : value,
|
|
120
|
-
}),
|
|
121
|
-
|
|
122
|
-
fontWeights: () =>
|
|
123
|
-
lint(fontWeights, {
|
|
124
|
-
condition: property => property === 'font-weight',
|
|
125
|
-
solid: true,
|
|
126
|
-
}),
|
|
127
|
-
|
|
128
|
-
opacities: () =>
|
|
129
|
-
lint(opacities, {
|
|
130
|
-
condition: property => property === 'opacity',
|
|
131
|
-
transformer: value => (value.startsWith('0.') ? value.slice(1) : value),
|
|
132
|
-
solid: true,
|
|
133
|
-
}),
|
|
134
|
-
|
|
135
|
-
zIndexes: () =>
|
|
136
|
-
lint(zIndexes, { condition: property => property === 'z-index', solid: true }),
|
|
137
|
-
|
|
138
|
-
lineHeights: () =>
|
|
139
|
-
lint(lineHeights, {
|
|
140
|
-
condition: property => property === 'line-height',
|
|
141
|
-
solid: true,
|
|
142
|
-
}),
|
|
143
|
-
|
|
144
|
-
fontFamilies: () =>
|
|
145
|
-
lint(fontFamilies, {
|
|
146
|
-
condition: property => property === 'font-family',
|
|
147
|
-
solid: true,
|
|
148
|
-
}),
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
for (const handler of Object.values(linters)) {
|
|
152
|
-
if (!handler()) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
};
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
plugin.ruleName = ruleName;
|
|
161
|
-
plugin.messages = messages;
|
|
162
|
-
|
|
163
|
-
export = Object.assign(createPlugin(ruleName, plugin), { ruleName, messages });
|
|
3
|
+
export = plugin;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
export const DESIGN_SYSTEM_SOURCE = '@servicetitan/tokens';
|
|
4
|
+
export const ANVIL2_SOURCE = '@servicetitan/anvil2';
|
|
5
|
+
export type TokenSource = typeof DESIGN_SYSTEM_SOURCE | typeof ANVIL2_SOURCE;
|
|
6
|
+
|
|
7
|
+
export interface Token {
|
|
8
|
+
/* Kebab-case name of the token's Less and Sass variables. */
|
|
9
|
+
name: string;
|
|
10
|
+
/* CSS custom property that holds the token's value at runtime. */
|
|
11
|
+
customProperty: string;
|
|
12
|
+
source: TokenSource;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* Loads tokens from whichever token packages are installed. Both are optional peers, so a
|
|
18
|
+
* missing package contributes no tokens.
|
|
19
|
+
*/
|
|
20
|
+
export function loadTokens(): Token[] {
|
|
21
|
+
return [...loadDesignSystemTokens(), ...loadAnvil2Tokens()];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadDesignSystemTokens(): Token[] {
|
|
25
|
+
const tokens = optionalRequire<{ tokens: Record<string, string> }>(
|
|
26
|
+
'@servicetitan/tokens/core'
|
|
27
|
+
)?.tokens;
|
|
28
|
+
if (!tokens) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* An alias is a second name for a value a canonical token already holds, for example
|
|
34
|
+
* "@default-border-radius: 3px; // (alias)" duplicates "@border-radius-1: 3px". Excluding
|
|
35
|
+
* aliases limits suggestions to the canonical name.
|
|
36
|
+
*/
|
|
37
|
+
const aliases = fs
|
|
38
|
+
.readFileSync(require.resolve('@servicetitan/tokens/core/tokens.less'), 'utf8')
|
|
39
|
+
.split('\n')
|
|
40
|
+
.filter(row => row.startsWith('@') && row.endsWith('(alias)'))
|
|
41
|
+
.map(row => row.split(':')[0].slice(1));
|
|
42
|
+
|
|
43
|
+
return Object.entries(tokens)
|
|
44
|
+
.map(([key, value]) => ({ name: toKebabCase(key), value }))
|
|
45
|
+
.filter(({ name }) => !aliases.includes(name))
|
|
46
|
+
.map(({ name, value }) => ({
|
|
47
|
+
name,
|
|
48
|
+
customProperty: `--${name}`,
|
|
49
|
+
source: DESIGN_SYSTEM_SOURCE,
|
|
50
|
+
value,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadAnvil2Tokens(): Token[] {
|
|
55
|
+
const primitives = optionalRequire<{
|
|
56
|
+
core: { primitive: Record<string, { value: string }> };
|
|
57
|
+
}>('@servicetitan/anvil2/token')?.core.primitive;
|
|
58
|
+
if (!primitives) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const version = optionalRequire<{ version: string }>(
|
|
63
|
+
'@servicetitan/anvil2/package.json'
|
|
64
|
+
)?.version;
|
|
65
|
+
/*
|
|
66
|
+
* Anvil2 v3 prefixes its CSS custom properties with "a2-". All versions <3
|
|
67
|
+
* export package.json, so a version that doesn't is v3 or later.
|
|
68
|
+
*/
|
|
69
|
+
const prefix = version === undefined || parseInt(version, 10) >= 3 ? 'a2-' : '';
|
|
70
|
+
|
|
71
|
+
return Object.entries(primitives).map(([key, { value }]) => {
|
|
72
|
+
const name = toKebabCase(key);
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
customProperty: `--${prefix}${name}`,
|
|
76
|
+
source: ANVIL2_SOURCE,
|
|
77
|
+
value,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* Returns undefined when the package isn't installed, or is a version that doesn't export the
|
|
84
|
+
* requested path.
|
|
85
|
+
*/
|
|
86
|
+
function optionalRequire<T>(id: string): T | undefined {
|
|
87
|
+
try {
|
|
88
|
+
return require(id);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
91
|
+
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toKebabCase(str: string) {
|
|
99
|
+
const camelCase = str.charAt(0).toLowerCase() + str.slice(1);
|
|
100
|
+
return camelCase.replace(/([A-Z]|[a-z]\d)/g, ([letter, digit]) =>
|
|
101
|
+
digit === undefined ? `-${letter.toLowerCase()}` : `${letter}-${digit}`
|
|
102
|
+
);
|
|
103
|
+
}
|