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.
- package/dist/chunks/{chunk-LOZL5HOF.js → chunk-UOF4MCAD.js} +64 -2
- package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
- package/dist/chunks/{chunk-3JXK2QFU.js → chunk-ZEDLSHYQ.js} +2 -2
- package/dist/lib/client/index.js +46 -28
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1267 -233
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +3 -1
- 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 +23 -0
- package/lib/server/fileWatcher.ts +6 -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/pageService.test.ts +15 -0
- package/lib/server/services/pageService.ts +44 -22
- package/lib/server/ssr/htmlGenerator.test.ts +29 -0
- package/lib/server/ssr/htmlGenerator.ts +83 -3
- 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 +28 -0
- package/lib/shared/interfaces/contentProvider.ts +9 -0
- package/lib/shared/types/cms.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +2 -0
- package/lib/shared/utilityClassMapper.test.ts +53 -0
- package/lib/shared/utilityClassNames.ts +74 -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-ZEDLSHYQ.js.map} +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { planDeMirror, renderDeMirrorMarkdown, type ComponentClassUsage } from './deMirror';
|
|
3
|
+
|
|
4
|
+
const CSS = `
|
|
5
|
+
.team-card { border: 1px solid #ccc; }
|
|
6
|
+
.team-card .title { font-weight: bold; }
|
|
7
|
+
.case-card { padding: 8px; }
|
|
8
|
+
.container { max-width: 1200px; }
|
|
9
|
+
.orphan { color: red; }
|
|
10
|
+
.wrap-x p { margin: 0; }
|
|
11
|
+
@media screen and (max-width: 767px) { .team-card { padding: 4px; } }
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
const COMPONENTS: ComponentClassUsage[] = [
|
|
15
|
+
{ name: 'TeamCard', classes: ['team-card', 'title', 'container'] },
|
|
16
|
+
{ name: 'CaseCard', classes: ['case-card', 'container'] },
|
|
17
|
+
{ name: 'WrapComp', classes: ['wrap-x'] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const plan = planDeMirror({ css: CSS, components: COMPONENTS });
|
|
21
|
+
const slice = (name: string) => plan.components.find((c) => c.name === name);
|
|
22
|
+
|
|
23
|
+
describe('planDeMirror', () => {
|
|
24
|
+
test('counts every class rule and stays lossless (round-trip)', () => {
|
|
25
|
+
expect(plan.source.classRules).toBe(7);
|
|
26
|
+
expect(plan.source.componentCount).toBe(3);
|
|
27
|
+
expect(plan.roundTrip).toBe(true);
|
|
28
|
+
// Every rule assigned exactly once.
|
|
29
|
+
expect(plan.moved.rules + plan.global.sharedRules + plan.global.deadRules).toBe(7);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('moves single-owner rules onto their component', () => {
|
|
33
|
+
expect(slice('TeamCard')?.ruleCount).toBe(3); // .team-card, .team-card .title, @media .team-card
|
|
34
|
+
expect(slice('CaseCard')?.ruleCount).toBe(1);
|
|
35
|
+
expect(slice('WrapComp')?.ruleCount).toBe(1); // class-less subject falls back to scope class
|
|
36
|
+
expect(plan.moved.components).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('keeps shared + dead rules global', () => {
|
|
40
|
+
expect(plan.global.sharedRules).toBe(1); // .container (both TeamCard + CaseCard)
|
|
41
|
+
expect(plan.global.deadRules).toBe(1); // .orphan (no component)
|
|
42
|
+
expect(plan.warnings.sharedSelectors).toContain('.container');
|
|
43
|
+
expect(plan.warnings.deadSelectors).toContain('.orphan');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('assembles de-minified slice text with base rules + @media groups', () => {
|
|
47
|
+
const css = slice('TeamCard')?.css ?? '';
|
|
48
|
+
expect(css).toContain('.team-card {');
|
|
49
|
+
expect(css).toContain('border: 1px solid #ccc;');
|
|
50
|
+
expect(css).toContain('.team-card .title {');
|
|
51
|
+
expect(css).toContain('@media screen and (max-width:767px) {');
|
|
52
|
+
expect(slice('TeamCard')?.breakpoints).toEqual(['', 'screen and (max-width:767px)']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('markdown renderer summarizes the plan', () => {
|
|
56
|
+
const md = renderDeMirrorMarkdown(plan);
|
|
57
|
+
expect(md).toContain('# De-mirror plan');
|
|
58
|
+
expect(md).toContain('round-trip ✅ lossless');
|
|
59
|
+
expect(md).toContain('TeamCard');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* De-mirror planner — move each component's CSS slice off the global Webflow monolith onto the
|
|
3
|
+
* component that owns it. This is the step AFTER componentization: the pages are already carved into
|
|
4
|
+
* components, but every component still leans on the one big `_mirror` stylesheet. De-mirror
|
|
5
|
+
* **relocates** the rules (it does NOT convert them to utilities — class names are preserved), so a
|
|
6
|
+
* component ends up owning the exact CSS its DOM uses.
|
|
7
|
+
*
|
|
8
|
+
* Ownership is a component-usage graph: a class rule whose subject classes live in **exactly one**
|
|
9
|
+
* component moves onto that component; a rule used by two or more components (or framework classes
|
|
10
|
+
* like `.w-*` / auto-wrappers shared everywhere) stays in the reduced global sheet. Every rule lands
|
|
11
|
+
* in exactly one place — that's the round-trip invariant this planner verifies.
|
|
12
|
+
*
|
|
13
|
+
* Pure (css-tree only) and read-only: it computes the plan + the de-minified slice text + the reduced
|
|
14
|
+
* global, and reports whether the split is lossless. Writing the files is a separate apply step.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as csstree from 'css-tree';
|
|
18
|
+
|
|
19
|
+
/** A component and the class tokens its DOM uses (from `collectClassesFromPageData`). */
|
|
20
|
+
export interface ComponentClassUsage {
|
|
21
|
+
name: string;
|
|
22
|
+
classes: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DeMirrorOptions {
|
|
26
|
+
/** Max rules to include verbatim in each component's slice text. Default: unlimited (0). */
|
|
27
|
+
sliceRuleLimit?: number;
|
|
28
|
+
/** Max sample selectors to report for the shared/dead warning lists. Default 40. */
|
|
29
|
+
warningSampleLimit?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ComponentSlice {
|
|
33
|
+
name: string;
|
|
34
|
+
/** Class rules that move onto this component. */
|
|
35
|
+
ruleCount: number;
|
|
36
|
+
declarations: number;
|
|
37
|
+
approxBytes: number;
|
|
38
|
+
/** Distinct `@media` conditions the slice spans. */
|
|
39
|
+
breakpoints: string[];
|
|
40
|
+
/** The de-minified CSS to co-locate on the component (base rules first, then each @media group). */
|
|
41
|
+
css: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DeMirrorPlan {
|
|
45
|
+
source: {
|
|
46
|
+
/** Total class rules in the sheet (element-only/base and at-rule bodies are not counted). */
|
|
47
|
+
classRules: number;
|
|
48
|
+
classRuleBytes: number;
|
|
49
|
+
componentCount: number;
|
|
50
|
+
};
|
|
51
|
+
/** Components that receive a slice, largest first. */
|
|
52
|
+
components: ComponentSlice[];
|
|
53
|
+
/** What stays in the global sheet after de-mirror. */
|
|
54
|
+
global: {
|
|
55
|
+
/** Class rules kept global because ≥2 components use them (shared framework/design-system). */
|
|
56
|
+
sharedRules: number;
|
|
57
|
+
/** Class rules kept global because NO component uses them (dead in the current pages). */
|
|
58
|
+
deadRules: number;
|
|
59
|
+
approxBytes: number;
|
|
60
|
+
};
|
|
61
|
+
moved: {
|
|
62
|
+
/** Rules relocated onto a component. */
|
|
63
|
+
rules: number;
|
|
64
|
+
approxBytes: number;
|
|
65
|
+
/** How many distinct components received a slice. */
|
|
66
|
+
components: number;
|
|
67
|
+
};
|
|
68
|
+
warnings: {
|
|
69
|
+
/** Sample selectors kept global because two+ components share them (can't co-locate). */
|
|
70
|
+
sharedSelectors: string[];
|
|
71
|
+
/** Sample selectors defined but matched by no component (dead). */
|
|
72
|
+
deadSelectors: string[];
|
|
73
|
+
};
|
|
74
|
+
/** True when every class rule was assigned to exactly one place (component slice OR global). */
|
|
75
|
+
roundTrip: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CapturedRule {
|
|
79
|
+
/** Full selector list text (comma-joined), as authored. */
|
|
80
|
+
selector: string;
|
|
81
|
+
/** `@media` prelude, or null for a base rule. */
|
|
82
|
+
media: string | null;
|
|
83
|
+
/** Class tokens appearing in the subject (rightmost) compound of any selector. */
|
|
84
|
+
subjectClasses: string[];
|
|
85
|
+
/** All class tokens anywhere in the selector list. */
|
|
86
|
+
allClasses: string[];
|
|
87
|
+
declarations: number;
|
|
88
|
+
bytes: number;
|
|
89
|
+
/** De-minified `selector {\n prop: value;\n}` text. */
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const CLASS_TOKEN_RE = /\.(-?[_a-zA-Z][\w-]*)/g;
|
|
94
|
+
const FUNCTIONAL_PSEUDO_RE = /:(not|is|where|has|matches)\([^()]*\)/gi;
|
|
95
|
+
|
|
96
|
+
function classTokens(selector: string): string[] {
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
let m: RegExpExecArray | null;
|
|
99
|
+
CLASS_TOKEN_RE.lastIndex = 0;
|
|
100
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
101
|
+
while ((m = CLASS_TOKEN_RE.exec(selector)) !== null) {
|
|
102
|
+
if (m[1]) out.push(m[1]);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Subject (rightmost compound) class tokens of a single selector. */
|
|
108
|
+
function subjectClassesOf(selector: string): string[] {
|
|
109
|
+
const cleaned = selector.replace(FUNCTIONAL_PSEUDO_RE, ' ');
|
|
110
|
+
const segments = cleaned.split(/\s*[>+~]\s*|\s+/).filter((s) => s.trim().length > 0);
|
|
111
|
+
const subject = segments[segments.length - 1] ?? cleaned;
|
|
112
|
+
return classTokens(subject);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** De-minified declaration block text for a rule, one declaration per indented line. */
|
|
116
|
+
function declBlock(block: csstree.CssNode | null | undefined): { text: string; count: number } {
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
if (block && block.type === 'Block') {
|
|
119
|
+
block.children.forEach((child) => {
|
|
120
|
+
if (child.type !== 'Declaration') return;
|
|
121
|
+
// `generate` minifies to `prop:value`; re-space after the property colon (the first one —
|
|
122
|
+
// values may contain their own colons, e.g. `url(https:…)`).
|
|
123
|
+
const g = csstree.generate(child);
|
|
124
|
+
const i = g.indexOf(':');
|
|
125
|
+
lines.push(` ${i > 0 ? `${g.slice(0, i)}: ${g.slice(i + 1)}` : g};`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return { text: lines.join('\n'), count: lines.length };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse a stylesheet and capture every CLASS-bearing rule (threading @media context). */
|
|
132
|
+
function captureClassRules(css: string): CapturedRule[] {
|
|
133
|
+
const ast = csstree.parse(css, { parseValue: false, parseCustomProperty: false });
|
|
134
|
+
const out: CapturedRule[] = [];
|
|
135
|
+
|
|
136
|
+
const visit = (container: csstree.CssNode, media: string | null, skip: boolean): void => {
|
|
137
|
+
const children =
|
|
138
|
+
'children' in container ? (container as { children: csstree.List<csstree.CssNode> }).children : null;
|
|
139
|
+
if (!children) return;
|
|
140
|
+
|
|
141
|
+
children.forEach((node) => {
|
|
142
|
+
if (node.type === 'Atrule') {
|
|
143
|
+
const name = node.name.toLowerCase();
|
|
144
|
+
if (name === 'media') {
|
|
145
|
+
const prelude = node.prelude ? csstree.generate(node.prelude) : '';
|
|
146
|
+
if (node.block) visit(node.block, prelude, false);
|
|
147
|
+
} else if (name === 'supports') {
|
|
148
|
+
if (node.block) visit(node.block, media, false);
|
|
149
|
+
} else if (node.block) {
|
|
150
|
+
visit(node.block, media, true); // @keyframes/@font-face — skip inner rules
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (node.type !== 'Rule' || skip) return;
|
|
155
|
+
if (node.prelude?.type !== 'SelectorList') return;
|
|
156
|
+
|
|
157
|
+
const selectors: string[] = [];
|
|
158
|
+
const subject = new Set<string>();
|
|
159
|
+
const all = new Set<string>();
|
|
160
|
+
node.prelude.children.forEach((sel) => {
|
|
161
|
+
const text = csstree.generate(sel);
|
|
162
|
+
const cls = classTokens(text);
|
|
163
|
+
if (cls.length === 0) return; // element-only selector — skip (belongs to base/global)
|
|
164
|
+
selectors.push(text);
|
|
165
|
+
for (const c of cls) all.add(c);
|
|
166
|
+
for (const c of subjectClassesOf(text)) subject.add(c);
|
|
167
|
+
});
|
|
168
|
+
if (selectors.length === 0) return;
|
|
169
|
+
|
|
170
|
+
const { text: decls, count } = declBlock(node.block);
|
|
171
|
+
out.push({
|
|
172
|
+
selector: selectors.join(', '),
|
|
173
|
+
media,
|
|
174
|
+
subjectClasses: [...subject],
|
|
175
|
+
allClasses: [...all],
|
|
176
|
+
declarations: count,
|
|
177
|
+
bytes: csstree.generate(node).length,
|
|
178
|
+
text: `${selectors.join(',\n')} {\n${decls}\n}`,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
visit(ast, null, false);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Assemble a component's moved rules into de-minified CSS: base rules first, then each @media group. */
|
|
188
|
+
function assembleSlice(rules: CapturedRule[]): string {
|
|
189
|
+
const base = rules.filter((r) => r.media === null);
|
|
190
|
+
const byMedia = new Map<string, CapturedRule[]>();
|
|
191
|
+
for (const r of rules) {
|
|
192
|
+
if (r.media === null) continue;
|
|
193
|
+
const list = byMedia.get(r.media) ?? [];
|
|
194
|
+
list.push(r);
|
|
195
|
+
byMedia.set(r.media, list);
|
|
196
|
+
}
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
for (const r of base) parts.push(r.text);
|
|
199
|
+
for (const [media, list] of byMedia) {
|
|
200
|
+
const inner = list.map((r) => r.text.replace(/^/gm, ' ')).join('\n');
|
|
201
|
+
parts.push(`@media ${media} {\n${inner}\n}`);
|
|
202
|
+
}
|
|
203
|
+
return parts.join('\n\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Plan the de-mirror: assign each class rule to the single component that owns it, or keep it global.
|
|
208
|
+
*/
|
|
209
|
+
export function planDeMirror(input: {
|
|
210
|
+
css: string;
|
|
211
|
+
components: ComponentClassUsage[];
|
|
212
|
+
options?: DeMirrorOptions;
|
|
213
|
+
}): DeMirrorPlan {
|
|
214
|
+
const { css, components, options = {} } = input;
|
|
215
|
+
const warningSampleLimit = options.warningSampleLimit ?? 40;
|
|
216
|
+
|
|
217
|
+
const compClasses = components.map((c) => ({ name: c.name, set: new Set(c.classes) }));
|
|
218
|
+
const rules = captureClassRules(css);
|
|
219
|
+
|
|
220
|
+
// Assign each rule: owners = components whose class-set covers the rule's subject (or, if the
|
|
221
|
+
// subject is class-less, its full) class set for at least one of its selectors.
|
|
222
|
+
const perComponent = new Map<string, CapturedRule[]>();
|
|
223
|
+
const sharedSelectors: string[] = [];
|
|
224
|
+
const deadSelectors: string[] = [];
|
|
225
|
+
let movedRules = 0;
|
|
226
|
+
let movedBytes = 0;
|
|
227
|
+
let sharedRules = 0;
|
|
228
|
+
let deadRules = 0;
|
|
229
|
+
let sharedBytes = 0;
|
|
230
|
+
let deadBytes = 0;
|
|
231
|
+
let totalBytes = 0;
|
|
232
|
+
|
|
233
|
+
for (const rule of rules) {
|
|
234
|
+
totalBytes += rule.bytes;
|
|
235
|
+
const need = rule.subjectClasses.length > 0 ? rule.subjectClasses : rule.allClasses;
|
|
236
|
+
const owners: string[] = [];
|
|
237
|
+
for (const c of compClasses) {
|
|
238
|
+
if (need.every((cls) => c.set.has(cls))) owners.push(c.name);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (owners.length === 1) {
|
|
242
|
+
const name = owners[0] as string;
|
|
243
|
+
const list = perComponent.get(name) ?? [];
|
|
244
|
+
list.push(rule);
|
|
245
|
+
perComponent.set(name, list);
|
|
246
|
+
movedRules += 1;
|
|
247
|
+
movedBytes += rule.bytes;
|
|
248
|
+
} else if (owners.length === 0) {
|
|
249
|
+
deadRules += 1;
|
|
250
|
+
deadBytes += rule.bytes;
|
|
251
|
+
if (deadSelectors.length < warningSampleLimit) deadSelectors.push(rule.selector);
|
|
252
|
+
} else {
|
|
253
|
+
sharedRules += 1;
|
|
254
|
+
sharedBytes += rule.bytes;
|
|
255
|
+
if (sharedSelectors.length < warningSampleLimit) sharedSelectors.push(rule.selector);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const componentsOut: ComponentSlice[] = [];
|
|
260
|
+
for (const [name, list] of perComponent) {
|
|
261
|
+
const breakpoints = [...new Set(list.map((r) => r.media ?? ''))].sort();
|
|
262
|
+
componentsOut.push({
|
|
263
|
+
name,
|
|
264
|
+
ruleCount: list.length,
|
|
265
|
+
declarations: list.reduce((n, r) => n + r.declarations, 0),
|
|
266
|
+
approxBytes: list.reduce((n, r) => n + r.bytes, 0),
|
|
267
|
+
breakpoints,
|
|
268
|
+
css: assembleSlice(list),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
componentsOut.sort((a, b) => b.approxBytes - a.approxBytes || a.name.localeCompare(b.name));
|
|
272
|
+
|
|
273
|
+
// Round-trip: every captured rule landed in exactly one bucket.
|
|
274
|
+
const roundTrip = movedRules + sharedRules + deadRules === rules.length;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
source: { classRules: rules.length, classRuleBytes: totalBytes, componentCount: components.length },
|
|
278
|
+
components: componentsOut,
|
|
279
|
+
global: { sharedRules, deadRules, approxBytes: sharedBytes + deadBytes },
|
|
280
|
+
moved: { rules: movedRules, approxBytes: movedBytes, components: componentsOut.length },
|
|
281
|
+
warnings: { sharedSelectors, deadSelectors },
|
|
282
|
+
roundTrip,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Render a compact human-readable summary of a de-mirror plan (for `?format=md`). */
|
|
287
|
+
export function renderDeMirrorMarkdown(plan: DeMirrorPlan): string {
|
|
288
|
+
const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;
|
|
289
|
+
const lines: string[] = [];
|
|
290
|
+
lines.push('# De-mirror plan');
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(
|
|
293
|
+
`${plan.source.classRules} class rules (${kb(plan.source.classRuleBytes)}) across ${plan.source.componentCount} components · ` +
|
|
294
|
+
`round-trip ${plan.roundTrip ? '✅ lossless' : '❌ FAILED'}`,
|
|
295
|
+
);
|
|
296
|
+
lines.push('');
|
|
297
|
+
lines.push(
|
|
298
|
+
`**Moves onto components:** ${plan.moved.rules} rules → ${plan.moved.components} components (${kb(plan.moved.approxBytes)})`,
|
|
299
|
+
);
|
|
300
|
+
lines.push(
|
|
301
|
+
`**Stays global:** ${plan.global.sharedRules} shared + ${plan.global.deadRules} dead = ${kb(plan.global.approxBytes)}`,
|
|
302
|
+
);
|
|
303
|
+
lines.push('');
|
|
304
|
+
lines.push('## Slices (largest first)');
|
|
305
|
+
lines.push('');
|
|
306
|
+
lines.push('| component | rules | declarations | ~size | breakpoints |');
|
|
307
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
308
|
+
for (const c of plan.components.slice(0, 40)) {
|
|
309
|
+
lines.push(`| ${c.name} | ${c.ruleCount} | ${c.declarations} | ${kb(c.approxBytes)} | ${c.breakpoints.length} |`);
|
|
310
|
+
}
|
|
311
|
+
if (plan.components.length > 40) lines.push(`| … +${plan.components.length - 40} more | | | | |`);
|
|
312
|
+
lines.push('');
|
|
313
|
+
lines.push(
|
|
314
|
+
`_Shared (kept global): ${plan.warnings.sharedSelectors.slice(0, 6).join(', ') || 'none'}${plan.warnings.sharedSelectors.length > 6 ? ' …' : ''}_`,
|
|
315
|
+
);
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
|
@@ -218,6 +218,29 @@ describe('FileWatcher astro CMS template pages', () => {
|
|
|
218
218
|
expect(pageChanges.length).toBeGreaterThanOrEqual(1);
|
|
219
219
|
expect(cmsTemplateChanges).toBe(0);
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
// A folder-index page (`blog/index.astro`) routes at `/blog`, so its watcher event must
|
|
223
|
+
// collapse to `/blog` — NOT `/blog/index`, which would re-cache a ghost picker entry.
|
|
224
|
+
test('collapses a folder-index page change to its route (/blog, not /blog/index)', async () => {
|
|
225
|
+
const pageChanges: string[] = [];
|
|
226
|
+
const watcher = new FileWatcher({
|
|
227
|
+
onPageChange: async (p) => {
|
|
228
|
+
pageChanges.push(p);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
watcher.watchAstroPages(pagesDir);
|
|
233
|
+
await settle(50);
|
|
234
|
+
|
|
235
|
+
mkdirSync(join(pagesDir, 'blog'), { recursive: true });
|
|
236
|
+
await settle(100);
|
|
237
|
+
writeFileSync(join(pagesDir, 'blog', 'index.astro'), '---\nconst meta = {};\n---\n');
|
|
238
|
+
await waitFor(() => pageChanges.length >= 1);
|
|
239
|
+
|
|
240
|
+
watcher.stopAll();
|
|
241
|
+
expect(pageChanges).toContain('/blog');
|
|
242
|
+
expect(pageChanges).not.toContain('/blog/index');
|
|
243
|
+
});
|
|
221
244
|
});
|
|
222
245
|
|
|
223
246
|
describe('FileWatcher project.config.json', () => {
|
|
@@ -7,6 +7,7 @@ import { watch, existsSync, statSync, readdirSync } from 'node:fs';
|
|
|
7
7
|
import { basename, dirname, join, relative } from 'node:path';
|
|
8
8
|
import type { FSWatcher } from 'node:fs';
|
|
9
9
|
import { mapPageNameToPath } from './jsonLoader';
|
|
10
|
+
import { collapseFolderIndex } from '../shared/utils/fileUtils';
|
|
10
11
|
import { projectPaths, getProjectRoot } from './projectContext';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -371,7 +372,11 @@ export class FileWatcher {
|
|
|
371
372
|
if (this.callbacks.onCMSTemplateChange) await this.callbacks.onCMSTemplateChange();
|
|
372
373
|
return;
|
|
373
374
|
}
|
|
374
|
-
|
|
375
|
+
// Collapse a folder-index file (`blog/index.astro`) to its route `/blog`, matching
|
|
376
|
+
// AstroPageProvider — otherwise a save re-caches the page under `/blog/index`,
|
|
377
|
+
// resurrecting the ghost picker entry the provider maps to `/blog`.
|
|
378
|
+
const pageName = collapseFolderIndex(filename.replace(/\.astro$/, ''));
|
|
379
|
+
const pagePath = mapPageNameToPath(pageName);
|
|
375
380
|
if (this.callbacks.onPageChange) await this.callbacks.onPageChange(pagePath);
|
|
376
381
|
}),
|
|
377
382
|
(w) => {
|
package/lib/server/index.ts
CHANGED
|
@@ -28,6 +28,20 @@ export { ColorService, colorService } from './services/ColorService';
|
|
|
28
28
|
export { VariableService, variableService } from './services/VariableService';
|
|
29
29
|
export { EnumService, enumService } from './services/EnumService';
|
|
30
30
|
|
|
31
|
+
// CSS audit + de-mirror rule extraction (monolithic-stylesheet → per-component migration).
|
|
32
|
+
export {
|
|
33
|
+
auditStylesheet,
|
|
34
|
+
extractRules,
|
|
35
|
+
collectClassesFromPageData,
|
|
36
|
+
sectionPrefix,
|
|
37
|
+
renderAuditMarkdown,
|
|
38
|
+
type CssAuditReport,
|
|
39
|
+
type CssAuditOptions,
|
|
40
|
+
type ClassInfo,
|
|
41
|
+
type PageClassUsage,
|
|
42
|
+
type ExtractedRule,
|
|
43
|
+
} from './cssAudit';
|
|
44
|
+
|
|
31
45
|
// Providers
|
|
32
46
|
// FileSystemPageProvider was retired with the bespoke JSON renderer (the editor is
|
|
33
47
|
// meno-astro-only; AstroPageProvider is the live page provider). FileSystemCMSProvider
|
|
@@ -119,3 +133,8 @@ export {
|
|
|
119
133
|
|
|
120
134
|
// Runtime abstraction
|
|
121
135
|
export * from './runtime';
|
|
136
|
+
|
|
137
|
+
// Webflow Designer Extension template wrapper (injects the `webflow` global).
|
|
138
|
+
// Sole survivor of the retired core/lib/server/webflow/ tree; reused by the
|
|
139
|
+
// Webflow→Meno import extension's serve.ts + routes.
|
|
140
|
+
export { wrapInWebflowTemplate } from './webflow/templateWrapper';
|
|
@@ -19,6 +19,8 @@ import * as colorsRoutes from './colors';
|
|
|
19
19
|
import * as variablesRoutes from './variables';
|
|
20
20
|
import * as enumsRoutes from './enums';
|
|
21
21
|
import * as cmsRoutes from './cms';
|
|
22
|
+
import { handleCssAuditRoute } from './cssAudit';
|
|
23
|
+
import { handleDeMirrorRoute } from './deMirror';
|
|
22
24
|
import { handleFunctionsRoute, handleFunctionsPreflight } from './functions';
|
|
23
25
|
|
|
24
26
|
/** Context for core API routes */
|
|
@@ -152,6 +154,22 @@ export async function handleCoreApiRoutes(
|
|
|
152
154
|
return await handleRouteError(() => enumsRoutes.handleEnumsRoute(), 'Failed to fetch enums');
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// CSS audit API route (read-only) — ownership map for a monolithic mirror stylesheet
|
|
158
|
+
if (url.pathname === '/api/css-audit' && req.method === 'GET') {
|
|
159
|
+
return await handleRouteError(
|
|
160
|
+
() => Promise.resolve(handleCssAuditRoute(url, pageService)),
|
|
161
|
+
'Failed to generate CSS audit',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// De-mirror API route (read-only dry-run) — plan moving each component's CSS slice onto it
|
|
166
|
+
if (url.pathname === '/api/de-mirror' && req.method === 'GET') {
|
|
167
|
+
return await handleRouteError(
|
|
168
|
+
() => Promise.resolve(handleDeMirrorRoute(url, pageService, componentService)),
|
|
169
|
+
'Failed to generate de-mirror plan',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
155
173
|
// Usage API route (for license limit checks)
|
|
156
174
|
if (url.pathname === '/api/usage' && req.method === 'GET') {
|
|
157
175
|
return await handleRouteError(() => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { setProjectRoot, getProjectRoot } from '../../projectContext';
|
|
5
|
+
import type { PageService } from '../../services/pageService';
|
|
6
|
+
import { handleCssAuditRoute } from './cssAudit';
|
|
7
|
+
|
|
8
|
+
const TEST_DIR = join('/tmp', `cssAuditRoute-test-${process.pid}`);
|
|
9
|
+
|
|
10
|
+
const MIRROR_CSS = `
|
|
11
|
+
body { margin: 0; }
|
|
12
|
+
.w-button { display: inline-block; }
|
|
13
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
14
|
+
.team-card { border: 1px solid #ccc; }
|
|
15
|
+
.team-grid { display: grid; }
|
|
16
|
+
.team-avatar { border-radius: 50%; }
|
|
17
|
+
.case-card { padding: 8px; }
|
|
18
|
+
@media screen and (max-width: 767px) { .team-card { padding: 4px; } }
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
/** Minimal PageService stub exposing only the two methods the route calls. */
|
|
22
|
+
function fakePageService(pages: Record<string, unknown>): PageService {
|
|
23
|
+
return {
|
|
24
|
+
getAllPagePaths: () => Object.keys(pages),
|
|
25
|
+
getPageData: (path: string) => (pages[path] as never) ?? null,
|
|
26
|
+
} as unknown as PageService;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PAGES = {
|
|
30
|
+
'/a': { root: { tag: 'div', attributes: { class: 'container team-card team-grid' } } },
|
|
31
|
+
'/b': { root: { tag: 'div', attributes: { class: 'container case-card team-avatar' } } },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('CSS Audit API Route', () => {
|
|
35
|
+
let originalRoot: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
originalRoot = getProjectRoot();
|
|
39
|
+
mkdirSync(join(TEST_DIR, 'public', '_mirror', 'css'), { recursive: true });
|
|
40
|
+
writeFileSync(join(TEST_DIR, 'public', '_mirror', 'css', 'site.webflow.abc.css'), MIRROR_CSS);
|
|
41
|
+
setProjectRoot(TEST_DIR);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
setProjectRoot(originalRoot);
|
|
46
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('auto-detects the mirror stylesheet and returns a report', async () => {
|
|
50
|
+
const res = handleCssAuditRoute(new URL('http://x/api/css-audit'), fakePageService(PAGES));
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
const body = (await res.json()) as Record<string, any>;
|
|
53
|
+
|
|
54
|
+
expect(body.cssFiles).toEqual(['public/_mirror/css/site.webflow.abc.css']);
|
|
55
|
+
expect(body.source.pageCount).toBe(2);
|
|
56
|
+
expect(body.breakpoints).toContain('screen and (max-width:767px)');
|
|
57
|
+
|
|
58
|
+
// .w-button → vendor, .container (both pages) → shared, .team-card → section.
|
|
59
|
+
const team = body.classes.find((c: any) => c.name === 'team-card');
|
|
60
|
+
expect(team.proposedBucket).toBe('team');
|
|
61
|
+
expect(team.breakpoints).toContain('screen and (max-width:767px)');
|
|
62
|
+
expect(body.buckets.vendor.classes).toBeGreaterThanOrEqual(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('format=md returns markdown', async () => {
|
|
66
|
+
const res = handleCssAuditRoute(new URL('http://x/api/css-audit?format=md'), fakePageService(PAGES));
|
|
67
|
+
expect(res.headers.get('Content-Type')).toContain('text/markdown');
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
expect(text).toContain('# CSS audit');
|
|
70
|
+
expect(text).toContain('Proposed buckets');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('css= override audits a specific file', async () => {
|
|
74
|
+
const res = handleCssAuditRoute(
|
|
75
|
+
new URL('http://x/api/css-audit?css=public/_mirror/css/site.webflow.abc.css'),
|
|
76
|
+
fakePageService(PAGES),
|
|
77
|
+
);
|
|
78
|
+
expect(res.status).toBe(200);
|
|
79
|
+
const body = (await res.json()) as Record<string, any>;
|
|
80
|
+
expect(body.cssFiles).toEqual(['public/_mirror/css/site.webflow.abc.css']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('css= outside the project root is rejected', async () => {
|
|
84
|
+
const res = handleCssAuditRoute(new URL('http://x/api/css-audit?css=../../../etc/hosts'), fakePageService(PAGES));
|
|
85
|
+
expect(res.status).toBe(404);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('404 when no mirror stylesheet exists', async () => {
|
|
89
|
+
const emptyDir = join('/tmp', `cssAuditRoute-empty-${process.pid}`);
|
|
90
|
+
mkdirSync(emptyDir, { recursive: true });
|
|
91
|
+
setProjectRoot(emptyDir);
|
|
92
|
+
try {
|
|
93
|
+
const res = handleCssAuditRoute(new URL('http://x/api/css-audit'), fakePageService(PAGES));
|
|
94
|
+
expect(res.status).toBe(404);
|
|
95
|
+
const body = (await res.json()) as Record<string, any>;
|
|
96
|
+
expect(body.error).toContain('No mirror stylesheet');
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(emptyDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|