meno-core 1.1.3 → 1.1.4

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 (50) hide show
  1. package/dist/chunks/{chunk-LOZL5HOF.js → chunk-UOF4MCAD.js} +64 -2
  2. package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
  3. package/dist/chunks/{chunk-3JXK2QFU.js → chunk-ZEDLSHYQ.js} +2 -2
  4. package/dist/lib/client/index.js +46 -28
  5. package/dist/lib/client/index.js.map +2 -2
  6. package/dist/lib/server/index.js +1267 -233
  7. package/dist/lib/server/index.js.map +4 -4
  8. package/dist/lib/shared/index.js +3 -1
  9. package/dist/lib/shared/index.js.map +1 -1
  10. package/lib/client/core/ComponentBuilder.test.ts +32 -0
  11. package/lib/client/core/ComponentBuilder.ts +30 -0
  12. package/lib/client/theme.test.ts +2 -2
  13. package/lib/client/theme.ts +28 -26
  14. package/lib/server/createServer.ts +5 -1
  15. package/lib/server/cssAudit.test.ts +270 -0
  16. package/lib/server/cssAudit.ts +815 -0
  17. package/lib/server/deMirror.test.ts +61 -0
  18. package/lib/server/deMirror.ts +317 -0
  19. package/lib/server/fileWatcher.test.ts +23 -0
  20. package/lib/server/fileWatcher.ts +6 -1
  21. package/lib/server/index.ts +19 -0
  22. package/lib/server/routes/api/core-routes.ts +18 -0
  23. package/lib/server/routes/api/cssAudit.test.ts +101 -0
  24. package/lib/server/routes/api/cssAudit.ts +138 -0
  25. package/lib/server/routes/api/deMirror.test.ts +105 -0
  26. package/lib/server/routes/api/deMirror.ts +127 -0
  27. package/lib/server/routes/api/pages.ts +2 -2
  28. package/lib/server/services/componentService.test.ts +43 -0
  29. package/lib/server/services/componentService.ts +49 -12
  30. package/lib/server/services/pageService.test.ts +15 -0
  31. package/lib/server/services/pageService.ts +44 -22
  32. package/lib/server/ssr/htmlGenerator.test.ts +29 -0
  33. package/lib/server/ssr/htmlGenerator.ts +83 -3
  34. package/lib/server/themeCssCodec.test.ts +67 -0
  35. package/lib/server/themeCssCodec.ts +67 -5
  36. package/lib/server/webflow/templateWrapper.ts +54 -0
  37. package/lib/shared/cssGeneration.test.ts +28 -0
  38. package/lib/shared/interfaces/contentProvider.ts +9 -0
  39. package/lib/shared/types/cms.ts +1 -0
  40. package/lib/shared/utilityClassConfig.ts +2 -0
  41. package/lib/shared/utilityClassMapper.test.ts +53 -0
  42. package/lib/shared/utilityClassNames.ts +74 -0
  43. package/lib/shared/utils/fileUtils.ts +11 -0
  44. package/lib/shared/validation/schemas.ts +1 -0
  45. package/lib/shared/viewportUnits.test.ts +34 -1
  46. package/lib/shared/viewportUnits.ts +43 -0
  47. package/package.json +3 -1
  48. package/vite.config.ts +4 -1
  49. package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
  50. /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-ZEDLSHYQ.js.map} +0 -0
@@ -0,0 +1,815 @@
1
+ /**
2
+ * CSS Audit — ownership map for a monolithic (e.g. Webflow-exported) stylesheet.
3
+ *
4
+ * This is the read-only "first step" of de-mirroring: it never rewrites or moves a byte. It
5
+ * parses a stylesheet into an AST, re-unites every selector with the responsive variants that a
6
+ * Webflow export pools at the end of the file, cross-references each class against the project's
7
+ * page DOM, and proposes how the rules would split into buckets:
8
+ *
9
+ * - base — element/reset rules with no class (normalize + bare typography)
10
+ * - vendor — Webflow framework classes (`.w-*`)
11
+ * - webflow-auto — Webflow's auto-generated element wrappers (`div-block-2`, `text-block`, `heading-5`)
12
+ * - shared — cross-cutting design-system classes (utilities, state/combo, used across pages)
13
+ * - sections — everything else, grouped by class-name prefix (team-*, home-*, case-*, …); tiny
14
+ * or typo prefixes fold into the nearest real section or a `misc` catch-all
15
+ *
16
+ * It also flags the cases that make a naive split unsafe: selectors that straddle two section
17
+ * buckets, the "fuzzy middle" classes near the shared/local threshold, dead classes, and classes
18
+ * used in pages but absent from this sheet. The output is data to decide from, not a change.
19
+ *
20
+ * Pure and dependency-light (css-tree only) so it is unit-testable and reusable across every
21
+ * Webflow-style import — no filesystem or HTTP here; the route layer feeds it `css` + `pages`.
22
+ */
23
+
24
+ import * as csstree from 'css-tree';
25
+
26
+ /** Classes used on a single page (the page's DOM, flattened to class tokens). */
27
+ export interface PageClassUsage {
28
+ path: string;
29
+ classes: string[];
30
+ }
31
+
32
+ export interface CssAuditOptions {
33
+ /**
34
+ * Minimum number of pages a class must appear on to be proposed as "shared" rather than
35
+ * section-local. Defaults to `max(2, ceil(pageCount / 2))`.
36
+ */
37
+ sharedMinPages?: number;
38
+ /**
39
+ * Regex sources (matched against the class name) that force a class into the shared bucket
40
+ * regardless of page usage — for spacing/layout utilities that are clearly cross-cutting even
41
+ * when they happen to be used on few pages. Defaults to {@link DEFAULT_UTILITY_HINTS}.
42
+ */
43
+ utilityHints?: string[];
44
+ /**
45
+ * Regex sources matched against the class name that route Webflow's auto-generated element
46
+ * wrappers (`div-block-2`, `text-block`, `heading-5`) into the `webflow-auto` bucket instead of
47
+ * letting them masquerade as sections. Defaults to {@link DEFAULT_WEBFLOW_AUTO}.
48
+ */
49
+ webflowAuto?: string[];
50
+ /**
51
+ * A section-prefix bucket with fewer than this many classes is not worth its own file: it folds
52
+ * into the nearest kept bucket (typo) or the misc catch-all. Default 3. Set to 1 to disable.
53
+ */
54
+ minSectionClasses?: number;
55
+ /** Name of the catch-all bucket that small/orphan section classes fold into. Default `'misc'`. */
56
+ miscBucketName?: string;
57
+ /**
58
+ * Fold a small section prefix into a near-identical kept prefix (designer typos like
59
+ * `testiimonial` → `testimonial`) instead of dumping it in misc. Default true.
60
+ */
61
+ mergeTypos?: boolean;
62
+ /** Cap on how many sample selectors to keep per class. Default 5. */
63
+ selectorSampleLimit?: number;
64
+ /** Cap on how many entries to keep in each `flags` list. Default 200. */
65
+ flagLimit?: number;
66
+ }
67
+
68
+ /**
69
+ * Name patterns treated as shared even at low page usage — utilities, state/combo classes, colors,
70
+ * and cross-cutting UI bits that a Webflow designer scattered without a section prefix. Matched
71
+ * (unanchored regex) against the whole class name, so `arrow` also catches `left-arrow-team`.
72
+ * Kept deliberately conservative to avoid folding real component classes into shared.
73
+ */
74
+ export const DEFAULT_UTILITY_HINTS: string[] = [
75
+ // Spacing / sizing
76
+ '^(margin|padding|gap)(-|$)',
77
+ '^no-(padding|margin|overflow)',
78
+ '(^|-)auto-margin$',
79
+ '^(max|min)-(w|h|width|height)',
80
+ '^(w|h)-[0-9]',
81
+ '^size-[0-9]',
82
+ '^(small|medium|large)(-|$)',
83
+ // Layout / display
84
+ '^align(-|$)',
85
+ '^justify(-|$)',
86
+ '^(grid|col|flex)-',
87
+ '^container(-|$)',
88
+ '(^|-)(overflow|opacity|relative|absolute|sticky|inline|centered)(-|$)',
89
+ '^(hide|show|hidden|visible)$',
90
+ // State / Webflow combo classes
91
+ '^is--?',
92
+ '^has--?',
93
+ '^on-h[0-9]',
94
+ '^type-[0-9]+$',
95
+ '(^|-)active$',
96
+ // Colors
97
+ '(^|-)(white|black|dark|light|primary|secondary|grey|gray)(-|$)',
98
+ // Cross-cutting UI
99
+ '(^|-)arrow',
100
+ // Relume / newer-Webflow utility families (text-size-*, heading-style-*, spacer-*, max-width-*)
101
+ '^text-(size|align|color|weight|style)',
102
+ '^heading-style',
103
+ '^(spacer|max-width|background-color)(-|$)',
104
+ ];
105
+
106
+ /**
107
+ * Webflow's auto-generated element wrappers — not real components, just default class names it
108
+ * assigns to blocks/headings/images (often with a numeric suffix). Routed to their own bucket so
109
+ * they don't masquerade as sections. Unambiguous wrappers match bare or numbered; the ambiguous
110
+ * element words (heading/paragraph/image/…) match ONLY when numbered, since a bare `.heading` or
111
+ * `.image` is usually a real design-system class.
112
+ */
113
+ export const DEFAULT_WEBFLOW_AUTO: string[] = [
114
+ '^(div-block|text-block|link-block|bold-text|block-quote|rich-text-block|html-embed)(-\\d+)?$',
115
+ '^(heading|paragraph|image|list|list-item|figure|grid|columns?|field-label|text-field)-\\d+$',
116
+ ];
117
+
118
+ export type ClassCategory = 'vendor' | 'webflow-auto' | 'shared' | 'section';
119
+
120
+ export interface BucketStat {
121
+ classes: number;
122
+ rules: number;
123
+ declarations: number;
124
+ approxBytes: number;
125
+ }
126
+
127
+ export interface ClassInfo {
128
+ name: string;
129
+ category: ClassCategory;
130
+ /** `'vendor'`, `'shared'`, or the section prefix the class would live under. */
131
+ proposedBucket: string;
132
+ ruleCount: number;
133
+ declarations: number;
134
+ /** Distinct `@media` preludes this class appears under (`''` = base, no media). */
135
+ breakpoints: string[];
136
+ pagesUsedIn: number;
137
+ /** Up to `selectorSampleLimit` selectors that mention this class. */
138
+ selectors: string[];
139
+ /** True when the class is never the lone subject of a rule — it only modifies/co-occurs. */
140
+ compoundOnly: boolean;
141
+ }
142
+
143
+ export interface CrossBoundaryFlag {
144
+ selector: string;
145
+ media: string | null;
146
+ classes: string[];
147
+ /** The distinct section buckets this one selector touches. */
148
+ buckets: string[];
149
+ }
150
+
151
+ /** A small/typo section prefix that was folded away during refinement. */
152
+ export interface FoldedSection {
153
+ /** The original prefix bucket. */
154
+ from: string;
155
+ /** Where its classes ended up — a kept prefix (typo) or the misc bucket (small). */
156
+ into: string;
157
+ classes: number;
158
+ reason: 'small' | 'typo';
159
+ }
160
+
161
+ export interface CssAuditReport {
162
+ source: {
163
+ bytes: number;
164
+ rules: number;
165
+ selectors: number;
166
+ declarations: number;
167
+ pageCount: number;
168
+ sharedMinPages: number;
169
+ };
170
+ /** Distinct `@media` preludes, in first-seen (source) order. */
171
+ breakpoints: string[];
172
+ buckets: {
173
+ base: BucketStat;
174
+ vendor: BucketStat;
175
+ /** Webflow auto-generated element wrappers (`div-block-*`, `text-block`, `heading-N`). */
176
+ webflowAuto: BucketStat;
177
+ shared: BucketStat;
178
+ sections: Record<string, BucketStat>;
179
+ };
180
+ classes: ClassInfo[];
181
+ flags: {
182
+ crossBoundarySelectors: CrossBoundaryFlag[];
183
+ /** Section classes used on 2..sharedMinPages-1 pages — the borderline shared/local calls. */
184
+ fuzzyMiddle: string[];
185
+ /** Classes defined in the sheet but used on 0 pages (vendor excluded). */
186
+ deadClasses: string[];
187
+ /** Class tokens used on pages but absent from this sheet (Meno utilities, other sheets). */
188
+ pageOnlyClasses: string[];
189
+ /** Section prefixes folded into a kept bucket or misc during refinement (transparency). */
190
+ foldedSections: FoldedSection[];
191
+ };
192
+ }
193
+
194
+ const CLASS_TOKEN_RE = /\.(-?[_a-zA-Z][\w-]*)/g;
195
+ // Strips the inner argument of functional pseudos so their contents don't fool combinator splitting.
196
+ const FUNCTIONAL_PSEUDO_RE = /:(not|is|where|has|matches)\([^()]*\)/gi;
197
+
198
+ function emptyBucket(): BucketStat {
199
+ return { classes: 0, rules: 0, declarations: 0, approxBytes: 0 };
200
+ }
201
+
202
+ function classTokens(selector: string): string[] {
203
+ const out: string[] = [];
204
+ let m: RegExpExecArray | null;
205
+ CLASS_TOKEN_RE.lastIndex = 0;
206
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
207
+ while ((m = CLASS_TOKEN_RE.exec(selector)) !== null) {
208
+ if (m[1]) out.push(m[1]);
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * The section prefix a class name would bucket under: its first dash/underscore segment, with any
215
+ * trailing digits stripped. The digit-strip collapses Webflow's newer numeric-suffixed section IDs
216
+ * (`header76`, `cta39`, `blog38`, `layout225`, `testimonial26`) onto their real prefix so all the
217
+ * numbered variants land in one bucket instead of fragmenting into dozens.
218
+ */
219
+ export function sectionPrefix(name: string): string {
220
+ const seg = name.split(/[-_]/)[0] ?? name;
221
+ const stripped = seg.replace(/\d+$/, '');
222
+ return stripped.length > 0 ? stripped : seg;
223
+ }
224
+
225
+ /** Length of the shared leading run of characters between two strings. */
226
+ function commonPrefixLength(a: string, b: string): number {
227
+ const max = Math.min(a.length, b.length);
228
+ let i = 0;
229
+ while (i < max && a[i] === b[i]) i++;
230
+ return i;
231
+ }
232
+
233
+ /** Levenshtein edit distance between two short strings. */
234
+ function editDistance(a: string, b: string): number {
235
+ const m = a.length;
236
+ const n = b.length;
237
+ if (m === 0) return n;
238
+ if (n === 0) return m;
239
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
240
+ let curr = new Array<number>(n + 1).fill(0);
241
+ for (let i = 1; i <= m; i++) {
242
+ curr[0] = i;
243
+ for (let j = 1; j <= n; j++) {
244
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
245
+ curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
246
+ }
247
+ [prev, curr] = [curr, prev];
248
+ }
249
+ return prev[n] ?? 0;
250
+ }
251
+
252
+ /**
253
+ * Find the kept section prefix a likely-typo prefix should merge into (e.g. `testiimonial` →
254
+ * `testimonial`), or null if none is close enough. Deliberately conservative — a merge requires a
255
+ * SINGLE edit (distance 1), a length gap of ≤1, and ≥2 shared leading chars. That keeps real
256
+ * single-letter typos (`teem`↔`team`, `homve`↔`home`, `careers`↔`career`, `meetup`↔`meetap`) while
257
+ * rejecting the distance-2 / low-overlap false pairs a looser rule produced across real sites
258
+ * (`content`↔`contact`, `footer`↔`filter`, `bottom`↔`button`, `next`↔`text`, `book`↔`ebook`). Rare
259
+ * two-edit typos (`testimonail`) simply fall to misc instead of merging into the wrong section.
260
+ * On ties, the candidate sharing the longest leading run wins (deterministic).
261
+ */
262
+ function nearestKeptPrefix(prefix: string, kept: Iterable<string>): string | null {
263
+ let best: string | null = null;
264
+ let bestShared = -1;
265
+ for (const candidate of kept) {
266
+ if (Math.abs(candidate.length - prefix.length) > 1) continue;
267
+ const shared = commonPrefixLength(prefix, candidate);
268
+ if (shared < 2) continue;
269
+ if (editDistance(prefix, candidate) !== 1) continue;
270
+ if (shared > bestShared) {
271
+ bestShared = shared;
272
+ best = candidate;
273
+ }
274
+ }
275
+ return best;
276
+ }
277
+
278
+ interface SelectorShape {
279
+ allClasses: string[];
280
+ subjectClasses: string[];
281
+ compound: boolean;
282
+ }
283
+
284
+ /**
285
+ * Split a selector into its compound segments to find the *subject* (rightmost) classes — the
286
+ * element the rule actually styles — versus classes that merely scope it (`.scope .subject`).
287
+ */
288
+ function analyzeSelector(selector: string): SelectorShape {
289
+ const cleaned = selector.replace(FUNCTIONAL_PSEUDO_RE, ' ');
290
+ const segments = cleaned.split(/\s*[>+~]\s*|\s+/).filter((s) => s.trim().length > 0);
291
+ const subject = segments[segments.length - 1] ?? cleaned;
292
+ const subjectClasses = classTokens(subject);
293
+ const allClasses = classTokens(selector);
294
+ const compound = segments.length > 1 || subjectClasses.length > 1;
295
+ return { allClasses, subjectClasses, compound };
296
+ }
297
+
298
+ interface RuleRecord {
299
+ media: string | null;
300
+ bytes: number;
301
+ declarations: number;
302
+ classes: string[];
303
+ subjectClasses: string[];
304
+ }
305
+
306
+ interface ClassAccum {
307
+ name: string;
308
+ ruleCount: number;
309
+ declarations: number;
310
+ breakpoints: Set<string>;
311
+ selectors: string[];
312
+ ownsSimple: boolean;
313
+ }
314
+
315
+ function countDeclarations(block: csstree.CssNode | null | undefined): number {
316
+ let count = 0;
317
+ if (block && block.type === 'Block') {
318
+ block.children.forEach((child) => {
319
+ if (child.type === 'Declaration') count++;
320
+ });
321
+ }
322
+ return count;
323
+ }
324
+
325
+ /** One selector's worth of a class rule, flattened out of the stylesheet. */
326
+ export interface ExtractedRule {
327
+ /** The single selector this entry represents (one entry per selector in a list). */
328
+ selector: string;
329
+ /** The `@media` prelude this rule sits under (`null` = base, no media). */
330
+ media: string | null;
331
+ /** All class tokens mentioned anywhere in the selector. */
332
+ classes: string[];
333
+ /** The rightmost (subject) compound's class tokens — the element actually styled. */
334
+ subjectClasses: string[];
335
+ /** Number of declarations in the rule's block. */
336
+ declarations: number;
337
+ /** Approx bytes attributed to this selector (whole-rule bytes / #class-selectors). */
338
+ bytes: number;
339
+ /** True when the selector is compound (`.a.b`) or a descendant chain (`.a .b`). */
340
+ compound: boolean;
341
+ }
342
+
343
+ /**
344
+ * Flatten a stylesheet to a per-selector list of its CLASS rules (rules whose selector
345
+ * mentions at least one class), threading `@media` context so responsive variants keep
346
+ * their condition. Element-only selectors and at-rule inner blocks (`@keyframes` /
347
+ * `@font-face`) are omitted — they belong to the always-loaded global sheet, not to any
348
+ * component.
349
+ *
350
+ * This is the de-mirror MIGRATION view (vs {@link auditStylesheet}'s aggregate audit): the
351
+ * planner attributes each rule to an owner, and the eventual writer matches rules back to
352
+ * source by `(selector, media)`. Reuses the same selector analysis as the audit.
353
+ */
354
+ export function extractRules(css: string): ExtractedRule[] {
355
+ const ast = csstree.parse(css, { parseValue: false, parseCustomProperty: false });
356
+ const out: ExtractedRule[] = [];
357
+
358
+ const visit = (container: csstree.CssNode, media: string | null, insideSkippedAtrule: boolean): void => {
359
+ const children =
360
+ 'children' in container ? (container as { children: csstree.List<csstree.CssNode> }).children : null;
361
+ if (!children) return;
362
+
363
+ children.forEach((node) => {
364
+ if (node.type === 'Atrule') {
365
+ const name = node.name.toLowerCase();
366
+ if (name === 'media') {
367
+ const prelude = node.prelude ? csstree.generate(node.prelude) : '';
368
+ if (node.block) visit(node.block, prelude, false);
369
+ } else if (name === 'supports') {
370
+ if (node.block) visit(node.block, media, false);
371
+ } else if (node.block) {
372
+ visit(node.block, media, true); // @keyframes/@font-face — skip inner rules
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (node.type !== 'Rule' || insideSkippedAtrule) return;
378
+ if (node.prelude?.type !== 'SelectorList') return;
379
+
380
+ const decls = countDeclarations(node.block);
381
+ const ruleBytes = csstree.generate(node).length;
382
+
383
+ // Collect only the class-bearing selectors, so the rule's bytes split across them.
384
+ const classSelectors: { text: string; shape: SelectorShape }[] = [];
385
+ node.prelude.children.forEach((selectorNode) => {
386
+ const text = csstree.generate(selectorNode);
387
+ const shape = analyzeSelector(text);
388
+ if (shape.allClasses.length > 0) classSelectors.push({ text, shape });
389
+ });
390
+ if (classSelectors.length === 0) return;
391
+
392
+ const perBytes = Math.max(1, Math.round(ruleBytes / classSelectors.length));
393
+ for (const { text, shape } of classSelectors) {
394
+ out.push({
395
+ selector: text,
396
+ media,
397
+ classes: shape.allClasses,
398
+ subjectClasses: shape.subjectClasses,
399
+ declarations: decls,
400
+ bytes: perBytes,
401
+ compound: shape.compound,
402
+ });
403
+ }
404
+ });
405
+ };
406
+
407
+ visit(ast, null, false);
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * Audit a stylesheet against project page usage and return the ownership map.
413
+ */
414
+ export function auditStylesheet(input: {
415
+ css: string;
416
+ pages: PageClassUsage[];
417
+ options?: CssAuditOptions;
418
+ }): CssAuditReport {
419
+ const { css, pages, options = {} } = input;
420
+ const selectorSampleLimit = options.selectorSampleLimit ?? 5;
421
+ const flagLimit = options.flagLimit ?? 200;
422
+ const minSectionClasses = options.minSectionClasses ?? 3;
423
+ const miscBucketName = options.miscBucketName ?? 'misc';
424
+ const mergeTypos = options.mergeTypos ?? true;
425
+ const utilityHints = (options.utilityHints ?? DEFAULT_UTILITY_HINTS).map((s) => new RegExp(s));
426
+ const webflowAutoPatterns = (options.webflowAuto ?? DEFAULT_WEBFLOW_AUTO).map((s) => new RegExp(s));
427
+
428
+ const pageCount = pages.length;
429
+ const sharedMinPages = options.sharedMinPages ?? Math.max(2, Math.ceil(pageCount / 2));
430
+
431
+ // Page usage: class -> number of pages it appears on.
432
+ const pageUseCount = new Map<string, number>();
433
+ for (const page of pages) {
434
+ for (const cls of new Set(page.classes)) {
435
+ pageUseCount.set(cls, (pageUseCount.get(cls) ?? 0) + 1);
436
+ }
437
+ }
438
+
439
+ const ast = csstree.parse(css, { parseValue: false, parseCustomProperty: false });
440
+
441
+ const classMap = new Map<string, ClassAccum>();
442
+ const rules: RuleRecord[] = [];
443
+ const breakpoints: string[] = [];
444
+ const seenMedia = new Set<string>();
445
+ const base = emptyBucket();
446
+ let totalRules = 0;
447
+ let totalSelectors = 0;
448
+ let totalDeclarations = 0;
449
+
450
+ const accumFor = (name: string): ClassAccum => {
451
+ let a = classMap.get(name);
452
+ if (!a) {
453
+ a = { name, ruleCount: 0, declarations: 0, breakpoints: new Set(), selectors: [], ownsSimple: false };
454
+ classMap.set(name, a);
455
+ }
456
+ return a;
457
+ };
458
+
459
+ // Recurse the AST, threading the active @media prelude so responsive variants land on the same
460
+ // class as their base rule (Webflow pools these at the end of the file).
461
+ const visit = (container: csstree.CssNode, media: string | null, insideSkippedAtrule: boolean): void => {
462
+ const children =
463
+ 'children' in container ? (container as { children: csstree.List<csstree.CssNode> }).children : null;
464
+ if (!children) return;
465
+
466
+ children.forEach((node) => {
467
+ if (node.type === 'Atrule') {
468
+ const name = node.name.toLowerCase();
469
+ if (name === 'media') {
470
+ const prelude = node.prelude ? csstree.generate(node.prelude) : '';
471
+ if (!seenMedia.has(prelude)) {
472
+ seenMedia.add(prelude);
473
+ breakpoints.push(prelude);
474
+ }
475
+ if (node.block) visit(node.block, prelude, false);
476
+ } else if (name === 'supports') {
477
+ if (node.block) visit(node.block, media, false);
478
+ } else if (node.block) {
479
+ // @keyframes / @font-face etc. — their inner blocks aren't class rules; skip rules within.
480
+ visit(node.block, media, true);
481
+ }
482
+ return;
483
+ }
484
+
485
+ if (node.type !== 'Rule' || insideSkippedAtrule) return;
486
+ if (node.prelude?.type !== 'SelectorList') return;
487
+
488
+ const ruleBytes = csstree.generate(node).length;
489
+ const decls = countDeclarations(node.block);
490
+ totalDeclarations += decls;
491
+ totalRules += 1;
492
+
493
+ const ruleAllClasses = new Set<string>();
494
+ const ruleSubjectClasses = new Set<string>();
495
+ let hasAnyClass = false;
496
+
497
+ node.prelude.children.forEach((selectorNode) => {
498
+ totalSelectors += 1;
499
+ const selectorText = csstree.generate(selectorNode);
500
+ const shape = analyzeSelector(selectorText);
501
+ if (shape.allClasses.length === 0) return; // element-only selector contributes to base below
502
+
503
+ hasAnyClass = true;
504
+ for (const c of shape.allClasses) ruleAllClasses.add(c);
505
+ for (const c of shape.subjectClasses) ruleSubjectClasses.add(c);
506
+
507
+ const subjectSet = new Set(shape.subjectClasses);
508
+ for (const cls of shape.allClasses) {
509
+ const a = accumFor(cls);
510
+ a.ruleCount += 1;
511
+ a.declarations += decls;
512
+ a.breakpoints.add(media ?? '');
513
+ if (a.selectors.length < selectorSampleLimit && !a.selectors.includes(selectorText)) {
514
+ a.selectors.push(selectorText);
515
+ }
516
+ // The class "owns" a simple rule when it is the sole class of the subject segment.
517
+ if (subjectSet.size === 1 && subjectSet.has(cls)) a.ownsSimple = true;
518
+ }
519
+ });
520
+
521
+ if (!hasAnyClass) {
522
+ // Element/reset/typography rule — base bucket.
523
+ base.rules += 1;
524
+ base.declarations += decls;
525
+ base.approxBytes += ruleBytes;
526
+ return;
527
+ }
528
+
529
+ rules.push({
530
+ media,
531
+ bytes: ruleBytes,
532
+ declarations: decls,
533
+ classes: [...ruleAllClasses],
534
+ subjectClasses: [...ruleSubjectClasses],
535
+ });
536
+ });
537
+ };
538
+
539
+ visit(ast, null, false);
540
+
541
+ // ---- Classify, then refine the section buckets ----------------------------------------------
542
+ // Phase 1 — preliminary classification: vendor (`.w-*`) / shared (utility hint or cross-page
543
+ // usage) / section (by name prefix). Tally section-bucket sizes for the refinement pass.
544
+ interface Prelim {
545
+ category: ClassCategory;
546
+ bucket: string;
547
+ pagesUsedIn: number;
548
+ accum: ClassAccum;
549
+ }
550
+ const prelim = new Map<string, Prelim>();
551
+ const sectionSizes = new Map<string, number>();
552
+ for (const a of classMap.values()) {
553
+ const pagesUsedIn = pageUseCount.get(a.name) ?? 0;
554
+ let category: ClassCategory;
555
+ let bucket: string;
556
+ if (/^w-/.test(a.name)) {
557
+ category = 'vendor';
558
+ bucket = 'vendor';
559
+ } else if (webflowAutoPatterns.some((re) => re.test(a.name))) {
560
+ category = 'webflow-auto';
561
+ bucket = 'webflow-auto';
562
+ } else if (utilityHints.some((re) => re.test(a.name)) || pagesUsedIn >= sharedMinPages) {
563
+ category = 'shared';
564
+ bucket = 'shared';
565
+ } else {
566
+ category = 'section';
567
+ bucket = sectionPrefix(a.name);
568
+ sectionSizes.set(bucket, (sectionSizes.get(bucket) ?? 0) + 1);
569
+ }
570
+ prelim.set(a.name, { category, bucket, pagesUsedIn, accum: a });
571
+ }
572
+
573
+ // Phase 2 — refine: a section prefix with >= minSectionClasses is "kept". Each smaller prefix
574
+ // folds into the nearest kept prefix (typo) or the misc catch-all (small). This is what collapses
575
+ // the long tail of singleton/typo buckets into the dozen-or-so real sections.
576
+ const keptSections = new Set<string>();
577
+ for (const [prefix, count] of sectionSizes) {
578
+ if (count >= minSectionClasses) keptSections.add(prefix);
579
+ }
580
+ const sectionRemap = new Map<string, { bucket: string; reason: 'small' | 'typo' }>();
581
+ for (const [prefix, count] of sectionSizes) {
582
+ if (keptSections.has(prefix) || count === 0) continue;
583
+ const near = mergeTypos && prefix.length >= 4 ? nearestKeptPrefix(prefix, keptSections) : null;
584
+ sectionRemap.set(prefix, near ? { bucket: near, reason: 'typo' } : { bucket: miscBucketName, reason: 'small' });
585
+ }
586
+
587
+ // Phase 3 — finalize per-class classification, applying the remap and recording what folded.
588
+ const foldedAgg = new Map<string, { from: string; into: string; reason: 'small' | 'typo'; classes: number }>();
589
+ const classified = new Map<string, { category: ClassCategory; bucket: string }>();
590
+ const classes: ClassInfo[] = [];
591
+ for (const [name, p] of prelim) {
592
+ let bucket = p.bucket;
593
+ if (p.category === 'section') {
594
+ const remap = sectionRemap.get(p.bucket);
595
+ if (remap) {
596
+ bucket = remap.bucket;
597
+ const key = `${p.bucket}→${remap.bucket}`;
598
+ const agg = foldedAgg.get(key) ?? { from: p.bucket, into: remap.bucket, reason: remap.reason, classes: 0 };
599
+ agg.classes += 1;
600
+ foldedAgg.set(key, agg);
601
+ }
602
+ }
603
+ classified.set(name, { category: p.category, bucket });
604
+ classes.push({
605
+ name,
606
+ category: p.category,
607
+ proposedBucket: bucket,
608
+ ruleCount: p.accum.ruleCount,
609
+ declarations: p.accum.declarations,
610
+ breakpoints: [...p.accum.breakpoints].sort(),
611
+ pagesUsedIn: p.pagesUsedIn,
612
+ selectors: p.accum.selectors,
613
+ compoundOnly: !p.accum.ownsSimple,
614
+ });
615
+ }
616
+ classes.sort((x, y) => y.declarations - x.declarations || x.name.localeCompare(y.name));
617
+
618
+ const foldedSections: FoldedSection[] = [...foldedAgg.values()]
619
+ .map(({ from, into, classes: c, reason }) => ({ from, into, classes: c, reason }))
620
+ .sort((a, b) => b.classes - a.classes || a.from.localeCompare(b.from));
621
+
622
+ // Bucket byte/rule accounting: assign each rule to a single owner bucket (section > shared >
623
+ // vendor), and flag rules whose classes straddle two or more distinct section buckets.
624
+ const vendor = emptyBucket();
625
+ const webflowAuto = emptyBucket();
626
+ const shared = emptyBucket();
627
+ const sections: Record<string, BucketStat> = {};
628
+ const crossBoundary: CrossBoundaryFlag[] = [];
629
+
630
+ const bucketStat = (bucket: string): BucketStat => {
631
+ if (bucket === 'vendor') return vendor;
632
+ if (bucket === 'webflow-auto') return webflowAuto;
633
+ if (bucket === 'shared') return shared;
634
+ let stat = sections[bucket];
635
+ if (!stat) {
636
+ stat = emptyBucket();
637
+ sections[bucket] = stat;
638
+ }
639
+ return stat;
640
+ };
641
+
642
+ for (const rule of rules) {
643
+ const ownerPool = rule.subjectClasses.length > 0 ? rule.subjectClasses : rule.classes;
644
+ let owner = 'base';
645
+ let bestRank = -1;
646
+ const sectionBucketsTouched = new Set<string>();
647
+ for (const cls of rule.classes) {
648
+ const c = classified.get(cls);
649
+ // misc is a heterogeneous grab-bag, so straddling it isn't a meaningful boundary.
650
+ if (c?.category === 'section' && c.bucket !== miscBucketName) sectionBucketsTouched.add(c.bucket);
651
+ }
652
+ for (const cls of ownerPool) {
653
+ const c = classified.get(cls);
654
+ if (!c) continue;
655
+ // section > shared > webflow-auto > vendor — so a rule touching a real section is attributed
656
+ // to it even when it also carries an auto/utility class.
657
+ const rank = c.category === 'section' ? 4 : c.category === 'shared' ? 3 : c.category === 'webflow-auto' ? 2 : 1;
658
+ if (rank > bestRank) {
659
+ bestRank = rank;
660
+ owner = c.bucket;
661
+ }
662
+ }
663
+ const stat = bucketStat(owner);
664
+ stat.rules += 1;
665
+ stat.declarations += rule.declarations;
666
+ stat.approxBytes += rule.bytes;
667
+
668
+ if (sectionBucketsTouched.size >= 2 && crossBoundary.length < flagLimit) {
669
+ crossBoundary.push({
670
+ selector: rule.classes.map((c) => `.${c}`).join(''),
671
+ media: rule.media,
672
+ classes: rule.classes,
673
+ buckets: [...sectionBucketsTouched].sort(),
674
+ });
675
+ }
676
+ }
677
+
678
+ // Per-bucket class counts.
679
+ for (const info of classes) {
680
+ bucketStat(info.proposedBucket).classes += 1;
681
+ }
682
+
683
+ // Flags derived from classification + usage.
684
+ const fuzzyMiddle: string[] = [];
685
+ const deadClasses: string[] = [];
686
+ for (const info of classes) {
687
+ if (info.category === 'section' && info.pagesUsedIn >= 2 && info.pagesUsedIn < sharedMinPages) {
688
+ fuzzyMiddle.push(info.name);
689
+ }
690
+ if (info.category !== 'vendor' && info.pagesUsedIn === 0) {
691
+ deadClasses.push(info.name);
692
+ }
693
+ }
694
+ const pageOnlyClasses: string[] = [];
695
+ for (const cls of pageUseCount.keys()) {
696
+ if (!classMap.has(cls)) pageOnlyClasses.push(cls);
697
+ }
698
+ pageOnlyClasses.sort();
699
+ deadClasses.sort();
700
+
701
+ return {
702
+ source: {
703
+ bytes: css.length,
704
+ rules: totalRules,
705
+ selectors: totalSelectors,
706
+ declarations: totalDeclarations,
707
+ pageCount,
708
+ sharedMinPages,
709
+ },
710
+ breakpoints,
711
+ buckets: { base, vendor, webflowAuto, shared, sections },
712
+ classes,
713
+ flags: {
714
+ crossBoundarySelectors: crossBoundary,
715
+ fuzzyMiddle: fuzzyMiddle.slice(0, flagLimit),
716
+ deadClasses: deadClasses.slice(0, flagLimit),
717
+ pageOnlyClasses: pageOnlyClasses.slice(0, flagLimit),
718
+ foldedSections,
719
+ },
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Walk a parsed page (JSONPage node tree, component defs, anything) and collect every static
725
+ * class token. Robust to the exact node schema: it harvests any string-valued `class`/`className`
726
+ * property anywhere in the structure, which is where the codec lands static class strings.
727
+ */
728
+ export function collectClassesFromPageData(pageData: unknown): string[] {
729
+ const out = new Set<string>();
730
+ const walk = (value: unknown): void => {
731
+ if (!value) return;
732
+ if (Array.isArray(value)) {
733
+ for (const item of value) walk(item);
734
+ return;
735
+ }
736
+ if (typeof value === 'object') {
737
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
738
+ if ((key === 'class' || key === 'className') && typeof v === 'string') {
739
+ for (const token of v.split(/\s+/)) {
740
+ if (token) out.add(token);
741
+ }
742
+ } else {
743
+ walk(v);
744
+ }
745
+ }
746
+ }
747
+ };
748
+ walk(pageData);
749
+ return [...out];
750
+ }
751
+
752
+ /** Render a compact human-readable summary of a report (for `?format=md`). */
753
+ export function renderAuditMarkdown(report: CssAuditReport): string {
754
+ const { source, buckets, breakpoints, flags } = report;
755
+ const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;
756
+ const lines: string[] = [];
757
+
758
+ lines.push('# CSS audit');
759
+ lines.push('');
760
+ lines.push(
761
+ `**${kb(source.bytes)}** · ${source.rules} rules · ${source.selectors} selectors · ` +
762
+ `${source.declarations} declarations · ${source.pageCount} pages · shared threshold = ${source.sharedMinPages} pages`,
763
+ );
764
+ lines.push('');
765
+ lines.push(`Breakpoints: ${breakpoints.length ? breakpoints.map((b) => `\`${b}\``).join(', ') : '(none)'}`);
766
+ lines.push('');
767
+
768
+ lines.push('## Proposed buckets');
769
+ lines.push('');
770
+ lines.push('| bucket | classes | rules | declarations | ~size |');
771
+ lines.push('| --- | --- | --- | --- | --- |');
772
+ const row = (name: string, b: BucketStat) =>
773
+ `| ${name} | ${b.classes} | ${b.rules} | ${b.declarations} | ${kb(b.approxBytes)} |`;
774
+ lines.push(row('base (reset/typography)', buckets.base));
775
+ lines.push(row('vendor (.w-*)', buckets.vendor));
776
+ lines.push(row('webflow-auto (div-block/text-block/…)', buckets.webflowAuto));
777
+ lines.push(row('shared', buckets.shared));
778
+ // Real sections first (by size); the misc catch-all always last.
779
+ const sectionEntries = Object.entries(buckets.sections).sort((a, b) => b[1].approxBytes - a[1].approxBytes);
780
+ for (const [prefix, stat] of sectionEntries) {
781
+ if (prefix === 'misc') continue;
782
+ lines.push(row(`section: ${prefix}`, stat));
783
+ }
784
+ const misc = buckets.sections.misc;
785
+ if (misc) lines.push(row('misc (folded small/orphan)', misc));
786
+ lines.push('');
787
+ lines.push(`_${sectionEntries.filter(([p]) => p !== 'misc').length} real sections + ${misc ? 'misc' : 'no misc'}_`);
788
+ lines.push('');
789
+
790
+ lines.push('## Flags');
791
+ lines.push('');
792
+ lines.push(`- Cross-boundary selectors (touch ≥2 sections): **${flags.crossBoundarySelectors.length}**`);
793
+ lines.push(`- Fuzzy-middle classes (near shared threshold): **${flags.fuzzyMiddle.length}**`);
794
+ lines.push(`- Dead classes (defined, unused on any page): **${flags.deadClasses.length}**`);
795
+ lines.push(`- Page-only classes (used in pages, not in this sheet): **${flags.pageOnlyClasses.length}**`);
796
+ const foldedClasses = flags.foldedSections.reduce((n, f) => n + f.classes, 0);
797
+ lines.push(
798
+ `- Folded section buckets (small/typo → misc/kept): **${flags.foldedSections.length}** (${foldedClasses} classes)`,
799
+ );
800
+ const typos = flags.foldedSections.filter((f) => f.reason === 'typo');
801
+ if (typos.length) {
802
+ lines.push('');
803
+ lines.push('Typo merges:');
804
+ for (const f of typos) lines.push(`- \`${f.from}\` → \`${f.into}\` (${f.classes})`);
805
+ }
806
+ if (flags.crossBoundarySelectors.length) {
807
+ lines.push('');
808
+ lines.push('Sample cross-boundary selectors:');
809
+ for (const f of flags.crossBoundarySelectors.slice(0, 10)) {
810
+ lines.push(`- \`${f.selector}\`${f.media ? ` @ ${f.media}` : ''} → ${f.buckets.join(' + ')}`);
811
+ }
812
+ }
813
+
814
+ return lines.join('\n');
815
+ }