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,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,119 @@ 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
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('FileWatcher theme.css', () => {
|
|
247
|
+
let projectRoot: string;
|
|
248
|
+
let stylesDir: string;
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
projectRoot = join(tmpdir(), `meno-fw-theme-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
252
|
+
stylesDir = join(projectRoot, 'src', 'styles');
|
|
253
|
+
mkdirSync(join(projectRoot, 'src'), { recursive: true });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
try {
|
|
258
|
+
rmSync(projectRoot, { recursive: true, force: true });
|
|
259
|
+
} catch {
|
|
260
|
+
/* ignore */
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// theme.css holds BOTH token halves (colors + variables), so one edit must
|
|
265
|
+
// invalidate both service caches — an external write (e.g. an AI tool adding
|
|
266
|
+
// a token) previously stayed invisible until a dev-server restart.
|
|
267
|
+
test('fires onColorsChange AND onVariablesChange when theme.css is written', async () => {
|
|
268
|
+
let colorChanges = 0;
|
|
269
|
+
let variableChanges = 0;
|
|
270
|
+
const watcher = new FileWatcher({
|
|
271
|
+
onColorsChange: async () => {
|
|
272
|
+
colorChanges++;
|
|
273
|
+
},
|
|
274
|
+
onVariablesChange: async () => {
|
|
275
|
+
variableChanges++;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
mkdirSync(stylesDir, { recursive: true });
|
|
280
|
+
writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; }');
|
|
281
|
+
watcher.watchThemeCss(stylesDir);
|
|
282
|
+
await settle(50);
|
|
283
|
+
|
|
284
|
+
writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; --accent: #f00; }');
|
|
285
|
+
await waitFor(() => colorChanges >= 1 && variableChanges >= 1);
|
|
286
|
+
|
|
287
|
+
watcher.stopAll();
|
|
288
|
+
expect(colorChanges).toBeGreaterThanOrEqual(1);
|
|
289
|
+
expect(variableChanges).toBeGreaterThanOrEqual(1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// src/styles may not exist at startup (blank project — theme.css lands later
|
|
293
|
+
// via the JSON migration or an external tool), so the watcher must defer-attach.
|
|
294
|
+
test('fires after src/styles is created post-startup', async () => {
|
|
295
|
+
let colorChanges = 0;
|
|
296
|
+
const watcher = new FileWatcher({
|
|
297
|
+
onColorsChange: async () => {
|
|
298
|
+
colorChanges++;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
watcher.watchThemeCss(stylesDir);
|
|
303
|
+
await settle(50);
|
|
304
|
+
mkdirSync(stylesDir, { recursive: true });
|
|
305
|
+
await settle(100);
|
|
306
|
+
writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; }');
|
|
307
|
+
await waitFor(() => colorChanges >= 1);
|
|
308
|
+
|
|
309
|
+
watcher.stopAll();
|
|
310
|
+
expect(colorChanges).toBeGreaterThanOrEqual(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('ignores writes to other files in src/styles', async () => {
|
|
314
|
+
let fired = 0;
|
|
315
|
+
const watcher = new FileWatcher({
|
|
316
|
+
onColorsChange: async () => {
|
|
317
|
+
fired++;
|
|
318
|
+
},
|
|
319
|
+
onVariablesChange: async () => {
|
|
320
|
+
fired++;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
mkdirSync(stylesDir, { recursive: true });
|
|
325
|
+
watcher.watchThemeCss(stylesDir);
|
|
326
|
+
await settle(50);
|
|
327
|
+
|
|
328
|
+
writeFileSync(join(stylesDir, 'global.css'), 'body { margin: 0; }');
|
|
329
|
+
await settle(200);
|
|
330
|
+
|
|
331
|
+
watcher.stopAll();
|
|
332
|
+
expect(fired).toBe(0);
|
|
333
|
+
});
|
|
221
334
|
});
|
|
222
335
|
|
|
223
336
|
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
|
/**
|
|
@@ -151,6 +152,7 @@ export class FileWatcher {
|
|
|
151
152
|
private templatesWatcher: FSWatcher | null = null;
|
|
152
153
|
private colorsWatcher: FSWatcher | null = null;
|
|
153
154
|
private variablesWatcher: FSWatcher | null = null;
|
|
155
|
+
private themeCssWatcher: FSWatcher | null = null;
|
|
154
156
|
private enumsWatcher: FSWatcher | null = null;
|
|
155
157
|
private cmsWatcher: FSWatcher | null = null;
|
|
156
158
|
private imagesWatcher: FSWatcher | null = null;
|
|
@@ -260,6 +262,35 @@ export class FileWatcher {
|
|
|
260
262
|
});
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Start watching `src/styles/theme.css` — the token source of truth for
|
|
267
|
+
* astro-format projects, where colors AND variables share the one file
|
|
268
|
+
* (colors.json / variables.json were migrated away, so the two watchers
|
|
269
|
+
* above never fire there). An external edit (e.g. an AI tool adding tokens)
|
|
270
|
+
* must invalidate both service caches and refresh connected clients, or the
|
|
271
|
+
* new tokens only appear after a dev-server restart. Deferred attach:
|
|
272
|
+
* `src/styles` may not exist yet (theme.css lands later via migration or an
|
|
273
|
+
* external tool). No-op in legacy JSON projects (no `src/`).
|
|
274
|
+
*/
|
|
275
|
+
watchThemeCss(dirPath: string = join(getProjectRoot(), 'src', 'styles')): void {
|
|
276
|
+
attachWhenDirExists(
|
|
277
|
+
dirPath,
|
|
278
|
+
() =>
|
|
279
|
+
watch(dirPath, { recursive: false }, async (_event, filename) => {
|
|
280
|
+
if (filename !== 'theme.css') return;
|
|
281
|
+
if (this.callbacks.onColorsChange) {
|
|
282
|
+
await this.callbacks.onColorsChange();
|
|
283
|
+
}
|
|
284
|
+
if (this.callbacks.onVariablesChange) {
|
|
285
|
+
await this.callbacks.onVariablesChange();
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
(w) => {
|
|
289
|
+
this.themeCssWatcher = w;
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
263
294
|
/**
|
|
264
295
|
* Start watching enums.json file
|
|
265
296
|
*/
|
|
@@ -371,7 +402,11 @@ export class FileWatcher {
|
|
|
371
402
|
if (this.callbacks.onCMSTemplateChange) await this.callbacks.onCMSTemplateChange();
|
|
372
403
|
return;
|
|
373
404
|
}
|
|
374
|
-
|
|
405
|
+
// Collapse a folder-index file (`blog/index.astro`) to its route `/blog`, matching
|
|
406
|
+
// AstroPageProvider — otherwise a save re-caches the page under `/blog/index`,
|
|
407
|
+
// resurrecting the ghost picker entry the provider maps to `/blog`.
|
|
408
|
+
const pageName = collapseFolderIndex(filename.replace(/\.astro$/, ''));
|
|
409
|
+
const pagePath = mapPageNameToPath(pageName);
|
|
375
410
|
if (this.callbacks.onPageChange) await this.callbacks.onPageChange(pagePath);
|
|
376
411
|
}),
|
|
377
412
|
(w) => {
|
|
@@ -420,6 +455,7 @@ export class FileWatcher {
|
|
|
420
455
|
this.watchTemplates();
|
|
421
456
|
this.watchColors();
|
|
422
457
|
this.watchVariables();
|
|
458
|
+
this.watchThemeCss();
|
|
423
459
|
this.watchEnums();
|
|
424
460
|
this.watchCMS();
|
|
425
461
|
this.watchImages();
|
|
@@ -460,6 +496,11 @@ export class FileWatcher {
|
|
|
460
496
|
this.variablesWatcher = null;
|
|
461
497
|
}
|
|
462
498
|
|
|
499
|
+
if (this.themeCssWatcher) {
|
|
500
|
+
this.themeCssWatcher.close();
|
|
501
|
+
this.themeCssWatcher = null;
|
|
502
|
+
}
|
|
503
|
+
|
|
463
504
|
if (this.enumsWatcher) {
|
|
464
505
|
this.enumsWatcher.close();
|
|
465
506
|
this.enumsWatcher = null;
|
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(() => {
|