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.
- package/dist/chunks/{chunk-3JXK2QFU.js → chunk-H6EOTPJA.js} +2 -2
- package/dist/chunks/{chunk-LOZL5HOF.js → chunk-RLBV2R6J.js} +603 -17
- package/dist/chunks/chunk-RLBV2R6J.js.map +7 -0
- package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
- package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
- package/dist/lib/client/index.js +46 -28
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1304 -241
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +4 -2
- package/dist/lib/shared/index.js.map +1 -1
- package/lib/client/core/ComponentBuilder.test.ts +32 -0
- package/lib/client/core/ComponentBuilder.ts +30 -0
- package/lib/client/theme.test.ts +2 -2
- package/lib/client/theme.ts +28 -26
- package/lib/server/createServer.ts +5 -1
- package/lib/server/cssAudit.test.ts +270 -0
- package/lib/server/cssAudit.ts +815 -0
- package/lib/server/deMirror.test.ts +61 -0
- package/lib/server/deMirror.ts +317 -0
- package/lib/server/fileWatcher.test.ts +113 -0
- package/lib/server/fileWatcher.ts +42 -1
- package/lib/server/index.ts +19 -0
- package/lib/server/routes/api/core-routes.ts +18 -0
- package/lib/server/routes/api/cssAudit.test.ts +101 -0
- package/lib/server/routes/api/cssAudit.ts +138 -0
- package/lib/server/routes/api/deMirror.test.ts +105 -0
- package/lib/server/routes/api/deMirror.ts +127 -0
- package/lib/server/routes/api/pages.ts +2 -2
- package/lib/server/services/componentService.test.ts +43 -0
- package/lib/server/services/componentService.ts +49 -12
- package/lib/server/services/configService.ts +0 -5
- package/lib/server/services/pageService.test.ts +15 -0
- package/lib/server/services/pageService.ts +44 -22
- package/lib/server/ssr/htmlGenerator.test.ts +29 -1
- package/lib/server/ssr/htmlGenerator.ts +86 -14
- package/lib/server/themeCssCodec.test.ts +67 -0
- package/lib/server/themeCssCodec.ts +67 -5
- package/lib/server/webflow/templateWrapper.ts +54 -0
- package/lib/shared/cssGeneration.test.ts +64 -0
- package/lib/shared/cssGeneration.ts +6 -1
- package/lib/shared/interfaces/contentProvider.ts +9 -0
- package/lib/shared/tailwindThemeScale.ts +1 -0
- package/lib/shared/types/cms.ts +1 -0
- package/lib/shared/types/comment.ts +9 -3
- package/lib/shared/utilityClassConfig.ts +471 -10
- package/lib/shared/utilityClassMapper.test.ts +173 -2
- package/lib/shared/utilityClassMapper.ts +37 -0
- package/lib/shared/utilityClassNames.ts +164 -0
- package/lib/shared/utils/fileUtils.ts +11 -0
- package/lib/shared/validation/schemas.ts +1 -0
- package/lib/shared/viewportUnits.test.ts +34 -1
- package/lib/shared/viewportUnits.ts +43 -0
- package/package.json +3 -1
- package/vite.config.ts +4 -1
- package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
- /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
|
+
});
|