@terrazzo/parser 0.10.3 → 2.0.0-alpha.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/index.d.ts +82 -333
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2203 -3660
  5. package/dist/index.js.map +1 -1
  6. package/package.json +6 -5
  7. package/src/build/index.ts +32 -41
  8. package/src/config.ts +13 -6
  9. package/src/lib/code-frame.ts +5 -2
  10. package/src/lib/momoa.ts +10 -0
  11. package/src/lint/index.ts +41 -37
  12. package/src/lint/plugin-core/index.ts +73 -16
  13. package/src/lint/plugin-core/rules/colorspace.ts +4 -0
  14. package/src/lint/plugin-core/rules/duplicate-values.ts +2 -0
  15. package/src/lint/plugin-core/rules/max-gamut.ts +24 -4
  16. package/src/lint/plugin-core/rules/no-type-on-alias.ts +29 -0
  17. package/src/lint/plugin-core/rules/required-modes.ts +2 -0
  18. package/src/lint/plugin-core/rules/required-typography-properties.ts +13 -3
  19. package/src/lint/plugin-core/rules/valid-boolean.ts +41 -0
  20. package/src/lint/plugin-core/rules/valid-border.ts +57 -0
  21. package/src/lint/plugin-core/rules/valid-color.ts +265 -0
  22. package/src/lint/plugin-core/rules/valid-cubic-bezier.ts +83 -0
  23. package/src/lint/plugin-core/rules/valid-dimension.ts +199 -0
  24. package/src/lint/plugin-core/rules/valid-duration.ts +123 -0
  25. package/src/lint/plugin-core/rules/valid-font-family.ts +68 -0
  26. package/src/lint/plugin-core/rules/valid-font-weight.ts +89 -0
  27. package/src/lint/plugin-core/rules/valid-gradient.ts +79 -0
  28. package/src/lint/plugin-core/rules/valid-link.ts +41 -0
  29. package/src/lint/plugin-core/rules/valid-number.ts +63 -0
  30. package/src/lint/plugin-core/rules/valid-shadow.ts +67 -0
  31. package/src/lint/plugin-core/rules/valid-string.ts +41 -0
  32. package/src/lint/plugin-core/rules/valid-stroke-style.ts +104 -0
  33. package/src/lint/plugin-core/rules/valid-transition.ts +61 -0
  34. package/src/lint/plugin-core/rules/valid-typography.ts +67 -0
  35. package/src/logger.ts +70 -59
  36. package/src/parse/index.ts +23 -318
  37. package/src/parse/load.ts +257 -0
  38. package/src/parse/normalize.ts +134 -170
  39. package/src/parse/token.ts +530 -0
  40. package/src/types.ts +76 -10
  41. package/src/parse/alias.ts +0 -369
  42. package/src/parse/json.ts +0 -211
  43. package/src/parse/validate.ts +0 -961
@@ -5,12 +5,16 @@ import { docsLink } from '../lib/docs.js';
5
5
  export const REQUIRED_TYPOGRAPHY_PROPERTIES = 'core/required-typography-properties';
6
6
 
7
7
  export interface RuleRequiredTypographyPropertiesOptions {
8
- /** Required typography properties */
8
+ /**
9
+ * Required typography properties.
10
+ * @default ["fontFamily", "fontWeight", "fontSize", "letterSpacing", "lineHeight"]
11
+ */
9
12
  properties: string[];
10
13
  /** Token globs to ignore */
11
14
  ignore?: string[];
12
15
  }
13
16
 
17
+ /** @deprecated Use core/valid-typography instead */
14
18
  const rule: LintRule<never, RuleRequiredTypographyPropertiesOptions> = {
15
19
  meta: {
16
20
  docs: {
@@ -18,7 +22,9 @@ const rule: LintRule<never, RuleRequiredTypographyPropertiesOptions> = {
18
22
  url: docsLink(REQUIRED_TYPOGRAPHY_PROPERTIES),
19
23
  },
20
24
  },
21
- defaultOptions: { properties: [] },
25
+ defaultOptions: {
26
+ properties: ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'],
27
+ },
22
28
  create({ tokens, options, report }) {
23
29
  if (!options) {
24
30
  return;
@@ -45,7 +51,11 @@ const rule: LintRule<never, RuleRequiredTypographyPropertiesOptions> = {
45
51
 
46
52
  for (const p of options.properties) {
47
53
  if (!t.partialAliasOf?.[p] && !(p in t.$value)) {
48
- report({ message: `${t.id} missing required typographic property "${p}"`, node: t.source.node });
54
+ report({
55
+ message: `This rule is deprecated. Use core/valid-typography. Missing required typographic property "${p}"`,
56
+ node: t.source.node,
57
+ filename: t.source.filename,
58
+ });
49
59
  }
50
60
  }
51
61
  }
@@ -0,0 +1,41 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import type { LintRule } from '../../../types.js';
4
+ import { docsLink } from '../lib/docs.js';
5
+
6
+ export const VALID_BOOLEAN = 'core/valid-boolean';
7
+
8
+ const ERROR = 'ERROR';
9
+
10
+ const rule: LintRule<typeof ERROR, {}> = {
11
+ meta: {
12
+ messages: {
13
+ [ERROR]: 'Must be a boolean.',
14
+ },
15
+ docs: {
16
+ description: 'Require boolean tokens to follow the Terrazzo extension.',
17
+ url: docsLink(VALID_BOOLEAN),
18
+ },
19
+ },
20
+ defaultOptions: {},
21
+ create({ tokens, report }) {
22
+ for (const t of Object.values(tokens)) {
23
+ if (t.aliasOf || !t.originalValue || t.$type !== 'boolean') {
24
+ continue;
25
+ }
26
+
27
+ validateBoolean(t.originalValue.$value, {
28
+ node: getObjMember(t.source.node, '$value'),
29
+ filename: t.source.filename,
30
+ });
31
+
32
+ function validateBoolean(value: unknown, { node, filename }: { node?: momoa.AnyNode; filename?: string }) {
33
+ if (typeof value !== 'boolean') {
34
+ report({ messageId: ERROR, filename, node });
35
+ }
36
+ }
37
+ }
38
+ },
39
+ };
40
+
41
+ export default rule;
@@ -0,0 +1,57 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import { BORDER_REQUIRED_PROPERTIES } from '@terrazzo/token-tools';
4
+ import type { LintRule } from '../../../types.js';
5
+ import { docsLink } from '../lib/docs.js';
6
+
7
+ export const VALID_BORDER = 'core/valid-border';
8
+
9
+ const ERROR = 'ERROR';
10
+ const ERROR_INVALID_PROP = 'ERROR_INVALID_PROP';
11
+
12
+ const rule: LintRule<typeof ERROR | typeof ERROR_INVALID_PROP, {}> = {
13
+ meta: {
14
+ messages: {
15
+ [ERROR]: `Border token missing required properties: ${new Intl.ListFormat(undefined, { type: 'conjunction' }).format(BORDER_REQUIRED_PROPERTIES)}.`,
16
+ [ERROR_INVALID_PROP]: 'Unknown property: {{ key }}.',
17
+ },
18
+ docs: {
19
+ description: 'Require border tokens to follow the format.',
20
+ url: docsLink(VALID_BORDER),
21
+ },
22
+ },
23
+ defaultOptions: {},
24
+ create({ tokens, report }) {
25
+ for (const t of Object.values(tokens)) {
26
+ if (t.aliasOf || !t.originalValue || t.$type !== 'border') {
27
+ continue;
28
+ }
29
+
30
+ validateBorder(t.originalValue.$value, {
31
+ node: getObjMember(t.source.node, '$value'),
32
+ filename: t.source.filename,
33
+ });
34
+ }
35
+
36
+ // Note: we validate sub-properties using other checks like valid-dimension, valid-font-family, etc.
37
+ // The only thing remaining is to check that all properties exist (since missing properties won’t appear as invalid)
38
+ function validateBorder(value: unknown, { node, filename }: { node?: momoa.AnyNode; filename?: string }) {
39
+ if (!value || typeof value !== 'object' || !BORDER_REQUIRED_PROPERTIES.every((property) => property in value)) {
40
+ report({ messageId: ERROR, filename, node });
41
+ } else {
42
+ for (const key of Object.keys(value)) {
43
+ if (!BORDER_REQUIRED_PROPERTIES.includes(key as (typeof BORDER_REQUIRED_PROPERTIES)[number])) {
44
+ report({
45
+ messageId: ERROR_INVALID_PROP,
46
+ data: { key: JSON.stringify(key) },
47
+ node: getObjMember(node as momoa.ObjectNode, key) ?? node,
48
+ filename,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ };
56
+
57
+ export default rule;
@@ -0,0 +1,265 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import {
4
+ type BorderValue,
5
+ COLORSPACE,
6
+ type ColorSpaceDefinition,
7
+ type ColorValueNormalized,
8
+ type GradientStopNormalized,
9
+ type GradientValueNormalized,
10
+ isAlias,
11
+ parseColor,
12
+ type ShadowValue,
13
+ } from '@terrazzo/token-tools';
14
+ import type { LintRule } from '../../../types.js';
15
+ import { docsLink } from '../lib/docs.js';
16
+
17
+ export const VALID_COLOR = 'core/valid-color';
18
+
19
+ const ERROR_ALPHA = 'ERROR_ALPHA';
20
+ const ERROR_INVALID_COLOR = 'ERROR_INVALID_COLOR';
21
+ const ERROR_INVALID_COLOR_SPACE = 'ERROR_INVALID_COLOR_SPACE';
22
+ const ERROR_INVALID_COMPONENT_LENGTH = 'ERROR_INVALID_COMPONENT_LENGTH';
23
+ const ERROR_INVALID_HEX8 = 'ERROR_INVALID_HEX8';
24
+ const ERROR_INVALID_PROP = 'ERROR_INVALID_PROP';
25
+ const ERROR_MISSING_COMPONENTS = 'ERROR_MISSING_COMPONENTS';
26
+ const ERROR_OBJ_FORMAT = 'ERROR_OBJ_FORMAT';
27
+ const ERROR_OUT_OF_RANGE = 'ERROR_OUT_OF_RANGE';
28
+
29
+ export interface RuleValidColorOptions {
30
+ /**
31
+ * Allow the legacy format of only a string sRGB hex code
32
+ * @default false
33
+ */
34
+ legacyFormat?: boolean;
35
+ /**
36
+ * Allow colors to be defined out of the expected ranges.
37
+ * @default false
38
+ */
39
+ ignoreRanges?: boolean;
40
+ }
41
+
42
+ const rule: LintRule<
43
+ | typeof ERROR_ALPHA
44
+ | typeof ERROR_INVALID_COLOR
45
+ | typeof ERROR_INVALID_COLOR_SPACE
46
+ | typeof ERROR_INVALID_COMPONENT_LENGTH
47
+ | typeof ERROR_INVALID_HEX8
48
+ | typeof ERROR_INVALID_PROP
49
+ | typeof ERROR_MISSING_COMPONENTS
50
+ | typeof ERROR_OBJ_FORMAT
51
+ | typeof ERROR_OUT_OF_RANGE,
52
+ RuleValidColorOptions
53
+ > = {
54
+ meta: {
55
+ messages: {
56
+ [ERROR_ALPHA]: `Alpha {{ alpha }} not in range 0 – 1.`,
57
+ [ERROR_INVALID_COLOR_SPACE]: `Invalid color space: {{ colorSpace }}. Expected ${new Intl.ListFormat(undefined, { type: 'disjunction' }).format(Object.keys(COLORSPACE))}`,
58
+ [ERROR_INVALID_COLOR]: `Could not parse color {{ color }}.`,
59
+ [ERROR_INVALID_COMPONENT_LENGTH]: 'Expected {{ expected }} components, received {{ got }}.',
60
+ [ERROR_INVALID_HEX8]: `Hex value can’t be semi-transparent.`,
61
+ [ERROR_INVALID_PROP]: `Unknown property {{ key }}.`,
62
+ [ERROR_MISSING_COMPONENTS]: 'Expected components to be array of numbers, received {{ got }}.',
63
+ [ERROR_OBJ_FORMAT]:
64
+ 'Migrate to the new object format, e.g. "#ff0000" → { "colorSpace": "srgb", "components": [1, 0, 0] } }',
65
+ [ERROR_OUT_OF_RANGE]: `Invalid range for color space {{ colorSpace }}. Expected {{ range }}.`,
66
+ },
67
+ docs: {
68
+ description: 'Require color tokens to follow the format.',
69
+ url: docsLink(VALID_COLOR),
70
+ },
71
+ },
72
+ defaultOptions: {
73
+ legacyFormat: false,
74
+ ignoreRanges: false,
75
+ },
76
+ create({ tokens, options, report }) {
77
+ for (const t of Object.values(tokens)) {
78
+ if (t.aliasOf || !t.originalValue) {
79
+ continue;
80
+ }
81
+
82
+ switch (t.$type) {
83
+ case 'color': {
84
+ validateColor(t.originalValue.$value, {
85
+ node: getObjMember(t.source.node, '$value'),
86
+ filename: t.source.filename,
87
+ });
88
+ break;
89
+ }
90
+ case 'border': {
91
+ if ((t.originalValue.$value as any).color && !isAlias((t.originalValue.$value as any).color)) {
92
+ validateColor((t.originalValue.$value as BorderValue).color, {
93
+ node: getObjMember(getObjMember(t.source.node, '$value') as momoa.ObjectNode, 'color'),
94
+ filename: t.source.filename,
95
+ });
96
+ }
97
+ break;
98
+ }
99
+ case 'gradient': {
100
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ArrayNode;
101
+ for (let i = 0; i < (t.originalValue.$value as GradientValueNormalized).length; i++) {
102
+ const stop = t.originalValue.$value[i] as GradientStopNormalized;
103
+ if (!stop.color || isAlias(stop.color as any)) {
104
+ continue;
105
+ }
106
+ validateColor(stop.color, {
107
+ node: getObjMember($valueNode.elements[i]!.value as momoa.ObjectNode, 'color'),
108
+ filename: t.source.filename,
109
+ });
110
+ }
111
+ break;
112
+ }
113
+ case 'shadow': {
114
+ const $value = (
115
+ Array.isArray(t.originalValue.$value) ? t.originalValue.$value : [t.originalValue.$value]
116
+ ) as ShadowValue[];
117
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode | momoa.ArrayNode;
118
+ for (let i = 0; i < $value.length; i++) {
119
+ const layer = $value[i]!;
120
+ if (!layer.color || isAlias(layer.color as any)) {
121
+ continue;
122
+ }
123
+ validateColor(layer.color, {
124
+ node:
125
+ $valueNode.type === 'Object'
126
+ ? getObjMember($valueNode, 'color')
127
+ : getObjMember($valueNode.elements[i]!.value as momoa.ObjectNode, 'color'),
128
+ filename: t.source.filename,
129
+ });
130
+ }
131
+ break;
132
+ }
133
+ }
134
+
135
+ function validateColor(value: unknown, { node, filename }: { node?: momoa.AnyNode; filename?: string }) {
136
+ if (!value) {
137
+ report({ messageId: ERROR_INVALID_COLOR, data: { color: JSON.stringify(value) }, node, filename });
138
+ } else if (typeof value === 'object') {
139
+ for (const key of Object.keys(value)) {
140
+ if (!['colorSpace', 'components', 'channels' /* TODO: remove */, 'hex', 'alpha'].includes(key)) {
141
+ report({
142
+ messageId: ERROR_INVALID_PROP,
143
+ data: { key: JSON.stringify(key) },
144
+ node: getObjMember(node as momoa.ObjectNode, key) ?? node,
145
+ filename,
146
+ });
147
+ }
148
+ }
149
+
150
+ // Color space
151
+ const colorSpace =
152
+ 'colorSpace' in value && typeof value.colorSpace === 'string' ? value.colorSpace : undefined;
153
+ const csData = (COLORSPACE as Record<string, ColorSpaceDefinition>)[colorSpace!] || undefined;
154
+ if (!('colorSpace' in value) || !csData) {
155
+ report({
156
+ messageId: ERROR_INVALID_COLOR_SPACE,
157
+ data: { colorSpace },
158
+ node: getObjMember(node as momoa.ObjectNode, 'colorSpace') ?? node,
159
+ filename,
160
+ });
161
+ return;
162
+ }
163
+
164
+ // Component ranges
165
+ const components = 'components' in value ? value.components : undefined;
166
+ if (Array.isArray(components)) {
167
+ if (csData?.ranges && components?.length === csData.ranges.length) {
168
+ for (let i = 0; i < components.length; i++) {
169
+ if (
170
+ !Number.isFinite(components[i]) ||
171
+ components[i]! < csData.ranges[i]![0] ||
172
+ components[i]! > csData.ranges[i]![1]
173
+ ) {
174
+ // special case for any hue-based components: allow null
175
+ if (
176
+ !(colorSpace === 'hsl' && components[0]! === null) &&
177
+ !(colorSpace === 'hwb' && components[0]! === null) &&
178
+ !(colorSpace === 'lch' && components[2]! === null) &&
179
+ !(colorSpace === 'oklch' && components[2]! === null)
180
+ ) {
181
+ report({
182
+ messageId: ERROR_OUT_OF_RANGE,
183
+ data: { colorSpace, range: `[${csData.ranges.map((r) => `${r[0]}–${r[1]}`).join(', ')}]` },
184
+ node: getObjMember(node as momoa.ObjectNode, 'components') ?? node,
185
+ filename,
186
+ });
187
+ }
188
+ }
189
+ }
190
+ } else {
191
+ report({
192
+ messageId: ERROR_INVALID_COMPONENT_LENGTH,
193
+ data: { expected: csData?.ranges.length, got: (components as number[] | undefined)?.length ?? 0 },
194
+ node: getObjMember(node as momoa.ObjectNode, 'components') ?? node,
195
+ filename,
196
+ });
197
+ }
198
+ } else {
199
+ report({
200
+ messageId: ERROR_MISSING_COMPONENTS,
201
+ data: { got: JSON.stringify(components) },
202
+ node: getObjMember(node as momoa.ObjectNode, 'components') ?? node,
203
+ filename,
204
+ });
205
+ }
206
+
207
+ // Alpha
208
+ const alpha = 'alpha' in value ? value.alpha : undefined;
209
+ if (alpha !== undefined && (typeof alpha !== 'number' || alpha < 0 || alpha > 1)) {
210
+ report({
211
+ messageId: ERROR_ALPHA,
212
+ data: { alpha },
213
+ node: getObjMember(node as momoa.ObjectNode, 'alpha') ?? node,
214
+ filename,
215
+ });
216
+ }
217
+
218
+ // Hex
219
+ const hex = 'hex' in value ? value.hex : undefined;
220
+ if (hex) {
221
+ let color: ColorValueNormalized;
222
+ try {
223
+ color = parseColor(hex as string);
224
+ } catch {
225
+ report({
226
+ messageId: ERROR_INVALID_COLOR,
227
+ data: { color: hex },
228
+ node: getObjMember(node as momoa.ObjectNode, 'hex') ?? node,
229
+ filename,
230
+ });
231
+ return;
232
+ }
233
+ // since we’re only parsing hex, this should always have alpha: 1
234
+ if (color.alpha !== 1) {
235
+ report({
236
+ messageId: ERROR_INVALID_HEX8,
237
+ data: { color: hex },
238
+ node: getObjMember(node as momoa.ObjectNode, 'hex') ?? node,
239
+ filename,
240
+ });
241
+ }
242
+ }
243
+ } else if (typeof value === 'string') {
244
+ if (isAlias(value)) {
245
+ return;
246
+ }
247
+ if (!options.legacyFormat) {
248
+ report({ messageId: ERROR_OBJ_FORMAT, data: { color: JSON.stringify(value) }, node, filename });
249
+ } else {
250
+ // Legacy format
251
+ try {
252
+ parseColor(value as string);
253
+ } catch {
254
+ report({ messageId: ERROR_INVALID_COLOR, data: { color: JSON.stringify(value) }, node, filename });
255
+ }
256
+ }
257
+ } else {
258
+ report({ messageId: ERROR_INVALID_COLOR, node, filename });
259
+ }
260
+ }
261
+ }
262
+ },
263
+ };
264
+
265
+ export default rule;
@@ -0,0 +1,83 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember, isPure$ref } from '@terrazzo/json-schema-tools';
3
+ import { isAlias } from '@terrazzo/token-tools';
4
+ import type { LintRule } from '../../../types.js';
5
+ import { docsLink } from '../lib/docs.js';
6
+
7
+ export const VALID_CUBIC_BEZIER = 'core/valid-cubic-bezier';
8
+
9
+ const ERROR = 'ERROR';
10
+ const ERROR_X = 'ERROR_X';
11
+ const ERROR_Y = 'ERROR_Y';
12
+
13
+ const rule: LintRule<typeof ERROR | typeof ERROR_X | typeof ERROR_Y> = {
14
+ meta: {
15
+ messages: {
16
+ [ERROR]: 'Expected [number, number, number, number].',
17
+ [ERROR_X]: 'x values must be between 0-1.',
18
+ [ERROR_Y]: 'y values must be a valid number.',
19
+ },
20
+ docs: {
21
+ description: 'Require cubicBezier tokens to follow the format.',
22
+ url: docsLink(VALID_CUBIC_BEZIER),
23
+ },
24
+ },
25
+ defaultOptions: {},
26
+ create({ tokens, report }) {
27
+ for (const t of Object.values(tokens)) {
28
+ if (t.aliasOf || !t.originalValue) {
29
+ continue;
30
+ }
31
+
32
+ switch (t.$type) {
33
+ case 'cubicBezier': {
34
+ validateCubicBezier(t.originalValue.$value, {
35
+ node: getObjMember(t.source.node, '$value') as momoa.ArrayNode,
36
+ filename: t.source.filename,
37
+ });
38
+ break;
39
+ }
40
+ case 'transition': {
41
+ if (
42
+ typeof t.originalValue.$value === 'object' &&
43
+ t.originalValue.$value.timingFunction &&
44
+ !isAlias(t.originalValue.$value.timingFunction as string)
45
+ ) {
46
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode;
47
+ validateCubicBezier(t.originalValue.$value.timingFunction, {
48
+ node: getObjMember($valueNode, 'timingFunction') as momoa.ArrayNode,
49
+ filename: t.source.filename,
50
+ });
51
+ }
52
+ }
53
+ }
54
+
55
+ function validateCubicBezier(value: unknown, { node, filename }: { node: momoa.ArrayNode; filename?: string }) {
56
+ if (Array.isArray(value) && value.length === 4) {
57
+ // validate x values
58
+ for (const pos of [0, 2]) {
59
+ if (isAlias(value[pos]) || isPure$ref(value[pos])) {
60
+ continue;
61
+ }
62
+ if (!(value[pos] >= 0 && value[pos] <= 1)) {
63
+ report({ messageId: ERROR_X, node: (node as momoa.ArrayNode).elements[pos], filename });
64
+ }
65
+ }
66
+ // validate y values
67
+ for (const pos of [1, 3]) {
68
+ if (isAlias(value[pos]) || isPure$ref(value[pos])) {
69
+ continue;
70
+ }
71
+ if (typeof value[pos] !== 'number') {
72
+ report({ messageId: ERROR_Y, node: (node as momoa.ArrayNode).elements[pos], filename });
73
+ }
74
+ }
75
+ } else {
76
+ report({ messageId: ERROR, node, filename });
77
+ }
78
+ }
79
+ }
80
+ },
81
+ };
82
+
83
+ export default rule;
@@ -0,0 +1,199 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import { isAlias } from '@terrazzo/token-tools';
4
+ import type { LintRule } from '../../../types.js';
5
+ import { docsLink } from '../lib/docs.js';
6
+
7
+ export const VALID_DIMENSION = 'core/valid-dimension';
8
+
9
+ const ERROR_FORMAT = 'ERROR_FORMAT';
10
+ const ERROR_INVALID_PROP = 'ERROR_INVALID_PROP';
11
+ const ERROR_LEGACY = 'ERROR_LEGACY';
12
+ const ERROR_UNIT = 'ERROR_UNIT';
13
+ const ERROR_VALUE = 'ERROR_VALUE';
14
+
15
+ export interface RuleValidDimension {
16
+ /**
17
+ * Allow the use of unknown "unit" values
18
+ * @default false
19
+ */
20
+ legacyFormat?: boolean;
21
+ /**
22
+ * Only allow the following units.
23
+ * @default ["px", "rem"]
24
+ */
25
+ allowedUnits?: string[];
26
+ }
27
+
28
+ const rule: LintRule<
29
+ typeof ERROR_FORMAT | typeof ERROR_LEGACY | typeof ERROR_UNIT | typeof ERROR_VALUE | typeof ERROR_INVALID_PROP,
30
+ RuleValidDimension
31
+ > = {
32
+ meta: {
33
+ messages: {
34
+ [ERROR_FORMAT]: 'Invalid dimension: {{ value }}. Expected object with "value" and "unit".',
35
+ [ERROR_LEGACY]: 'Migrate to the new object format: { "value": 10, "unit": "px" }.',
36
+ [ERROR_UNIT]: 'Unit {{ unit }} not allowed. Expected {{ allowed }}.',
37
+ [ERROR_INVALID_PROP]: 'Unknown property {{ key }}.',
38
+ [ERROR_VALUE]: 'Expected number, received {{ value }}.',
39
+ },
40
+ docs: {
41
+ description: 'Require dimension tokens to follow the format',
42
+ url: docsLink(VALID_DIMENSION),
43
+ },
44
+ },
45
+ defaultOptions: {
46
+ legacyFormat: false,
47
+ allowedUnits: ['px', 'em', 'rem'],
48
+ },
49
+ create({ tokens, options, report }) {
50
+ for (const t of Object.values(tokens)) {
51
+ if (t.aliasOf || !t.originalValue) {
52
+ continue;
53
+ }
54
+
55
+ switch (t.$type) {
56
+ case 'dimension': {
57
+ validateDimension(t.originalValue.$value, {
58
+ node: getObjMember(t.source.node, '$value'),
59
+ filename: t.source.filename,
60
+ });
61
+ break;
62
+ }
63
+ case 'strokeStyle': {
64
+ if (typeof t.originalValue.$value === 'object' && Array.isArray(t.originalValue.$value.dashArray)) {
65
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode;
66
+ const dashArray = getObjMember($valueNode, 'dashArray') as momoa.ArrayNode;
67
+ for (let i = 0; i < t.originalValue.$value.dashArray.length; i++) {
68
+ if (isAlias(t.originalValue.$value.dashArray[i] as string)) {
69
+ continue;
70
+ }
71
+ validateDimension(t.originalValue.$value.dashArray[i], {
72
+ node: dashArray.elements[i]!.value,
73
+ filename: t.source.filename,
74
+ });
75
+ }
76
+ }
77
+ break;
78
+ }
79
+ case 'border': {
80
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode;
81
+ if (typeof t.originalValue.$value === 'object') {
82
+ if (t.originalValue.$value.width && !isAlias(t.originalValue.$value.width as string)) {
83
+ validateDimension(t.originalValue.$value.width, {
84
+ node: getObjMember($valueNode, 'width'),
85
+ filename: t.source.filename,
86
+ });
87
+ }
88
+ if (
89
+ typeof t.originalValue.$value.style === 'object' &&
90
+ Array.isArray(t.originalValue.$value.style.dashArray)
91
+ ) {
92
+ const style = getObjMember($valueNode, 'style') as momoa.ObjectNode;
93
+ const dashArray = getObjMember(style, 'dashArray') as momoa.ArrayNode;
94
+ for (let i = 0; i < t.originalValue.$value.style.dashArray.length; i++) {
95
+ if (isAlias(t.originalValue.$value.style.dashArray[i] as string)) {
96
+ continue;
97
+ }
98
+ validateDimension(t.originalValue.$value.style.dashArray[i], {
99
+ node: dashArray.elements[i]!.value,
100
+ filename: t.source.filename,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ break;
106
+ }
107
+ case 'shadow': {
108
+ if (t.originalValue.$value && typeof t.originalValue.$value === 'object') {
109
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode | momoa.ArrayNode;
110
+ const valueArray = Array.isArray(t.originalValue.$value)
111
+ ? t.originalValue.$value
112
+ : [t.originalValue.$value];
113
+ for (let i = 0; i < valueArray.length; i++) {
114
+ const node =
115
+ $valueNode.type === 'Array' ? ($valueNode.elements[i]!.value as momoa.ObjectNode) : $valueNode;
116
+ for (const property of ['offsetX', 'offsetY', 'blur', 'spread'] as const) {
117
+ if (isAlias(valueArray[i]![property] as string)) {
118
+ continue;
119
+ }
120
+ validateDimension(valueArray[i]![property], {
121
+ node: getObjMember(node, property),
122
+ filename: t.source.filename,
123
+ });
124
+ }
125
+ }
126
+ }
127
+ break;
128
+ }
129
+ case 'typography': {
130
+ const $valueNode = getObjMember(t.source.node, '$value') as momoa.ObjectNode;
131
+ if (typeof t.originalValue.$value === 'object') {
132
+ for (const property of ['fontSize', 'lineHeight', 'letterSpacing'] as const) {
133
+ if (property in t.originalValue.$value) {
134
+ if (
135
+ isAlias(t.originalValue.$value[property] as string) ||
136
+ // special case: lineHeight may be a number
137
+ (property === 'lineHeight' && typeof t.originalValue.$value[property] === 'number')
138
+ ) {
139
+ continue;
140
+ }
141
+ validateDimension(t.originalValue.$value[property], {
142
+ node: getObjMember($valueNode, property),
143
+ filename: t.source.filename,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ break;
149
+ }
150
+ }
151
+
152
+ function validateDimension(value: unknown, { node, filename }: { node?: momoa.AnyNode; filename?: string }) {
153
+ if (value && typeof value === 'object') {
154
+ for (const key of Object.keys(value)) {
155
+ if (!['value', 'unit'].includes(key)) {
156
+ report({
157
+ messageId: ERROR_INVALID_PROP,
158
+ data: { key: JSON.stringify(key) },
159
+ node: getObjMember(node as momoa.ObjectNode, key) ?? node,
160
+ filename,
161
+ });
162
+ }
163
+ }
164
+
165
+ const { unit, value: numValue } = value as Record<string, any>;
166
+ if (!('value' in value || 'unit' in value)) {
167
+ report({ messageId: ERROR_FORMAT, data: { value }, node, filename });
168
+ return;
169
+ }
170
+ if (!options.allowedUnits!.includes(unit)) {
171
+ report({
172
+ messageId: ERROR_UNIT,
173
+ data: {
174
+ unit,
175
+ allowed: new Intl.ListFormat('en-us', { type: 'disjunction' }).format(options.allowedUnits!),
176
+ },
177
+ node: getObjMember(node as momoa.ObjectNode, 'unit') ?? node,
178
+ filename,
179
+ });
180
+ }
181
+ if (!Number.isFinite(numValue)) {
182
+ report({
183
+ messageId: ERROR_VALUE,
184
+ data: { value },
185
+ node: getObjMember(node as momoa.ObjectNode, 'value') ?? node,
186
+ filename,
187
+ });
188
+ }
189
+ } else if (typeof value === 'string' && !options.legacyFormat) {
190
+ report({ messageId: ERROR_LEGACY, node, filename });
191
+ } else {
192
+ report({ messageId: ERROR_FORMAT, data: { value }, node, filename });
193
+ }
194
+ }
195
+ }
196
+ },
197
+ };
198
+
199
+ export default rule;