meno-core 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/chunks/{chunk-3JXK2QFU.js → chunk-H6EOTPJA.js} +2 -2
  2. package/dist/chunks/{chunk-LOZL5HOF.js → chunk-RLBV2R6J.js} +603 -17
  3. package/dist/chunks/chunk-RLBV2R6J.js.map +7 -0
  4. package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
  5. package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
  6. package/dist/lib/client/index.js +46 -28
  7. package/dist/lib/client/index.js.map +2 -2
  8. package/dist/lib/server/index.js +1304 -241
  9. package/dist/lib/server/index.js.map +4 -4
  10. package/dist/lib/shared/index.js +4 -2
  11. package/dist/lib/shared/index.js.map +1 -1
  12. package/lib/client/core/ComponentBuilder.test.ts +32 -0
  13. package/lib/client/core/ComponentBuilder.ts +30 -0
  14. package/lib/client/theme.test.ts +2 -2
  15. package/lib/client/theme.ts +28 -26
  16. package/lib/server/createServer.ts +5 -1
  17. package/lib/server/cssAudit.test.ts +270 -0
  18. package/lib/server/cssAudit.ts +815 -0
  19. package/lib/server/deMirror.test.ts +61 -0
  20. package/lib/server/deMirror.ts +317 -0
  21. package/lib/server/fileWatcher.test.ts +113 -0
  22. package/lib/server/fileWatcher.ts +42 -1
  23. package/lib/server/index.ts +19 -0
  24. package/lib/server/routes/api/core-routes.ts +18 -0
  25. package/lib/server/routes/api/cssAudit.test.ts +101 -0
  26. package/lib/server/routes/api/cssAudit.ts +138 -0
  27. package/lib/server/routes/api/deMirror.test.ts +105 -0
  28. package/lib/server/routes/api/deMirror.ts +127 -0
  29. package/lib/server/routes/api/pages.ts +2 -2
  30. package/lib/server/services/componentService.test.ts +43 -0
  31. package/lib/server/services/componentService.ts +49 -12
  32. package/lib/server/services/configService.ts +0 -5
  33. package/lib/server/services/pageService.test.ts +15 -0
  34. package/lib/server/services/pageService.ts +44 -22
  35. package/lib/server/ssr/htmlGenerator.test.ts +29 -1
  36. package/lib/server/ssr/htmlGenerator.ts +86 -14
  37. package/lib/server/themeCssCodec.test.ts +67 -0
  38. package/lib/server/themeCssCodec.ts +67 -5
  39. package/lib/server/webflow/templateWrapper.ts +54 -0
  40. package/lib/shared/cssGeneration.test.ts +64 -0
  41. package/lib/shared/cssGeneration.ts +6 -1
  42. package/lib/shared/interfaces/contentProvider.ts +9 -0
  43. package/lib/shared/tailwindThemeScale.ts +1 -0
  44. package/lib/shared/types/cms.ts +1 -0
  45. package/lib/shared/types/comment.ts +9 -3
  46. package/lib/shared/utilityClassConfig.ts +471 -10
  47. package/lib/shared/utilityClassMapper.test.ts +173 -2
  48. package/lib/shared/utilityClassMapper.ts +37 -0
  49. package/lib/shared/utilityClassNames.ts +164 -0
  50. package/lib/shared/utils/fileUtils.ts +11 -0
  51. package/lib/shared/validation/schemas.ts +1 -0
  52. package/lib/shared/viewportUnits.test.ts +34 -1
  53. package/lib/shared/viewportUnits.ts +43 -0
  54. package/package.json +3 -1
  55. package/vite.config.ts +4 -1
  56. package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
  57. /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-H6EOTPJA.js.map} +0 -0
@@ -0,0 +1,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
- const pagePath = mapPageNameToPath(filename.replace(/\.astro$/, ''));
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;
@@ -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(() => {