@wix/origami-lint 1.34.30 → 1.34.31

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.
@@ -1,4 +1,5 @@
1
1
  import homePageFolder from "./home-page-folder.js";
2
+ import themeConnectionNotes from "./theme-connection-notes.js";
2
3
  export { defineProjectRule } from "./define-project-rule.js";
3
4
  // Execution registries combine already-checked rules; only this boundary erases their individual key unions.
4
- export const projectRuleRegistry = [homePageFolder];
5
+ export const projectRuleRegistry = [homePageFolder, themeConnectionNotes];
@@ -0,0 +1,221 @@
1
+ // The theme-connection-notes subject: how an element's style.css connects to the Theme. Warnings: the import stores each value as
2
+ // written, so it takes all of these; the editor edits a half-connected element worse, and its Theme no longer changes a copied literal.
3
+ // - KIT-slotMix: an element takes every field from one slot of a family. A slot is the theme's --wst-* tokens of one look: a button
4
+ // (button-primary, -secondary, -tertiary), a box (box-primary, -secondary) or a type style (heading-1 to heading-6, paragraph-1 to -3).
5
+ // - KIT-slotPartial: an element connected to a slot writes no raw value on that slot's fields: a type slot's font or color, any color of
6
+ // a button or a box.
7
+ // - KIT-themeRepeat: a literal color or font written on three or more element rules across the site, when a --wst-* token in
8
+ // global/themes/theme.css holds it: connect them to the token.
9
+ // An element's fields are its classes' own rules in its page's, popup's or global component's style.css, joined as the cascade
10
+ // reads them (`className="base extra"` takes both): per breakpoint (`@media`) and property, the line that applies. Its inner parts
11
+ // (`.comp-a .item`) are left out, as the editor itself writes a slot's font with a raw color on them (converter fixture
12
+ // f16-c1dmp-real-page); so are states (`:hover`) and what an `@apply` puts in (a global style is the theme's). KIT-themeRepeat counts
13
+ // class rules, and needs every page at once, so this is a project rule: it builds each page's model and reads the theme.
14
+ import { buildPageModel, findPages, JSX_FILES } from "../../page-model/index.js";
15
+ import { readThemeTokens } from "../../shared/css.js";
16
+ import { POPUPS_FOLDER, SITE_SRC } from "../../shared/site-files-tree-mapping.js";
17
+ import { defineProjectRule } from "./define-project-rule.js";
18
+ /** One check per row: KIT-slotMix, KIT-slotPartial, KIT-themeRepeat. */
19
+ const CHECKS = ['mixed_slots', 'raw_slot_field', 'repeated_theme_literal'];
20
+ /** A var() of a slot's token: `var(--wst-heading-1-font)` → 'heading-1'. */
21
+ const SLOT_TOKEN = /var\(\s*--wst-(button-(?:primary|secondary|tertiary)|box-(?:primary|secondary)|heading-[1-6]|paragraph-[1-3])-/g;
22
+ /** A type slot's fields; a button's and a box's are every color they write. */
23
+ const TYPE_FIELDS = ['font', 'color'];
24
+ /** At least this many element rules write a literal before KIT-themeRepeat reports it. */
25
+ const REPEATS = 3;
26
+ /** At most this many rules are named in a KIT-themeRepeat message. */
27
+ const NAMED_RULES = 5;
28
+ /** A literal color in a value: `#` and 3 to 8 hex digits, or `rgb(…)` / `rgba(…)`. */
29
+ const LITERAL_COLOR = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)/gi;
30
+ const HEX_COLOR = /^#([0-9a-f]{3,8})$/i;
31
+ const RGB_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)$/i;
32
+ const THEME_TOKEN = /^--wst-/;
33
+ const FONT_TOKEN = /^--wst-[\w-]+-font$/;
34
+ export default defineProjectRule({
35
+ name: 'theme-connection-notes',
36
+ checks: CHECKS,
37
+ editors: ['studio2'],
38
+ severity: 'warn',
39
+ check: (project) => {
40
+ const pages = getPageModels(project);
41
+ const holders = getThemeHolders(pages[0]);
42
+ return [
43
+ ...pages.flatMap(getElementsFields).flatMap(checkElementSlots),
44
+ ...[...getLiteralUses(pages.flatMap(getClassRulesFields))].flatMap(([key, uses]) => checkLiteralConnected(key, uses, holders)),
45
+ ];
46
+ },
47
+ });
48
+ // ── the steps ──
49
+ /** Per family of slots the element uses: one slot (KIT-slotMix), then that slot's fields connected (KIT-slotPartial). */
50
+ function checkElementSlots(element) {
51
+ return [...getSlotsByFamily(element)].flatMap(([family, slots]) => {
52
+ const mix = checkElementOneSlot(element, slots);
53
+ return mix.length ? mix : checkSlotFieldsConnected(element, family, slots[0].slot);
54
+ });
55
+ }
56
+ /** Reports an element that takes fields from two slots of one family, on the line the second is first used (KIT-slotMix). */
57
+ function checkElementOneSlot({ file, name }, slots) {
58
+ if (slots.length === 1)
59
+ return [];
60
+ const names = slots.map(({ slot }) => `--wst-${slot}-*`).join(' with ');
61
+ return [{ check: 'mixed_slots', file, line: slots[1].line, message: `${name} mixes ${names}. Connect every field to one slot (rule KIT-slotMix).` }];
62
+ }
63
+ /** Reports each raw value on the slot's fields: a type slot's font or color, any color of a button or a box (KIT-slotPartial). A transparent
64
+ * color is no raw look. */
65
+ function checkSlotFieldsConnected({ file, name, fields }, family, slot) {
66
+ return fields.flatMap(({ property, value, line }) => {
67
+ if (value.includes('var(') || !(family === 'type' ? TYPE_FIELDS.includes(property) && !isTransparent(value) : readLiteralColors(value).length > 0))
68
+ return [];
69
+ const token = family === 'type' ? `var(--wst-${slot}-${property})` : `a --wst-${slot}-* token with var()`;
70
+ return [{ check: 'raw_slot_field', file, line, message: `${name} is connected to --wst-${slot}-* but its ${property} is a raw value (${value}). Connect it to ${token}, or keep it as a deliberate one-off (rule KIT-slotPartial).` }];
71
+ });
72
+ }
73
+ /** Reports a literal written on three or more element rules that a theme token holds, on the first rule's line (KIT-themeRepeat). */
74
+ function checkLiteralConnected(key, uses, holders) {
75
+ const tokens = holders.get(key);
76
+ if (uses.length < REPEATS || tokens === undefined)
77
+ return [];
78
+ const [kind, value] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
79
+ const named = uses.slice(0, NAMED_RULES).map(({ file, name, line }) => `${name} in ${file.slice(SITE_SRC.length + 1)}:${line}`);
80
+ const rules = `${named.join(', ')}${uses.length > NAMED_RULES ? ', …' : ''}`;
81
+ const message = `${kind} ${value} is written on ${uses.length} rules (${rules}); ${tokens.join(' / ')} in theme.css holds it. Connect them with var(${tokens[0]}) (rule KIT-themeRepeat).`;
82
+ return [{ check: 'repeated_theme_literal', file: uses[0].file, line: uses[0].line, message }];
83
+ }
84
+ // ── reading the site ──
85
+ /** The fields of each element of a page: its classes' CSS joined (`component.css`, as the cascade reads them, in file order). Elements with
86
+ * the same set of classes, in any order, are one, named by the set sorted: `.base.extra`. */
87
+ function getElementsFields(page) {
88
+ const byName = new Map();
89
+ for (const component of Object.values(page.components)) {
90
+ const classNames = [...new Set(component.classNames.filter((className) => page.css.byClassName[className]))].sort();
91
+ if (classNames.length)
92
+ byName.set(`.${classNames.join('.')}`, component.css);
93
+ }
94
+ return [...byName].flatMap(([name, css]) => getFieldsByMedia(`${page.path}/style.css`, name, css));
95
+ }
96
+ /** Each class rule's fields: what KIT-themeRepeat counts, a rule written once whatever elements take it. */
97
+ function getClassRulesFields(page) {
98
+ return Object.entries(page.css.byClassName).flatMap(([className, css]) => getFieldsByMedia(`${page.path}/style.css`, `.${className}`, css));
99
+ }
100
+ /** One entry per breakpoint (`@media`): the own style.css lines (`&`: no part, no state), per property the last one, which applies. */
101
+ function getFieldsByMedia(file, name, css) {
102
+ const byMedia = new Map();
103
+ for (const [property, lines] of Object.entries(css.style)) {
104
+ for (const line of lines.filter(({ selector }) => selector === '&')) {
105
+ if (!byMedia.has(line.media))
106
+ byMedia.set(line.media, new Map());
107
+ byMedia.get(line.media).set(property, { property, ...line });
108
+ }
109
+ }
110
+ return [...byMedia.values()].map((fields) => ({ file, name, fields: [...fields.values()].sort((a, b) => a.line - b.line || a.column - b.column) }));
111
+ }
112
+ /** The model of each page, popup and global component, sorted by path; a page whose files can't be parsed is left out (the page rules report it,
113
+ * and a note never fails the run). */
114
+ function getPageModels({ files, readFile }) {
115
+ return [...findPages([...files], [...files]), ...getPopupJsxFiles(files)].sort().flatMap((jsxFile) => {
116
+ try {
117
+ return [buildPageModel({ jsxFile, text: readFile(jsxFile) ?? '' }, readFile)];
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ });
123
+ }
124
+ /** Each popup's structure file: one popup per folder directly under popups/, written like a page (structure.tsx wins over structure.jsx).
125
+ * findPages, which picks the page rules' pages, has no popups. */
126
+ function getPopupJsxFiles(files) {
127
+ const existing = new Set(files);
128
+ const folders = new Set(files.filter((file) => file.startsWith(`${POPUPS_FOLDER}/`) && file.split('/').length === POPUPS_FOLDER.split('/').length + 2).map((file) => file.slice(0, file.lastIndexOf('/'))));
129
+ return [...folders].flatMap((folder) => JSX_FILES.map((name) => `${folder}/${name}`).find((file) => existing.has(file)) ?? []);
130
+ }
131
+ /** The slots an element's fields use, by family, each once, in the order first used. */
132
+ function getSlotsByFamily({ fields }) {
133
+ const slots = new Map();
134
+ for (const { value, line } of fields) {
135
+ for (const [, slot] of value.matchAll(SLOT_TOKEN)) {
136
+ const family = getFamily(slot);
137
+ const uses = slots.get(family) ?? [];
138
+ if (!uses.some((use) => use.slot === slot))
139
+ slots.set(family, [...uses, { slot, line }]);
140
+ }
141
+ }
142
+ return slots;
143
+ }
144
+ /** 'button-primary' → 'button'; 'box-secondary' → 'box'; 'heading-1', 'paragraph-2' → 'type'. */
145
+ function getFamily(slot) {
146
+ if (slot.startsWith('button-'))
147
+ return 'button';
148
+ return slot.startsWith('box-') ? 'box' : 'type';
149
+ }
150
+ /** Each literal color and font the class rules write, with the rules that write it, each rule once, in order. */
151
+ function getLiteralUses(rules) {
152
+ const uses = new Map();
153
+ const add = (key, use) => {
154
+ const keyUses = uses.get(key) ?? [];
155
+ if (!keyUses.some(({ file, name }) => file === use.file && name === use.name))
156
+ uses.set(key, [...keyUses, use]);
157
+ };
158
+ for (const { file, name, fields } of rules) {
159
+ for (const { property, value, line } of fields) {
160
+ const literal = value.replace(/!important\s*$/i, '').trim();
161
+ if (literal.includes('var('))
162
+ continue;
163
+ if (property === 'font')
164
+ add(fontKey(literal), { file, name, line });
165
+ for (const color of readLiteralColors(literal))
166
+ add(`color:${color}`, { file, name, line });
167
+ }
168
+ }
169
+ return uses;
170
+ }
171
+ /** The theme's --wst-* tokens whose value is one literal color or a literal font, by that literal. Every page holds the same theme. */
172
+ function getThemeHolders(page) {
173
+ const holders = new Map();
174
+ for (const { name, value } of readThemeTokens(page?.css.files.theme)) {
175
+ if (!THEME_TOKEN.test(name) || value.includes('var('))
176
+ continue;
177
+ const color = normalizeColor(value);
178
+ const key = FONT_TOKEN.test(name) ? fontKey(value) : color && `color:${color}`;
179
+ if (key && !holders.get(key)?.includes(name))
180
+ holders.set(key, [...(holders.get(key) ?? []), name]);
181
+ }
182
+ return holders;
183
+ }
184
+ /** A font shorthand in one spelling: spaces collapsed, lower case. */
185
+ const fontKey = (value) => `font:${value.replace(/\s+/g, ' ').trim().toLowerCase()}`;
186
+ /** A literal color read: its channels in one spelling ('#116DFF', '#16f', 'rgb(17, 102, 255)' → '#116dff', '#1166ff') and its alpha, 0 to 1.
187
+ * Null for anything else ('red', 'var(--x)', '#12345'). */
188
+ function parseColor(color) {
189
+ const hex = HEX_COLOR.exec(color.trim())?.[1].toLowerCase();
190
+ const rgb = hex ? null : RGB_COLOR.exec(color.trim());
191
+ let channels;
192
+ let alpha = 1;
193
+ if (hex) {
194
+ const digits = hex.length <= 4 ? [...hex].map((digit) => digit + digit).join('') : hex;
195
+ if (digits.length !== 6 && digits.length !== 8)
196
+ return null;
197
+ channels = [0, 2, 4].map((at) => parseInt(digits.slice(at, at + 2), 16));
198
+ if (digits.length === 8)
199
+ alpha = parseInt(digits.slice(6), 16) / 255;
200
+ }
201
+ else if (rgb) {
202
+ channels = rgb.slice(1, 4).map(Number);
203
+ if (rgb[4] !== undefined)
204
+ alpha = rgb[4].endsWith('%') ? parseFloat(rgb[4]) / 100 : Number(rgb[4]);
205
+ }
206
+ else
207
+ return null;
208
+ return { spelled: `#${channels.map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`, alpha };
209
+ }
210
+ /** One spelling of a literal color, so two spellings of one color compare equal; with an alpha, '#116dff/0.5'. Null for a transparent
211
+ * color (alpha 0: not a look) and for anything that isn't a literal color. */
212
+ function normalizeColor(color) {
213
+ const parsed = parseColor(color);
214
+ if (parsed === null || parsed.alpha === 0)
215
+ return null;
216
+ return parsed.alpha >= 1 ? parsed.spelled : `${parsed.spelled}/${Number(parsed.alpha.toFixed(2))}`;
217
+ }
218
+ /** `transparent`, or a literal color with alpha 0: no look of its own, so not a raw value. */
219
+ const isTransparent = (value) => /^transparent$/i.test(value.trim()) || parseColor(value)?.alpha === 0;
220
+ /** The literal colors written in a value, each in one spelling (normalizeColor), in order; a transparent one is left out. */
221
+ const readLiteralColors = (value) => [...value.matchAll(LITERAL_COLOR)].flatMap(([color]) => normalizeColor(color) ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-lint",
3
- "version": "1.34.30",
3
+ "version": "1.34.31",
4
4
  "description": "Offline lint for an exported Origami site: lints the pages that changed since the last passing run",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,5 +52,5 @@
52
52
  ]
53
53
  }
54
54
  },
55
- "falconPackageHash": "335c078a8c6afc26c556fcd6c71643e777664f3b26b63a3b7b9832a4"
55
+ "falconPackageHash": "686d35f346108816f50b0d95576cb023e273a903c797136d43187f3e"
56
56
  }