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,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
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Audit API Route (read-only)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/css-audit
|
|
5
|
+
* Parses the project's monolithic (Webflow-exported) stylesheet and returns an ownership map:
|
|
6
|
+
* which rules would split into base / vendor / shared / per-section buckets, plus the flags that
|
|
7
|
+
* make a naive split unsafe (cross-boundary selectors, fuzzy-middle classes, dead classes,
|
|
8
|
+
* page-only classes). Pure analysis — nothing is moved or rewritten.
|
|
9
|
+
*
|
|
10
|
+
* Query params:
|
|
11
|
+
* - css=<relative path> stylesheet to audit (default: auto-detected `_mirror/css/*.css`)
|
|
12
|
+
* - sharedMinPages=<n> override the shared/local page-usage threshold
|
|
13
|
+
* - format=md|markdown return a human-readable summary instead of JSON
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import type { PageService } from '../../services/pageService';
|
|
19
|
+
import { getProjectRoot } from '../../projectContext';
|
|
20
|
+
import { auditStylesheet, collectClassesFromPageData, renderAuditMarkdown, type PageClassUsage } from '../../cssAudit';
|
|
21
|
+
import { jsonResponse } from './shared';
|
|
22
|
+
|
|
23
|
+
/** Directories never worth descending into when hunting for the mirror stylesheet. */
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.meno', 'rendered-websites']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Locate Webflow mirror stylesheets under the project root. Webflow exports land them at
|
|
28
|
+
* `public/_mirror/css/*.css`, but the project layout varies (some nest under `src/`), so scan for
|
|
29
|
+
* any `_mirror/css/*.css` with a bounded-depth walk.
|
|
30
|
+
*/
|
|
31
|
+
export function findMirrorCss(root: string, maxDepth = 6): string[] {
|
|
32
|
+
const found: string[] = [];
|
|
33
|
+
const walk = (dir: string, depth: number): void => {
|
|
34
|
+
if (depth > maxDepth) return;
|
|
35
|
+
let entries: string[];
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(dir);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const full = path.join(dir, entry);
|
|
43
|
+
let isDir = false;
|
|
44
|
+
try {
|
|
45
|
+
isDir = statSync(full).isDirectory();
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (isDir) {
|
|
50
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
51
|
+
walk(full, depth + 1);
|
|
52
|
+
} else if (entry.endsWith('.css') && full.replace(/\\/g, '/').includes('/_mirror/css/')) {
|
|
53
|
+
found.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk(root, 0);
|
|
58
|
+
return found.sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve a caller-supplied stylesheet path, guarding against escaping the project root. */
|
|
62
|
+
export function resolveCssParam(root: string, rel: string): string | null {
|
|
63
|
+
const resolved = path.resolve(root, rel);
|
|
64
|
+
const normalizedRoot = path.resolve(root);
|
|
65
|
+
if (resolved !== normalizedRoot && !resolved.startsWith(normalizedRoot + path.sep)) return null;
|
|
66
|
+
return existsSync(resolved) ? resolved : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handle GET /api/css-audit
|
|
71
|
+
*/
|
|
72
|
+
export function handleCssAuditRoute(url: URL, pageService: PageService): Response {
|
|
73
|
+
const root = getProjectRoot();
|
|
74
|
+
|
|
75
|
+
// 1. Resolve the stylesheet(s) to audit.
|
|
76
|
+
const cssParam = url.searchParams.get('css');
|
|
77
|
+
let cssFiles: string[];
|
|
78
|
+
if (cssParam) {
|
|
79
|
+
const resolved = resolveCssParam(root, cssParam);
|
|
80
|
+
if (!resolved) {
|
|
81
|
+
return jsonResponse({ error: `Stylesheet not found or outside project: ${cssParam}` }, { status: 404 });
|
|
82
|
+
}
|
|
83
|
+
cssFiles = [resolved];
|
|
84
|
+
} else {
|
|
85
|
+
cssFiles = findMirrorCss(root);
|
|
86
|
+
if (cssFiles.length === 0) {
|
|
87
|
+
return jsonResponse(
|
|
88
|
+
{
|
|
89
|
+
error: 'No mirror stylesheet found',
|
|
90
|
+
hint: 'Expected a Webflow export at public/_mirror/css/*.css. Pass ?css=<relative path> to audit a specific stylesheet.',
|
|
91
|
+
},
|
|
92
|
+
{ status: 404 },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const css = cssFiles.map((f) => readFileSync(f, 'utf8')).join('\n');
|
|
98
|
+
|
|
99
|
+
// 2. Build per-page class usage from the parsed page node trees.
|
|
100
|
+
const pages: PageClassUsage[] = [];
|
|
101
|
+
for (const pagePath of pageService.getAllPagePaths()) {
|
|
102
|
+
const data = pageService.getPageData(pagePath);
|
|
103
|
+
if (!data) continue;
|
|
104
|
+
pages.push({ path: pagePath, classes: collectClassesFromPageData(data) });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. Options — numeric knobs from the query string.
|
|
108
|
+
const intParam = (name: string): number | undefined => {
|
|
109
|
+
const raw = url.searchParams.get(name);
|
|
110
|
+
if (!raw) return undefined;
|
|
111
|
+
const n = Number.parseInt(raw, 10);
|
|
112
|
+
return Number.isFinite(n) ? n : undefined;
|
|
113
|
+
};
|
|
114
|
+
const options: Record<string, number> = {};
|
|
115
|
+
const sharedMinPages = intParam('sharedMinPages');
|
|
116
|
+
const minSectionClasses = intParam('minSectionClasses');
|
|
117
|
+
if (sharedMinPages !== undefined) options.sharedMinPages = sharedMinPages;
|
|
118
|
+
if (minSectionClasses !== undefined) options.minSectionClasses = minSectionClasses;
|
|
119
|
+
|
|
120
|
+
const report = auditStylesheet({
|
|
121
|
+
css,
|
|
122
|
+
pages,
|
|
123
|
+
options: Object.keys(options).length > 0 ? options : undefined,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Annotate which files were audited (relative to root) without bloating the kernel's contract.
|
|
127
|
+
const cssFilesRel = cssFiles.map((f) => path.relative(root, f));
|
|
128
|
+
|
|
129
|
+
const format = url.searchParams.get('format');
|
|
130
|
+
if (format === 'md' || format === 'markdown') {
|
|
131
|
+
const md = `${renderAuditMarkdown(report)}\n\n_Audited: ${cssFilesRel.join(', ')}_\n`;
|
|
132
|
+
return new Response(md, {
|
|
133
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'no-store, max-age=0' },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return jsonResponse({ ...report, cssFiles: cssFilesRel });
|
|
138
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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 type { ComponentService } from '../../services/componentService';
|
|
7
|
+
import { handleDeMirrorRoute } from './deMirror';
|
|
8
|
+
|
|
9
|
+
const TEST_DIR = join('/tmp', `deMirrorRoute-test-${process.pid}`);
|
|
10
|
+
|
|
11
|
+
const MIRROR_CSS = `
|
|
12
|
+
.team-card { border: 1px solid #ccc; }
|
|
13
|
+
.case-card { padding: 8px; }
|
|
14
|
+
.container { max-width: 1200px; }
|
|
15
|
+
.orphan { color: red; }
|
|
16
|
+
@media screen and (max-width: 767px) { .team-card { padding: 4px; } }
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
function fakeComponentService(comps: Record<string, unknown>): ComponentService {
|
|
20
|
+
return { getAllComponents: () => comps } as unknown as ComponentService;
|
|
21
|
+
}
|
|
22
|
+
function fakePageService(pages: Record<string, unknown>): PageService {
|
|
23
|
+
return { getPageData: (p: string) => (pages[p] as never) ?? null } as unknown as PageService;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// TeamCard uses team-card + container; CaseCard uses case-card + container.
|
|
27
|
+
const COMPONENTS = {
|
|
28
|
+
TeamCard: { component: { structure: { tag: 'div', attributes: { class: 'team-card container' } } } },
|
|
29
|
+
CaseCard: { component: { structure: { tag: 'div', attributes: { class: 'case-card container' } } } },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('De-mirror API Route', () => {
|
|
33
|
+
let originalRoot: string;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
originalRoot = getProjectRoot();
|
|
36
|
+
mkdirSync(join(TEST_DIR, 'public', '_mirror', 'css'), { recursive: true });
|
|
37
|
+
writeFileSync(join(TEST_DIR, 'public', '_mirror', 'css', 'site.webflow.abc.css'), MIRROR_CSS);
|
|
38
|
+
setProjectRoot(TEST_DIR);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
setProjectRoot(originalRoot);
|
|
42
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('plans a lossless de-mirror and marks it dry-run', async () => {
|
|
46
|
+
const res = handleDeMirrorRoute(
|
|
47
|
+
new URL('http://x/api/de-mirror'),
|
|
48
|
+
fakePageService({}),
|
|
49
|
+
fakeComponentService(COMPONENTS),
|
|
50
|
+
);
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
const body = (await res.json()) as any;
|
|
53
|
+
expect(body.dryRun).toBe(true);
|
|
54
|
+
expect(body.roundTrip).toBe(true);
|
|
55
|
+
// team-card → TeamCard, case-card → CaseCard; container shared, orphan dead.
|
|
56
|
+
expect(body.moved.components).toBe(2);
|
|
57
|
+
expect(body.global.sharedRules).toBe(1); // .container
|
|
58
|
+
expect(body.global.deadRules).toBe(1); // .orphan
|
|
59
|
+
// slice text stripped by default.
|
|
60
|
+
expect(body.components[0].css).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('slices=true includes the de-minified CSS text', async () => {
|
|
64
|
+
const res = handleDeMirrorRoute(
|
|
65
|
+
new URL('http://x/api/de-mirror?slices=true'),
|
|
66
|
+
fakePageService({}),
|
|
67
|
+
fakeComponentService(COMPONENTS),
|
|
68
|
+
);
|
|
69
|
+
const body = (await res.json()) as any;
|
|
70
|
+
const team = body.components.find((c: any) => c.name === 'TeamCard');
|
|
71
|
+
expect(team.css).toContain('.team-card {');
|
|
72
|
+
expect(team.css).toContain('@media screen and (max-width:767px)');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('format=md returns markdown', async () => {
|
|
76
|
+
const res = handleDeMirrorRoute(
|
|
77
|
+
new URL('http://x/api/de-mirror?format=md'),
|
|
78
|
+
fakePageService({}),
|
|
79
|
+
fakeComponentService(COMPONENTS),
|
|
80
|
+
);
|
|
81
|
+
expect(res.headers.get('Content-Type')).toContain('text/markdown');
|
|
82
|
+
expect(await res.text()).toContain('# De-mirror plan');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('422 when the project has no components', async () => {
|
|
86
|
+
const res = handleDeMirrorRoute(new URL('http://x/api/de-mirror'), fakePageService({}), fakeComponentService({}));
|
|
87
|
+
expect(res.status).toBe(422);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('404 when no mirror stylesheet exists', async () => {
|
|
91
|
+
const empty = join('/tmp', `deMirrorRoute-empty-${process.pid}`);
|
|
92
|
+
mkdirSync(empty, { recursive: true });
|
|
93
|
+
setProjectRoot(empty);
|
|
94
|
+
try {
|
|
95
|
+
const res = handleDeMirrorRoute(
|
|
96
|
+
new URL('http://x/api/de-mirror'),
|
|
97
|
+
fakePageService({}),
|
|
98
|
+
fakeComponentService(COMPONENTS),
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(404);
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(empty, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* De-mirror API Route (read-only DRY-RUN)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/de-mirror
|
|
5
|
+
* Plans moving each component's CSS slice off the global mirror monolith onto the component that
|
|
6
|
+
* owns it (relocation — class names preserved, NOT a utility conversion). Read-only: it computes
|
|
7
|
+
* the plan, the de-minified slice text, and the round-trip verdict; it writes nothing. Applying
|
|
8
|
+
* the plan is a separate write step.
|
|
9
|
+
*
|
|
10
|
+
* Query params:
|
|
11
|
+
* - css=<relative path> stylesheet to de-mirror (default: auto-detected `_mirror/css/*.css`)
|
|
12
|
+
* - slug=<page> show only the slices for components used by this page (view filter;
|
|
13
|
+
* ownership is still computed project-wide)
|
|
14
|
+
* - slices=true include each component's full slice CSS text (omitted by default —
|
|
15
|
+
* the plan can be large)
|
|
16
|
+
* - format=md|markdown human-readable summary instead of JSON
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import type { PageService } from '../../services/pageService';
|
|
22
|
+
import type { ComponentService } from '../../services/componentService';
|
|
23
|
+
import { getProjectRoot } from '../../projectContext';
|
|
24
|
+
import { collectClassesFromPageData } from '../../cssAudit';
|
|
25
|
+
import { planDeMirror, renderDeMirrorMarkdown, type ComponentClassUsage } from '../../deMirror';
|
|
26
|
+
import { findMirrorCss, resolveCssParam } from './cssAudit';
|
|
27
|
+
import { jsonResponse } from './shared';
|
|
28
|
+
|
|
29
|
+
/** Component names referenced by a page (best-effort: names appearing in the page's node tree). */
|
|
30
|
+
function componentsUsedByPage(pageData: unknown, allNames: string[]): Set<string> {
|
|
31
|
+
const hay = JSON.stringify(pageData ?? '');
|
|
32
|
+
const used = new Set<string>();
|
|
33
|
+
for (const name of allNames) {
|
|
34
|
+
if (hay.includes(`"${name}"`)) used.add(name);
|
|
35
|
+
}
|
|
36
|
+
return used;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle GET /api/de-mirror
|
|
41
|
+
*/
|
|
42
|
+
export function handleDeMirrorRoute(url: URL, pageService: PageService, componentService: ComponentService): Response {
|
|
43
|
+
const root = getProjectRoot();
|
|
44
|
+
|
|
45
|
+
// 1. Resolve the stylesheet(s).
|
|
46
|
+
const cssParam = url.searchParams.get('css');
|
|
47
|
+
let cssFiles: string[];
|
|
48
|
+
if (cssParam) {
|
|
49
|
+
const resolved = resolveCssParam(root, cssParam);
|
|
50
|
+
if (!resolved) {
|
|
51
|
+
return jsonResponse({ error: `Stylesheet not found or outside project: ${cssParam}` }, { status: 404 });
|
|
52
|
+
}
|
|
53
|
+
cssFiles = [resolved];
|
|
54
|
+
} else {
|
|
55
|
+
cssFiles = findMirrorCss(root);
|
|
56
|
+
if (cssFiles.length === 0) {
|
|
57
|
+
return jsonResponse(
|
|
58
|
+
{
|
|
59
|
+
error: 'No mirror stylesheet found',
|
|
60
|
+
hint: 'Expected a Webflow export at public/_mirror/css/*.css. Pass ?css=<relative path> to target a specific stylesheet.',
|
|
61
|
+
},
|
|
62
|
+
{ status: 404 },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const css = cssFiles.map((f) => readFileSync(f, 'utf8')).join('\n');
|
|
67
|
+
|
|
68
|
+
// 2. Build each component's class usage from its node tree.
|
|
69
|
+
const all = componentService.getAllComponents();
|
|
70
|
+
const components: ComponentClassUsage[] = Object.entries(all).map(([name, def]) => ({
|
|
71
|
+
name,
|
|
72
|
+
classes: collectClassesFromPageData(def),
|
|
73
|
+
}));
|
|
74
|
+
if (components.length === 0) {
|
|
75
|
+
return jsonResponse(
|
|
76
|
+
{
|
|
77
|
+
error: 'No components found',
|
|
78
|
+
hint: 'Componentize the project first (e.g. /componentize-mirror), then de-mirror.',
|
|
79
|
+
},
|
|
80
|
+
{ status: 422 },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Plan project-wide (ownership needs the full component graph).
|
|
85
|
+
const plan = planDeMirror({ css, components });
|
|
86
|
+
|
|
87
|
+
// 4. Optional per-page view filter — narrow the reported slices to one page's components.
|
|
88
|
+
const slug = url.searchParams.get('slug');
|
|
89
|
+
let scoped = plan.components;
|
|
90
|
+
let scopedNote: string | undefined;
|
|
91
|
+
if (slug) {
|
|
92
|
+
const pagePath = slug === 'index' ? '/' : `/${slug.replace(/^\//, '')}`;
|
|
93
|
+
const pageData = pageService.getPageData(pagePath);
|
|
94
|
+
if (!pageData) {
|
|
95
|
+
return jsonResponse({ error: `Page not found: ${slug}` }, { status: 404 });
|
|
96
|
+
}
|
|
97
|
+
const used = componentsUsedByPage(
|
|
98
|
+
pageData,
|
|
99
|
+
components.map((c) => c.name),
|
|
100
|
+
);
|
|
101
|
+
scoped = plan.components.filter((c) => used.has(c.name));
|
|
102
|
+
scopedNote = `filtered to ${scoped.length} component(s) used by /${slug} (ownership computed project-wide)`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cssFilesRel = cssFiles.map((f) => path.relative(root, f));
|
|
106
|
+
const view = { ...plan, components: scoped };
|
|
107
|
+
|
|
108
|
+
const format = url.searchParams.get('format');
|
|
109
|
+
if (format === 'md' || format === 'markdown') {
|
|
110
|
+
const md = `${renderDeMirrorMarkdown(view)}\n${scopedNote ? `\n_${scopedNote}_\n` : ''}\n_Sheet: ${cssFilesRel.join(', ')}_\n`;
|
|
111
|
+
return new Response(md, {
|
|
112
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'no-store, max-age=0' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// JSON: strip the (potentially large) slice text unless explicitly requested.
|
|
117
|
+
const includeSlices = url.searchParams.get('slices') === 'true';
|
|
118
|
+
const responseComponents = includeSlices ? scoped : scoped.map(({ css: _css, ...meta }) => meta);
|
|
119
|
+
|
|
120
|
+
return jsonResponse({
|
|
121
|
+
...view,
|
|
122
|
+
components: responseComponents,
|
|
123
|
+
cssFiles: cssFilesRel,
|
|
124
|
+
...(scopedNote ? { scopedNote } : {}),
|
|
125
|
+
dryRun: true,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -14,8 +14,8 @@ import { jsonResponse, cachedJsonResponse } from './shared';
|
|
|
14
14
|
* Handle pages API endpoint - GET /api/pages
|
|
15
15
|
* Returns pages with draft status info
|
|
16
16
|
*/
|
|
17
|
-
export function handlePagesRoute(pageService: PageService): Response {
|
|
18
|
-
const pages = pageService.getAllPagesWithInfo();
|
|
17
|
+
export async function handlePagesRoute(pageService: PageService): Promise<Response> {
|
|
18
|
+
const pages = await pageService.getAllPagesWithInfo();
|
|
19
19
|
return jsonResponse({ pages });
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -509,6 +509,49 @@ describe('ComponentService', () => {
|
|
|
509
509
|
expect(cached?.component?.javascript).toBe('x();');
|
|
510
510
|
});
|
|
511
511
|
|
|
512
|
+
test('concurrent saveComponentCSS + saveComponentJavaScript do not clobber each other', async () => {
|
|
513
|
+
// The Custom Code modal's Save fires a JS save AND a CSS save together. For
|
|
514
|
+
// single-file (astro) components each writer call re-emits the WHOLE file
|
|
515
|
+
// from a snapshot of the other field — so a race lets the slower write
|
|
516
|
+
// overwrite the file with a stale snapshot, silently losing one edit.
|
|
517
|
+
// This models that whole-file re-emit and asserts both edits survive.
|
|
518
|
+
let file: { javascript: string; css: string } = { javascript: '', css: '' };
|
|
519
|
+
const dir = '/virtual/src/components';
|
|
520
|
+
// Yield so unserialized calls would interleave (and race) if not locked.
|
|
521
|
+
const tick = () => new Promise<void>((r) => setTimeout(r, 5));
|
|
522
|
+
const writer: ComponentWriter = {
|
|
523
|
+
componentsDir: () => dir,
|
|
524
|
+
writeComponent: async (_d, _n, def) => {
|
|
525
|
+
file = { javascript: def.component?.javascript ?? '', css: def.component?.css ?? '' };
|
|
526
|
+
},
|
|
527
|
+
writeJavaScript: async (_d, _n, js, current) => {
|
|
528
|
+
await tick();
|
|
529
|
+
file = { javascript: js, css: current?.component?.css ?? '' };
|
|
530
|
+
},
|
|
531
|
+
writeCSS: async (_d, _n, css, current) => {
|
|
532
|
+
await tick();
|
|
533
|
+
file = { javascript: current?.component?.javascript ?? '', css };
|
|
534
|
+
},
|
|
535
|
+
removeComponent: async () => {},
|
|
536
|
+
componentExists: async () => false,
|
|
537
|
+
moveComponent: async () => {},
|
|
538
|
+
};
|
|
539
|
+
const svc = new ComponentService({ writer });
|
|
540
|
+
await svc.saveComponent('Widget', createMockComponentDefinition('Widget'));
|
|
541
|
+
|
|
542
|
+
await Promise.all([
|
|
543
|
+
svc.saveComponentCSS('Widget', '.hello {}'),
|
|
544
|
+
svc.saveComponentJavaScript('Widget', 'console.log(1);'),
|
|
545
|
+
]);
|
|
546
|
+
|
|
547
|
+
// Both the on-disk file and the in-memory cache keep both fields.
|
|
548
|
+
expect(file.css).toBe('.hello {}');
|
|
549
|
+
expect(file.javascript).toBe('console.log(1);');
|
|
550
|
+
const cached = svc.getComponent('Widget');
|
|
551
|
+
expect(cached?.component?.css).toBe('.hello {}');
|
|
552
|
+
expect(cached?.component?.javascript).toBe('console.log(1);');
|
|
553
|
+
});
|
|
554
|
+
|
|
512
555
|
test('renameComponent uses writer.moveComponent with the new name', async () => {
|
|
513
556
|
const { writer, calls } = makeSpyWriter();
|
|
514
557
|
const svc = new ComponentService({
|
|
@@ -98,6 +98,35 @@ export class ComponentService {
|
|
|
98
98
|
private writer?: ComponentWriter;
|
|
99
99
|
private loadErrors = new Map<string, ComponentLoadDiagnostic>();
|
|
100
100
|
private loadWarnings: ComponentLoadDiagnostic[] = [];
|
|
101
|
+
/** Per-component serialization of whole-file re-emits. @see withComponentWriteLock */
|
|
102
|
+
private writeLocks = new Map<string, Promise<unknown>>();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Serialize whole-file re-emits for one component.
|
|
106
|
+
*
|
|
107
|
+
* `saveComponentCSS` and `saveComponentJavaScript` each read the in-memory
|
|
108
|
+
* definition, re-emit the ENTIRE `.astro` file, then write the result back.
|
|
109
|
+
* Fired concurrently — the Custom Code modal's Save issues a JS save AND a CSS
|
|
110
|
+
* save together — their read-modify-write races: each reads a snapshot taken
|
|
111
|
+
* before the other's `components.set`, so the writer that lands last clobbers
|
|
112
|
+
* the other field with a stale value (a fresh CSS edit silently reverting is
|
|
113
|
+
* the visible symptom). Chaining per component name makes each op observe the
|
|
114
|
+
* previous op's committed state. Prior failures are swallowed on the chain tail
|
|
115
|
+
* so one bad write can't wedge the queue.
|
|
116
|
+
*/
|
|
117
|
+
private withComponentWriteLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
118
|
+
const prev = this.writeLocks.get(name) ?? Promise.resolve();
|
|
119
|
+
const run = prev.then(fn, fn);
|
|
120
|
+
const tail = run.then(
|
|
121
|
+
() => {},
|
|
122
|
+
() => {},
|
|
123
|
+
);
|
|
124
|
+
this.writeLocks.set(name, tail);
|
|
125
|
+
tail.finally(() => {
|
|
126
|
+
if (this.writeLocks.get(name) === tail) this.writeLocks.delete(name);
|
|
127
|
+
});
|
|
128
|
+
return run;
|
|
129
|
+
}
|
|
101
130
|
|
|
102
131
|
/**
|
|
103
132
|
* Creates a new ComponentService instance
|
|
@@ -569,12 +598,16 @@ export class ComponentService {
|
|
|
569
598
|
if (this.writer) {
|
|
570
599
|
// Single-file formats (astro) fold JS into the `.astro` file. Re-emit from
|
|
571
600
|
// the latest in-memory definition with the new JS so structure/CSS survive.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
601
|
+
// Serialized against a concurrent CSS save so the two whole-file re-emits
|
|
602
|
+
// don't clobber each other (see withComponentWriteLock).
|
|
603
|
+
await this.withComponentWriteLock(name, async () => {
|
|
604
|
+
const current = this.components.get(name);
|
|
605
|
+
await this.writer!.writeJavaScript(componentDir, name, javascript || '', current);
|
|
606
|
+
const next: ComponentDefinition = current
|
|
607
|
+
? { ...current, component: { ...current.component, javascript: javascript || '' } }
|
|
608
|
+
: { component: { javascript: javascript || '' } };
|
|
609
|
+
this.components.set(name, next);
|
|
610
|
+
});
|
|
578
611
|
return;
|
|
579
612
|
}
|
|
580
613
|
|
|
@@ -625,12 +658,16 @@ export class ComponentService {
|
|
|
625
658
|
if (this.writer) {
|
|
626
659
|
// Single-file formats (astro) fold CSS into the `.astro` file. Re-emit from
|
|
627
660
|
// the latest in-memory definition with the new CSS so structure/JS survive.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
661
|
+
// Serialized against a concurrent JS save so the two whole-file re-emits
|
|
662
|
+
// don't clobber each other (see withComponentWriteLock).
|
|
663
|
+
await this.withComponentWriteLock(name, async () => {
|
|
664
|
+
const current = this.components.get(name);
|
|
665
|
+
await this.writer!.writeCSS(componentDir, name, css || '', current);
|
|
666
|
+
const next: ComponentDefinition = current
|
|
667
|
+
? { ...current, component: { ...current.component, css: css || '' } }
|
|
668
|
+
: { component: { css: css || '' } };
|
|
669
|
+
this.components.set(name, next);
|
|
670
|
+
});
|
|
634
671
|
return;
|
|
635
672
|
}
|
|
636
673
|
|
|
@@ -66,7 +66,6 @@ interface RawProjectConfig {
|
|
|
66
66
|
imageFormat?: ImageFormat;
|
|
67
67
|
remConversion?: Partial<RemConversionConfig>;
|
|
68
68
|
customCode?: CustomCodeConfig;
|
|
69
|
-
showMenoBadge?: boolean;
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
/**
|
|
@@ -343,10 +342,6 @@ export class ConfigService {
|
|
|
343
342
|
return this.config.customCode;
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
getShowMenoBadge(): boolean {
|
|
347
|
-
return this.config?.showMenoBadge === true;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
345
|
getImageFormat(): ImageFormat {
|
|
351
346
|
if (this.config?.imageFormat === 'avif') return 'avif';
|
|
352
347
|
return 'webp';
|
|
@@ -38,6 +38,21 @@ describe('PageService', () => {
|
|
|
38
38
|
expect(mockProvider.loadAll).toHaveBeenCalledTimes(1);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test('reloading prunes cached paths the provider no longer reports (no ghost entries)', async () => {
|
|
42
|
+
mockProvider._pages['/'] = JSON.stringify(createMockPageData({ meta: { title: 'Home' } }));
|
|
43
|
+
mockProvider._pages['/blog/index'] = JSON.stringify(createMockPageData({ meta: { title: 'Blog' } }));
|
|
44
|
+
await pageService.loadAllPages();
|
|
45
|
+
expect(pageService.getAllPagePaths().sort()).toEqual(['/', '/blog/index']);
|
|
46
|
+
|
|
47
|
+
// Simulate the page's path changing on disk (folder-index collapse / rename).
|
|
48
|
+
delete mockProvider._pages['/blog/index'];
|
|
49
|
+
mockProvider._pages['/blog'] = JSON.stringify(createMockPageData({ meta: { title: 'Blog' } }));
|
|
50
|
+
await pageService.loadAllPages();
|
|
51
|
+
|
|
52
|
+
// The stale `/blog/index` key must be gone — only the current paths remain.
|
|
53
|
+
expect(pageService.getAllPagePaths().sort()).toEqual(['/', '/blog']);
|
|
54
|
+
});
|
|
55
|
+
|
|
41
56
|
test('should handle empty provider gracefully', async () => {
|
|
42
57
|
await pageService.loadAllPages();
|
|
43
58
|
|