meno-core 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{chunk-LOZL5HOF.js → chunk-UOF4MCAD.js} +64 -2
- package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
- package/dist/chunks/{chunk-3JXK2QFU.js → chunk-ZEDLSHYQ.js} +2 -2
- package/dist/lib/client/index.js +46 -28
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1267 -233
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +3 -1
- package/dist/lib/shared/index.js.map +1 -1
- package/lib/client/core/ComponentBuilder.test.ts +32 -0
- package/lib/client/core/ComponentBuilder.ts +30 -0
- package/lib/client/theme.test.ts +2 -2
- package/lib/client/theme.ts +28 -26
- package/lib/server/createServer.ts +5 -1
- package/lib/server/cssAudit.test.ts +270 -0
- package/lib/server/cssAudit.ts +815 -0
- package/lib/server/deMirror.test.ts +61 -0
- package/lib/server/deMirror.ts +317 -0
- package/lib/server/fileWatcher.test.ts +23 -0
- package/lib/server/fileWatcher.ts +6 -1
- package/lib/server/index.ts +19 -0
- package/lib/server/routes/api/core-routes.ts +18 -0
- package/lib/server/routes/api/cssAudit.test.ts +101 -0
- package/lib/server/routes/api/cssAudit.ts +138 -0
- package/lib/server/routes/api/deMirror.test.ts +105 -0
- package/lib/server/routes/api/deMirror.ts +127 -0
- package/lib/server/routes/api/pages.ts +2 -2
- package/lib/server/services/componentService.test.ts +43 -0
- package/lib/server/services/componentService.ts +49 -12
- package/lib/server/services/pageService.test.ts +15 -0
- package/lib/server/services/pageService.ts +44 -22
- package/lib/server/ssr/htmlGenerator.test.ts +29 -0
- package/lib/server/ssr/htmlGenerator.ts +83 -3
- package/lib/server/themeCssCodec.test.ts +67 -0
- package/lib/server/themeCssCodec.ts +67 -5
- package/lib/server/webflow/templateWrapper.ts +54 -0
- package/lib/shared/cssGeneration.test.ts +28 -0
- package/lib/shared/interfaces/contentProvider.ts +9 -0
- package/lib/shared/types/cms.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +2 -0
- package/lib/shared/utilityClassMapper.test.ts +53 -0
- package/lib/shared/utilityClassNames.ts +74 -0
- package/lib/shared/utils/fileUtils.ts +11 -0
- package/lib/shared/validation/schemas.ts +1 -0
- package/lib/shared/viewportUnits.test.ts +34 -1
- package/lib/shared/viewportUnits.ts +43 -0
- package/package.json +3 -1
- package/vite.config.ts +4 -1
- package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
- /package/dist/chunks/{chunk-3JXK2QFU.js.map → chunk-ZEDLSHYQ.js.map} +0 -0
|
@@ -0,0 +1,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
|
|
|
@@ -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
|
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readdirSync, mkdirSync, rmdirSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { join, relative, sep } from 'node:path';
|
|
11
11
|
import type { PageCache } from '../pageCache';
|
|
12
12
|
import { parseJSON } from '../jsonLoader';
|
|
13
13
|
import { buildLineMap } from '../utils/jsonLineMapper';
|
|
14
|
-
import { projectPaths } from '../projectContext';
|
|
14
|
+
import { projectPaths, getProjectRoot } from '../projectContext';
|
|
15
15
|
import { logRuntimeError } from '../../shared/errorLogger';
|
|
16
16
|
import { isPathWithinRoot } from '../../shared/pathSecurity';
|
|
17
17
|
import type { JSONPage } from '../../shared/types';
|
|
@@ -64,6 +64,13 @@ export class PageService {
|
|
|
64
64
|
const lineMap = buildLineMap(content);
|
|
65
65
|
this.pageCache.set(path, content, lineMap);
|
|
66
66
|
}
|
|
67
|
+
// Reconcile: drop cached paths the provider no longer reports. Without this, a page whose
|
|
68
|
+
// path CHANGES on disk (a rename, or a folder-index file `blog/index.astro` that now maps
|
|
69
|
+
// to `/blog` instead of `/blog/index`) leaves its old key behind as a ghost entry — it
|
|
70
|
+
// keeps showing up in the page picker until the process restarts.
|
|
71
|
+
for (const path of this.pageCache.keys()) {
|
|
72
|
+
if (!pages.has(path)) this.pageCache.delete(path);
|
|
73
|
+
}
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -241,30 +248,45 @@ export class PageService {
|
|
|
241
248
|
*
|
|
242
249
|
* @example
|
|
243
250
|
* ```typescript
|
|
244
|
-
* const pages = pageService.getAllPagesWithInfo();
|
|
251
|
+
* const pages = await pageService.getAllPagesWithInfo();
|
|
245
252
|
* // [{ path: "/", isDraft: false }, { path: "/about", isDraft: true }]
|
|
246
253
|
* ```
|
|
247
254
|
*/
|
|
248
|
-
getAllPagesWithInfo():
|
|
255
|
+
async getAllPagesWithInfo(): Promise<
|
|
256
|
+
Array<{ path: string; isDraft: boolean; folder?: string; source?: 'cms' | 'ssr'; sourcePath?: string }>
|
|
257
|
+
> {
|
|
249
258
|
const paths = this.pageCache.keys().filter((p) => !p.startsWith('/_sketch/'));
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
path
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
259
|
+
return Promise.all(
|
|
260
|
+
paths.map(async (path) => {
|
|
261
|
+
const pageData = this.getPageData(path);
|
|
262
|
+
// Extract folder from path: /blog/post -> blog, /templates/blog-post -> templates
|
|
263
|
+
// Root-level pages (e.g., /, /about) have no folder
|
|
264
|
+
const pathWithoutSlash = path === '/' ? 'index' : path.substring(1);
|
|
265
|
+
const slashIndex = pathWithoutSlash.lastIndexOf('/');
|
|
266
|
+
const folder = slashIndex > 0 ? pathWithoutSlash.substring(0, slashIndex) : undefined;
|
|
267
|
+
// Surface a non-static page source (`cms` template / `ssr` live page) so the editor's
|
|
268
|
+
// page picker can group them apart from plain static pages. Static/undefined omitted.
|
|
269
|
+
const metaSource = pageData?.meta?.source;
|
|
270
|
+
const source = metaSource === 'cms' || metaSource === 'ssr' ? metaSource : undefined;
|
|
271
|
+
// For template pages the logical editor path (`/templates/<col>`) diverges from the
|
|
272
|
+
// on-disk file (`src/pages/<routeDir>/[slug].astro`), so a naive `<pagesDir>/<path>`
|
|
273
|
+
// mapping opens the wrong (nonexistent) file. Ask the provider to resolve the real
|
|
274
|
+
// source file and surface it project-root-relative so "Open source file" targets it.
|
|
275
|
+
// Only resolved for templates — normal pages' paths map directly.
|
|
276
|
+
let sourcePath: string | undefined;
|
|
277
|
+
if (source === 'cms' && this.provider?.resolveSourcePath) {
|
|
278
|
+
const abs = await this.provider.resolveSourcePath(path);
|
|
279
|
+
if (abs) sourcePath = relative(getProjectRoot(), abs).split(sep).join('/');
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
path,
|
|
283
|
+
isDraft: pageData?.meta?.draft === true,
|
|
284
|
+
...(folder ? { folder } : {}),
|
|
285
|
+
...(source ? { source } : {}),
|
|
286
|
+
...(sourcePath ? { sourcePath } : {}),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
268
290
|
}
|
|
269
291
|
|
|
270
292
|
/**
|