meno-core 1.1.3 → 1.1.5

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 (57) hide show
  1. package/dist/chunks/{chunk-3JXK2QFU.js → chunk-H6EOTPJA.js} +2 -2
  2. package/dist/chunks/{chunk-LOZL5HOF.js → chunk-RLBV2R6J.js} +603 -17
  3. package/dist/chunks/chunk-RLBV2R6J.js.map +7 -0
  4. package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
  5. package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
  6. package/dist/lib/client/index.js +46 -28
  7. package/dist/lib/client/index.js.map +2 -2
  8. package/dist/lib/server/index.js +1304 -241
  9. package/dist/lib/server/index.js.map +4 -4
  10. package/dist/lib/shared/index.js +4 -2
  11. package/dist/lib/shared/index.js.map +1 -1
  12. package/lib/client/core/ComponentBuilder.test.ts +32 -0
  13. package/lib/client/core/ComponentBuilder.ts +30 -0
  14. package/lib/client/theme.test.ts +2 -2
  15. package/lib/client/theme.ts +28 -26
  16. package/lib/server/createServer.ts +5 -1
  17. package/lib/server/cssAudit.test.ts +270 -0
  18. package/lib/server/cssAudit.ts +815 -0
  19. package/lib/server/deMirror.test.ts +61 -0
  20. package/lib/server/deMirror.ts +317 -0
  21. package/lib/server/fileWatcher.test.ts +113 -0
  22. package/lib/server/fileWatcher.ts +42 -1
  23. package/lib/server/index.ts +19 -0
  24. package/lib/server/routes/api/core-routes.ts +18 -0
  25. package/lib/server/routes/api/cssAudit.test.ts +101 -0
  26. package/lib/server/routes/api/cssAudit.ts +138 -0
  27. package/lib/server/routes/api/deMirror.test.ts +105 -0
  28. package/lib/server/routes/api/deMirror.ts +127 -0
  29. package/lib/server/routes/api/pages.ts +2 -2
  30. package/lib/server/services/componentService.test.ts +43 -0
  31. package/lib/server/services/componentService.ts +49 -12
  32. package/lib/server/services/configService.ts +0 -5
  33. package/lib/server/services/pageService.test.ts +15 -0
  34. package/lib/server/services/pageService.ts +44 -22
  35. package/lib/server/ssr/htmlGenerator.test.ts +29 -1
  36. package/lib/server/ssr/htmlGenerator.ts +86 -14
  37. package/lib/server/themeCssCodec.test.ts +67 -0
  38. package/lib/server/themeCssCodec.ts +67 -5
  39. package/lib/server/webflow/templateWrapper.ts +54 -0
  40. package/lib/shared/cssGeneration.test.ts +64 -0
  41. package/lib/shared/cssGeneration.ts +6 -1
  42. package/lib/shared/interfaces/contentProvider.ts +9 -0
  43. package/lib/shared/tailwindThemeScale.ts +1 -0
  44. package/lib/shared/types/cms.ts +1 -0
  45. package/lib/shared/types/comment.ts +9 -3
  46. package/lib/shared/utilityClassConfig.ts +471 -10
  47. package/lib/shared/utilityClassMapper.test.ts +173 -2
  48. package/lib/shared/utilityClassMapper.ts +37 -0
  49. package/lib/shared/utilityClassNames.ts +164 -0
  50. package/lib/shared/utils/fileUtils.ts +11 -0
  51. package/lib/shared/validation/schemas.ts +1 -0
  52. package/lib/shared/viewportUnits.test.ts +34 -1
  53. package/lib/shared/viewportUnits.ts +43 -0
  54. package/package.json +3 -1
  55. package/vite.config.ts +4 -1
  56. package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
  57. /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-H6EOTPJA.js.map} +0 -0
@@ -0,0 +1,270 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ auditStylesheet,
4
+ collectClassesFromPageData,
5
+ extractRules,
6
+ sectionPrefix,
7
+ type PageClassUsage,
8
+ } from './cssAudit';
9
+
10
+ const CSS = `
11
+ body { margin: 0; color: #111; }
12
+ h1 { font-size: 2em; }
13
+ .w-button { display: inline-block; padding: 9px 15px; }
14
+
15
+ /* shared design-system + utilities */
16
+ .container { max-width: 1200px; margin: 0 auto; }
17
+ .section-label { font-size: 12px; }
18
+ .left-arrow { transform: rotate(180deg); }
19
+ .type-2 { opacity: 0.5; }
20
+ .margin-bottom-16 { margin-bottom: 16px; }
21
+
22
+ /* team section (kept: 3 classes) */
23
+ .team-card { border: 1px solid #ccc; }
24
+ .team-grid { display: grid; }
25
+ .team-avatar { border-radius: 50%; }
26
+ .teem-badge { color: gold; }
27
+
28
+ /* home section (kept: 3 classes) */
29
+ .home-hero { min-height: 100vh; }
30
+ .home-grid { display: grid; }
31
+ .home-cta { color: #fff; }
32
+
33
+ /* webflow auto-generated element wrappers (fix 2) */
34
+ .div-block-3 { display: block; }
35
+ .text-block-2 { color: #333; }
36
+ .heading-5 { font-size: 20px; }
37
+ .image { max-width: 100%; }
38
+
39
+ /* numeric-suffixed section id — should strip to \`home\` (fix 1) */
40
+ .home42 { color: teal; }
41
+
42
+ /* button section (kept: 4) + a distance-2 false-typo candidate \`bottom\` (fix 3) */
43
+ .button-cta { background: blue; }
44
+ .button-ghost { border: 1px solid; }
45
+ .button-wide { width: 100%; }
46
+ .bottom { position: absolute; }
47
+
48
+ /* small + dead + compound-only */
49
+ .case-card { padding: 8px; }
50
+ .unused-thing { color: red; }
51
+ .promo-tag.is-featured { color: gold; }
52
+
53
+ /* relationships */
54
+ .team-card .button { font-weight: bold; }
55
+ .team-card.home-hero { gap: 4px; }
56
+
57
+ @media screen and (max-width: 767px) {
58
+ .team-card { padding: 8px; }
59
+ .container { max-width: 100%; }
60
+ }
61
+ `;
62
+
63
+ const PAGES: PageClassUsage[] = [
64
+ { path: '/a', classes: ['container', 'section-label', 'team-card', 'team-grid', 'home-hero'] },
65
+ { path: '/b', classes: ['container', 'section-label', 'team-avatar', 'teem-badge', 'home-grid'] },
66
+ { path: '/c', classes: ['container', 'section-label', 'case-card', 'home-cta'] },
67
+ { path: '/d', classes: ['container', 'margin-bottom-16', 'mystery-util', 'div-block-3'] },
68
+ { path: '/e', classes: ['container', 'team-card', 'button-cta'] },
69
+ ];
70
+
71
+ function byName(report: ReturnType<typeof auditStylesheet>, name: string) {
72
+ const info = report.classes.find((c) => c.name === name);
73
+ if (!info) throw new Error(`class .${name} not found in report`);
74
+ return info;
75
+ }
76
+
77
+ describe('auditStylesheet', () => {
78
+ const report = auditStylesheet({ css: CSS, pages: PAGES });
79
+
80
+ test('counts source totals and threshold', () => {
81
+ expect(report.source.pageCount).toBe(5);
82
+ expect(report.source.sharedMinPages).toBe(3); // max(2, ceil(5/2))
83
+ expect(report.source.rules).toBeGreaterThan(0);
84
+ });
85
+
86
+ test('element-only rules go to the base bucket', () => {
87
+ expect(report.buckets.base.rules).toBe(2); // body + h1
88
+ expect(report.buckets.base.declarations).toBe(3);
89
+ });
90
+
91
+ test('classifies vendor / shared (usage + hints) / section', () => {
92
+ expect(byName(report, 'w-button').category).toBe('vendor');
93
+ expect(byName(report, 'container').category).toBe('shared'); // hint + used on 5 pages
94
+ expect(byName(report, 'section-label').category).toBe('shared'); // used on 3 pages (>= threshold)
95
+ expect(byName(report, 'team-card').proposedBucket).toBe('team');
96
+ expect(byName(report, 'home-hero').proposedBucket).toBe('home');
97
+ });
98
+
99
+ test('utility hints fold obvious utilities into shared', () => {
100
+ expect(byName(report, 'left-arrow').category).toBe('shared'); // `arrow`
101
+ expect(byName(report, 'type-2').category).toBe('shared'); // combo variant
102
+ expect(byName(report, 'margin-bottom-16').category).toBe('shared'); // spacing
103
+ });
104
+
105
+ test('folds small section buckets into misc', () => {
106
+ expect(byName(report, 'case-card').proposedBucket).toBe('misc');
107
+ expect(byName(report, 'unused-thing').proposedBucket).toBe('misc');
108
+ expect(report.flags.foldedSections.find((f) => f.from === 'case')).toMatchObject({
109
+ into: 'misc',
110
+ reason: 'small',
111
+ });
112
+ });
113
+
114
+ test('merges typo prefixes into the nearest kept section', () => {
115
+ // `teem-badge` (prefix `teem`) merges into the kept `team` section.
116
+ expect(byName(report, 'teem-badge').proposedBucket).toBe('team');
117
+ expect(report.flags.foldedSections.find((f) => f.from === 'teem')).toMatchObject({
118
+ into: 'team',
119
+ reason: 'typo',
120
+ });
121
+ });
122
+
123
+ test('collapses to a small set of real sections + misc', () => {
124
+ const prefixes = Object.keys(report.buckets.sections);
125
+ expect(prefixes).toContain('team');
126
+ expect(prefixes).toContain('home');
127
+ expect(prefixes).toContain('button');
128
+ expect(prefixes).toContain('misc');
129
+ expect(prefixes).not.toContain('teem'); // typo → team
130
+ expect(prefixes).not.toContain('case'); // singleton → misc
131
+ expect(prefixes).not.toContain('unused');
132
+ expect(prefixes).not.toContain('bottom'); // distance-2 false typo → misc, not button
133
+ });
134
+
135
+ test('fix 1 — strips trailing digits from section prefixes (Webflow numeric IDs)', () => {
136
+ // `.home42` is a numeric-suffixed section id → merges into the `home` section, not its own bucket.
137
+ expect(byName(report, 'home42').proposedBucket).toBe('home');
138
+ });
139
+
140
+ test('fix 2 — routes Webflow auto-generated wrappers to the webflow-auto bucket', () => {
141
+ expect(byName(report, 'div-block-3').category).toBe('webflow-auto'); // auto beats page-usage
142
+ expect(byName(report, 'text-block-2').category).toBe('webflow-auto');
143
+ expect(byName(report, 'heading-5').category).toBe('webflow-auto');
144
+ expect(report.buckets.webflowAuto.classes).toBeGreaterThanOrEqual(3);
145
+ // A bare ambiguous element word is NOT auto — `.image` could be a real design-system class.
146
+ expect(byName(report, 'image').category).not.toBe('webflow-auto');
147
+ });
148
+
149
+ test('fix 3 — typo-merge rejects distance-2 / low-overlap false pairs', () => {
150
+ // `.bottom` must NOT fold into the kept `button` section (edit distance 2, shared "b").
151
+ expect(byName(report, 'bottom').proposedBucket).toBe('misc');
152
+ expect(report.flags.foldedSections.find((f) => f.from === 'bottom')?.into).not.toBe('button');
153
+ });
154
+
155
+ test('re-unites responsive variants with the base rule', () => {
156
+ const team = byName(report, 'team-card');
157
+ expect(team.breakpoints).toContain('');
158
+ expect(team.breakpoints).toContain('screen and (max-width:767px)');
159
+ expect(team.declarations).toBe(4); // base + descendant + compound + @media
160
+ expect(team.ruleCount).toBe(4);
161
+ expect(report.breakpoints).toContain('screen and (max-width:767px)');
162
+ });
163
+
164
+ test('detects compound-only classes', () => {
165
+ // promo-tag only ever appears as `.promo-tag.is-featured`.
166
+ expect(byName(report, 'promo-tag').compoundOnly).toBe(true);
167
+ // team-card has a lone `.team-card { … }` rule.
168
+ expect(byName(report, 'team-card').compoundOnly).toBe(false);
169
+ });
170
+
171
+ test('flags selectors that straddle two kept section buckets (misc excluded)', () => {
172
+ const flag = report.flags.crossBoundarySelectors.find(
173
+ (f) => f.buckets.includes('team') && f.buckets.includes('home'),
174
+ );
175
+ expect(flag).toBeTruthy();
176
+ expect(report.flags.crossBoundarySelectors.every((f) => f.buckets.length >= 2)).toBe(true);
177
+ });
178
+
179
+ test('reports dead classes and page-only classes', () => {
180
+ expect(report.flags.deadClasses).toContain('unused-thing');
181
+ expect(report.flags.pageOnlyClasses).toContain('mystery-util');
182
+ expect(report.flags.pageOnlyClasses).not.toContain('container');
183
+ });
184
+
185
+ test('minSectionClasses is tunable — 1 disables folding', () => {
186
+ const loose = auditStylesheet({ css: CSS, pages: PAGES, options: { minSectionClasses: 1 } });
187
+ expect(loose.flags.foldedSections).toHaveLength(0);
188
+ expect(loose.classes.find((c) => c.name === 'case-card')?.proposedBucket).toBe('case');
189
+ expect(Object.keys(loose.buckets.sections)).not.toContain('misc');
190
+ });
191
+
192
+ test('mergeTypos=false sends typos to misc instead of a kept section', () => {
193
+ const noTypo = auditStylesheet({ css: CSS, pages: PAGES, options: { mergeTypos: false } });
194
+ expect(noTypo.classes.find((c) => c.name === 'teem-badge')?.proposedBucket).toBe('misc');
195
+ });
196
+ });
197
+
198
+ describe('collectClassesFromPageData', () => {
199
+ test('harvests class/className strings anywhere in the tree', () => {
200
+ const pageData = {
201
+ root: {
202
+ tag: 'div',
203
+ attributes: { class: 'page-wrapper has-overflow' },
204
+ children: [
205
+ { tag: 'span', props: { class: 'label' }, children: ['hi'] },
206
+ { tag: 'a', attributes: { class: 'button is-primary' } },
207
+ ],
208
+ },
209
+ components: {
210
+ Card: { component: { structure: { className: 'card-root', children: [] } } },
211
+ },
212
+ };
213
+ const classes = collectClassesFromPageData(pageData);
214
+ expect(classes).toEqual(
215
+ expect.arrayContaining(['page-wrapper', 'has-overflow', 'label', 'button', 'is-primary', 'card-root']),
216
+ );
217
+ });
218
+
219
+ test('ignores non-string class values and empties', () => {
220
+ const classes = collectClassesFromPageData({ class: { _i18n: true }, other: ' ', nested: [{ class: '' }] });
221
+ expect(classes).toEqual([]);
222
+ });
223
+ });
224
+
225
+ describe('sectionPrefix', () => {
226
+ test('takes the first dash/underscore segment', () => {
227
+ expect(sectionPrefix('team-card-inner')).toBe('team');
228
+ expect(sectionPrefix('home_v1_hero')).toBe('home');
229
+ expect(sectionPrefix('solo')).toBe('solo');
230
+ });
231
+
232
+ test('strips trailing digits (Webflow numeric section IDs)', () => {
233
+ expect(sectionPrefix('header76')).toBe('header');
234
+ expect(sectionPrefix('cta39')).toBe('cta');
235
+ expect(sectionPrefix('tda06-grid')).toBe('tda');
236
+ expect(sectionPrefix('testimonial26')).toBe('testimonial');
237
+ });
238
+ });
239
+
240
+ describe('extractRules', () => {
241
+ const rules = extractRules(CSS);
242
+ const find = (selector: string, media: string | null = null) =>
243
+ rules.find((r) => r.selector === selector && r.media === media);
244
+
245
+ test('emits only class-bearing selectors (element/reset rules omitted)', () => {
246
+ // body {} and h1 {} carry no class → excluded.
247
+ expect(rules.some((r) => r.selector === 'body')).toBe(false);
248
+ expect(rules.some((r) => r.selector === 'h1')).toBe(false);
249
+ expect(find('.container')).toBeTruthy();
250
+ expect(find('.w-button')).toBeTruthy();
251
+ });
252
+
253
+ test('captures classes, subject classes, and compound shape', () => {
254
+ // descendant: subject is `.button`, scope adds `.team-card`.
255
+ const descendant = find('.team-card .button');
256
+ expect(descendant?.classes.sort()).toEqual(['button', 'team-card']);
257
+ expect(descendant?.subjectClasses).toEqual(['button']);
258
+ expect(descendant?.compound).toBe(true);
259
+ // lone class → not compound.
260
+ expect(find('.container')?.compound).toBe(false);
261
+ });
262
+
263
+ test('threads @media context onto responsive variants', () => {
264
+ const responsive = find('.team-card', 'screen and (max-width:767px)');
265
+ expect(responsive).toBeTruthy();
266
+ expect(responsive?.declarations).toBe(1); // padding: 8px
267
+ // The base .team-card rule is a separate entry with null media.
268
+ expect(find('.team-card', null)).toBeTruthy();
269
+ });
270
+ });