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.
Files changed (50) hide show
  1. package/dist/chunks/{chunk-LOZL5HOF.js → chunk-UOF4MCAD.js} +64 -2
  2. package/dist/chunks/chunk-UOF4MCAD.js.map +7 -0
  3. package/dist/chunks/{chunk-3JXK2QFU.js → chunk-ZEDLSHYQ.js} +2 -2
  4. package/dist/lib/client/index.js +46 -28
  5. package/dist/lib/client/index.js.map +2 -2
  6. package/dist/lib/server/index.js +1267 -233
  7. package/dist/lib/server/index.js.map +4 -4
  8. package/dist/lib/shared/index.js +3 -1
  9. package/dist/lib/shared/index.js.map +1 -1
  10. package/lib/client/core/ComponentBuilder.test.ts +32 -0
  11. package/lib/client/core/ComponentBuilder.ts +30 -0
  12. package/lib/client/theme.test.ts +2 -2
  13. package/lib/client/theme.ts +28 -26
  14. package/lib/server/createServer.ts +5 -1
  15. package/lib/server/cssAudit.test.ts +270 -0
  16. package/lib/server/cssAudit.ts +815 -0
  17. package/lib/server/deMirror.test.ts +61 -0
  18. package/lib/server/deMirror.ts +317 -0
  19. package/lib/server/fileWatcher.test.ts +23 -0
  20. package/lib/server/fileWatcher.ts +6 -1
  21. package/lib/server/index.ts +19 -0
  22. package/lib/server/routes/api/core-routes.ts +18 -0
  23. package/lib/server/routes/api/cssAudit.test.ts +101 -0
  24. package/lib/server/routes/api/cssAudit.ts +138 -0
  25. package/lib/server/routes/api/deMirror.test.ts +105 -0
  26. package/lib/server/routes/api/deMirror.ts +127 -0
  27. package/lib/server/routes/api/pages.ts +2 -2
  28. package/lib/server/services/componentService.test.ts +43 -0
  29. package/lib/server/services/componentService.ts +49 -12
  30. package/lib/server/services/pageService.test.ts +15 -0
  31. package/lib/server/services/pageService.ts +44 -22
  32. package/lib/server/ssr/htmlGenerator.test.ts +29 -0
  33. package/lib/server/ssr/htmlGenerator.ts +83 -3
  34. package/lib/server/themeCssCodec.test.ts +67 -0
  35. package/lib/server/themeCssCodec.ts +67 -5
  36. package/lib/server/webflow/templateWrapper.ts +54 -0
  37. package/lib/shared/cssGeneration.test.ts +28 -0
  38. package/lib/shared/interfaces/contentProvider.ts +9 -0
  39. package/lib/shared/types/cms.ts +1 -0
  40. package/lib/shared/utilityClassConfig.ts +2 -0
  41. package/lib/shared/utilityClassMapper.test.ts +53 -0
  42. package/lib/shared/utilityClassNames.ts +74 -0
  43. package/lib/shared/utils/fileUtils.ts +11 -0
  44. package/lib/shared/validation/schemas.ts +1 -0
  45. package/lib/shared/viewportUnits.test.ts +34 -1
  46. package/lib/shared/viewportUnits.ts +43 -0
  47. package/package.json +3 -1
  48. package/vite.config.ts +4 -1
  49. package/dist/chunks/chunk-LOZL5HOF.js.map +0 -7
  50. /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
- const current = this.components.get(name);
573
- await this.writer.writeJavaScript(componentDir, name, javascript || '', current);
574
- const next: ComponentDefinition = current
575
- ? { ...current, component: { ...current.component, javascript: javascript || '' } }
576
- : { component: { javascript: javascript || '' } };
577
- this.components.set(name, next);
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
- const current = this.components.get(name);
629
- await this.writer.writeCSS(componentDir, name, css || '', current);
630
- const next: ComponentDefinition = current
631
- ? { ...current, component: { ...current.component, css: css || '' } }
632
- : { component: { css: css || '' } };
633
- this.components.set(name, next);
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(): Array<{ path: string; isDraft: boolean; folder?: string; source?: 'cms' | 'ssr' }> {
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 paths.map((path) => {
251
- const pageData = this.getPageData(path);
252
- // Extract folder from path: /blog/post -> blog, /templates/blog-post -> templates
253
- // Root-level pages (e.g., /, /about) have no folder
254
- const pathWithoutSlash = path === '/' ? 'index' : path.substring(1);
255
- const slashIndex = pathWithoutSlash.lastIndexOf('/');
256
- const folder = slashIndex > 0 ? pathWithoutSlash.substring(0, slashIndex) : undefined;
257
- // Surface a non-static page source (`cms` template / `ssr` live page) so the editor's
258
- // page picker can group them apart from plain static pages. Static/undefined omitted.
259
- const metaSource = pageData?.meta?.source;
260
- const source = metaSource === 'cms' || metaSource === 'ssr' ? metaSource : undefined;
261
- return {
262
- path,
263
- isDraft: pageData?.meta?.draft === true,
264
- ...(folder ? { folder } : {}),
265
- ...(source ? { source } : {}),
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
  /**