meno-core 1.0.52 → 1.0.53

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 (135) hide show
  1. package/build-astro.ts +183 -13
  2. package/build-next.ts +1361 -0
  3. package/build-static.ts +7 -5
  4. package/dist/bin/cli.js +2 -2
  5. package/dist/build-static.js +6 -6
  6. package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
  7. package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
  8. package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
  9. package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
  10. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
  11. package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
  12. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
  13. package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
  14. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
  15. package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
  16. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
  17. package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
  18. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
  19. package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
  20. package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
  21. package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
  22. package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
  23. package/dist/chunks/chunk-X754AHS5.js.map +7 -0
  24. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
  25. package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
  26. package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
  27. package/dist/entries/server-router.js +7 -7
  28. package/dist/lib/client/index.js +354 -59
  29. package/dist/lib/client/index.js.map +4 -4
  30. package/dist/lib/server/index.js +1458 -190
  31. package/dist/lib/server/index.js.map +4 -4
  32. package/dist/lib/shared/index.js +202 -34
  33. package/dist/lib/shared/index.js.map +4 -4
  34. package/dist/lib/test-utils/index.js +1 -1
  35. package/entries/client-router.tsx +5 -165
  36. package/lib/client/ErrorBoundary.test.tsx +27 -25
  37. package/lib/client/ErrorBoundary.tsx +34 -19
  38. package/lib/client/core/ComponentBuilder.ts +19 -2
  39. package/lib/client/core/builders/embedBuilder.ts +8 -4
  40. package/lib/client/core/builders/listBuilder.ts +23 -4
  41. package/lib/client/fontFamiliesService.test.ts +76 -0
  42. package/lib/client/fontFamiliesService.ts +69 -0
  43. package/lib/client/hmrCssReload.ts +160 -0
  44. package/lib/client/hooks/useColorVariables.ts +2 -0
  45. package/lib/client/index.ts +4 -0
  46. package/lib/client/meno-filter/ui.ts +2 -0
  47. package/lib/client/routing/RouteLoader.test.ts +2 -2
  48. package/lib/client/routing/RouteLoader.ts +8 -2
  49. package/lib/client/routing/Router.tsx +81 -15
  50. package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
  51. package/lib/client/scripts/ScriptExecutor.ts +56 -2
  52. package/lib/client/styles/StyleInjector.ts +20 -5
  53. package/lib/client/styles/UtilityClassCollector.ts +7 -1
  54. package/lib/client/styles/cspNonce.test.ts +67 -0
  55. package/lib/client/styles/cspNonce.ts +63 -0
  56. package/lib/client/templateEngine.test.ts +80 -0
  57. package/lib/client/templateEngine.ts +5 -0
  58. package/lib/server/astro/cmsPageEmitter.ts +35 -5
  59. package/lib/server/astro/componentEmitter.ts +61 -5
  60. package/lib/server/astro/nodeToAstro.ts +149 -11
  61. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
  62. package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
  63. package/lib/server/createServer.ts +11 -0
  64. package/lib/server/draftPageStore.ts +49 -0
  65. package/lib/server/fileWatcher.ts +62 -2
  66. package/lib/server/index.ts +13 -1
  67. package/lib/server/providers/fileSystemPageProvider.ts +8 -0
  68. package/lib/server/routes/api/components.ts +9 -4
  69. package/lib/server/routes/api/core-routes.ts +2 -2
  70. package/lib/server/routes/api/pages.ts +14 -22
  71. package/lib/server/routes/api/shared.ts +56 -0
  72. package/lib/server/routes/index.ts +90 -0
  73. package/lib/server/routes/pages.ts +13 -6
  74. package/lib/server/services/componentService.test.ts +199 -2
  75. package/lib/server/services/componentService.ts +354 -49
  76. package/lib/server/services/fileWatcherService.ts +4 -24
  77. package/lib/server/services/pageService.test.ts +23 -0
  78. package/lib/server/services/pageService.ts +124 -6
  79. package/lib/server/ssr/attributeBuilder.ts +8 -2
  80. package/lib/server/ssr/buildErrorOverlay.ts +1 -1
  81. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  82. package/lib/server/ssr/errorOverlay.ts +38 -11
  83. package/lib/server/ssr/htmlGenerator.test.ts +53 -13
  84. package/lib/server/ssr/htmlGenerator.ts +71 -27
  85. package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
  86. package/lib/server/ssr/ssrRenderer.test.ts +67 -0
  87. package/lib/server/ssr/ssrRenderer.ts +94 -9
  88. package/lib/server/websocketManager.ts +0 -1
  89. package/lib/shared/componentRefs.ts +45 -0
  90. package/lib/shared/constants.ts +8 -0
  91. package/lib/shared/cssGeneration.ts +2 -0
  92. package/lib/shared/cssProperties.ts +184 -0
  93. package/lib/shared/expressionEvaluator.ts +54 -0
  94. package/lib/shared/fontCss.ts +101 -0
  95. package/lib/shared/fontLoader.ts +8 -86
  96. package/lib/shared/friendlyError.test.ts +87 -0
  97. package/lib/shared/friendlyError.ts +121 -0
  98. package/lib/shared/hrefRefs.test.ts +130 -0
  99. package/lib/shared/hrefRefs.ts +100 -0
  100. package/lib/shared/index.ts +52 -0
  101. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  102. package/lib/shared/inlineSvgStyleRules.ts +134 -0
  103. package/lib/shared/interfaces/contentProvider.ts +13 -0
  104. package/lib/shared/itemTemplateUtils.test.ts +14 -0
  105. package/lib/shared/itemTemplateUtils.ts +4 -1
  106. package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
  107. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
  108. package/lib/shared/slugTranslator.test.ts +24 -0
  109. package/lib/shared/slugTranslator.ts +24 -0
  110. package/lib/shared/styleNodeUtils.ts +4 -1
  111. package/lib/shared/tree/PathBuilder.test.ts +128 -1
  112. package/lib/shared/tree/PathBuilder.ts +83 -31
  113. package/lib/shared/types/comment.ts +99 -0
  114. package/lib/shared/types/index.ts +12 -0
  115. package/lib/shared/types/rendering.ts +8 -0
  116. package/lib/shared/utilityClassConfig.ts +4 -2
  117. package/lib/shared/utilityClassMapper.test.ts +24 -0
  118. package/lib/shared/validation/commentValidators.ts +69 -0
  119. package/lib/shared/validation/index.ts +1 -0
  120. package/lib/shared/viewportUnits.integration.test.ts +42 -0
  121. package/lib/shared/viewportUnits.test.ts +103 -0
  122. package/lib/shared/viewportUnits.ts +63 -0
  123. package/lib/test-utils/dom-setup.ts +6 -0
  124. package/package.json +1 -1
  125. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  126. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  127. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  128. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  129. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  130. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  131. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  132. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  133. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  134. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  135. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
@@ -11,8 +11,13 @@ import { existsSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
11
11
  import { loadComponentDirectory, loadJSONFile, parseJSON } from '../jsonLoader';
12
12
  import type { ComponentWithCategory, ComponentDirectoryResult, ComponentLoadDiagnostic } from '../jsonLoader';
13
13
  import { projectPaths } from '../projectContext';
14
- import type { ComponentDefinition } from '../../shared/types';
14
+ import type { ComponentDefinition, JSONPage } from '../../shared/types';
15
15
  import { readTextFile, fileExists } from '../runtime';
16
+ import type { PageService } from './pageService';
17
+ import { rewriteComponentRefs } from '../../shared/componentRefs';
18
+
19
+ // Re-export for callers that already imported the helper from this module.
20
+ export { rewriteComponentRefs };
16
21
 
17
22
  /**
18
23
  * File system interface for dependency injection
@@ -32,6 +37,50 @@ export interface ComponentLoader {
32
37
  loadFile(path: string): Promise<string | null>;
33
38
  }
34
39
 
40
+ /**
41
+ * Component writer interface for dependency injection.
42
+ *
43
+ * This is the WRITE-side mirror of {@link ComponentLoader}. By default (no writer
44
+ * injected) ComponentService keeps its exact JSON behavior — `.json` definition files
45
+ * plus sibling `.js`/`.css` under `<root>/components/`. Injecting a writer lets a
46
+ * different on-disk format own where and how components are persisted. The astro
47
+ * format (`meno-astro`'s `AstroComponentWriter`) emits a single `.astro` file per
48
+ * component under `src/components/` (structure + JS + CSS + meta folded together).
49
+ *
50
+ * The service still owns category resolution, the in-memory cache, ref-rewriting,
51
+ * and validation; the writer only abstracts the directory + the physical file I/O.
52
+ */
53
+ export interface ComponentWriter {
54
+ /** Base components directory this writer owns (e.g. `<root>/src/components`). */
55
+ componentsDir(): string;
56
+
57
+ /**
58
+ * Persist a component definition into `dir` under `name`. For single-file formats
59
+ * (astro) `def` carries everything (structure/interface/js/css/meta); for the JSON
60
+ * writer it is the `.json` payload (js/css live in sibling files written separately).
61
+ */
62
+ writeComponent(dir: string, name: string, def: ComponentDefinition): Promise<void>;
63
+
64
+ /**
65
+ * Persist the component's JavaScript. `currentDef` is the latest in-memory definition
66
+ * so single-file formats can re-emit the whole `.astro` with the new JS folded in.
67
+ * Multi-file formats (JSON) just write a sibling `<name>.js`.
68
+ */
69
+ writeJavaScript(dir: string, name: string, javascript: string, currentDef?: ComponentDefinition): Promise<void>;
70
+
71
+ /** Persist the component's CSS. Mirrors {@link writeJavaScript}. */
72
+ writeCSS(dir: string, name: string, css: string, currentDef?: ComponentDefinition): Promise<void>;
73
+
74
+ /** Delete every on-disk file for `name` in `dir` (definition + any JS/CSS). */
75
+ removeComponent(dir: string, name: string): Promise<void>;
76
+
77
+ /** Whether any on-disk file for `name` already exists in `dir` (collision check). */
78
+ componentExists(dir: string, name: string): Promise<boolean>;
79
+
80
+ /** Move every file for `name` from `srcDir` to `dstDir` (category change / rename). */
81
+ moveComponent(srcDir: string, dstDir: string, name: string, dstName?: string): Promise<void>;
82
+ }
83
+
35
84
  /**
36
85
  * Component info with category metadata
37
86
  */
@@ -45,6 +94,7 @@ export class ComponentService {
45
94
  private componentCategories = new Map<string, string | undefined>();
46
95
  private fs?: ComponentServiceFs;
47
96
  private loader?: ComponentLoader;
97
+ private writer?: ComponentWriter;
48
98
  private loadErrors = new Map<string, ComponentLoadDiagnostic>();
49
99
  private loadWarnings: ComponentLoadDiagnostic[] = [];
50
100
 
@@ -53,11 +103,27 @@ export class ComponentService {
53
103
  *
54
104
  * @param options - Optional configuration for dependency injection
55
105
  * @param options.fs - Optional file system interface for testing
56
- * @param options.loader - Optional component loader interface for testing
106
+ * @param options.loader - Optional component loader interface for testing (READ)
107
+ * @param options.writer - Optional component writer for non-JSON on-disk formats
108
+ * (WRITE). When omitted, the service writes JSON exactly as before — legacy
109
+ * projects are byte-identical. The astro bootstrap injects an AstroComponentWriter
110
+ * so saves emit `.astro` files under `src/components/`.
57
111
  */
58
- constructor(options?: { fs?: ComponentServiceFs; loader?: ComponentLoader }) {
112
+ constructor(options?: { fs?: ComponentServiceFs; loader?: ComponentLoader; writer?: ComponentWriter }) {
59
113
  this.fs = options?.fs;
60
114
  this.loader = options?.loader;
115
+ this.writer = options?.writer;
116
+ }
117
+
118
+ /**
119
+ * Base components directory — `<root>/components` for JSON, or whatever the
120
+ * injected writer owns (e.g. `<root>/src/components` for astro). All path
121
+ * resolution (categories, folders, save/move/rename targets) routes through
122
+ * here so a single switch covers every write op.
123
+ * @internal
124
+ */
125
+ private componentsBaseDir(): string {
126
+ return this.writer ? this.writer.componentsDir() : projectPaths.components();
61
127
  }
62
128
 
63
129
  /**
@@ -77,10 +143,15 @@ export class ComponentService {
77
143
  * ```
78
144
  */
79
145
  async loadAllComponents(): Promise<void> {
80
- this.components.clear();
81
- this.componentCategories.clear();
82
- this.loadErrors.clear();
83
- this.loadWarnings = [];
146
+ // Build new state locally and swap atomically. Concurrent saves
147
+ // (e.g. ensureAcceptsStyles firing while the file watcher reload runs)
148
+ // must never observe an empty `componentCategories` mid-reload — that
149
+ // gap was what caused fresh component files to be written at root next
150
+ // to the canonical categorized file.
151
+ const nextComponents = new Map<string, ComponentDefinition>();
152
+ const nextCategories = new Map<string, string | undefined>();
153
+ const nextErrors = new Map<string, ComponentLoadDiagnostic>();
154
+ let nextWarnings: ComponentLoadDiagnostic[] = [];
84
155
 
85
156
  let loadedComponents: Map<string, ComponentWithCategory>;
86
157
 
@@ -92,9 +163,9 @@ export class ComponentService {
92
163
  loadedComponents = result.components;
93
164
 
94
165
  // Store diagnostics
95
- this.loadWarnings = result.warnings;
166
+ nextWarnings = result.warnings;
96
167
  for (const error of result.errors) {
97
- this.loadErrors.set(error.componentName, error);
168
+ nextErrors.set(error.componentName, error);
98
169
  }
99
170
  }
100
171
 
@@ -103,13 +174,17 @@ export class ComponentService {
103
174
  const componentWithCategory = value as ComponentWithCategory;
104
175
  const category = componentWithCategory._category;
105
176
 
106
- // Store category mapping
107
- this.componentCategories.set(key, category);
177
+ nextCategories.set(key, category);
108
178
 
109
179
  // Remove internal metadata before storing
110
180
  const { _category, _relativePath, ...cleanDef } = componentWithCategory;
111
- this.components.set(key, cleanDef as ComponentDefinition);
181
+ nextComponents.set(key, cleanDef as ComponentDefinition);
112
182
  });
183
+
184
+ this.components = nextComponents;
185
+ this.componentCategories = nextCategories;
186
+ this.loadErrors = nextErrors;
187
+ this.loadWarnings = nextWarnings;
113
188
  }
114
189
 
115
190
  /**
@@ -286,11 +361,67 @@ export class ComponentService {
286
361
  * @internal
287
362
  */
288
363
  private getComponentDir(name: string, category?: string): string {
289
- const componentsDir = projectPaths.components();
290
- const cat = category ?? this.componentCategories.get(name);
364
+ const componentsDir = this.componentsBaseDir();
365
+ const cat = this.resolveComponentCategory(name, category);
291
366
  return cat ? join(componentsDir, cat) : componentsDir;
292
367
  }
293
368
 
369
+ /**
370
+ * Resolve which category a save should land in. Priority:
371
+ * 1. Explicit caller-supplied category (including '' for root)
372
+ * 2. In-memory categories cache
373
+ * 3. Existing file on disk (defense against a stale/empty cache)
374
+ * Falls back to root only when none of the above resolves. The disk
375
+ * scan in step 3 closes the race where a save fires while
376
+ * loadAllComponents has been called but hasn't rebuilt the cache yet.
377
+ * @internal
378
+ */
379
+ private resolveComponentCategory(name: string, category?: string): string | undefined {
380
+ if (category !== undefined) {
381
+ return category || undefined;
382
+ }
383
+ if (this.componentCategories.has(name)) {
384
+ return this.componentCategories.get(name);
385
+ }
386
+ return this.findComponentCategoryOnDisk(name);
387
+ }
388
+
389
+ /**
390
+ * Look on disk for an existing `<name>.json` and return its category.
391
+ * Returns undefined for "exists at root" or "not found anywhere" — both
392
+ * map to writing at root, which is the right default.
393
+ * @internal
394
+ */
395
+ private findComponentCategoryOnDisk(name: string): string | undefined {
396
+ const componentsDir = this.componentsBaseDir();
397
+ if (!existsSync(componentsDir)) return undefined;
398
+ // File extension is format-specific (.json for JSON, .astro for astro). When a
399
+ // writer is injected we don't know its extension here, so use a synchronous
400
+ // directory scan that matches any `<name>.*` definition file.
401
+ const ext = this.writer ? null : '.json';
402
+ const existsAt = (dir: string): boolean => {
403
+ if (ext) return existsSync(join(dir, `${name}${ext}`));
404
+ try {
405
+ return readdirSync(dir, { withFileTypes: true }).some(
406
+ (e) => e.isFile() && (e.name === `${name}.json` || e.name === `${name}.astro`),
407
+ );
408
+ } catch {
409
+ return false;
410
+ }
411
+ };
412
+ if (existsAt(componentsDir)) return undefined;
413
+ try {
414
+ for (const entry of readdirSync(componentsDir, { withFileTypes: true })) {
415
+ if (entry.isDirectory() && existsAt(join(componentsDir, entry.name))) {
416
+ return entry.name;
417
+ }
418
+ }
419
+ } catch {
420
+ // If the components dir is unreadable, fall through to root.
421
+ }
422
+ return undefined;
423
+ }
424
+
294
425
  /**
295
426
  * Get component JavaScript from .js file
296
427
  *
@@ -349,33 +480,39 @@ export class ComponentService {
349
480
  * ```
350
481
  */
351
482
  async saveComponent(name: string, data: ComponentDefinition, category?: string): Promise<void> {
352
- const writeFile = this.fs
353
- ? this.fs.writeFile.bind(this.fs)
354
- : (await import('fs/promises')).writeFile;
355
-
356
483
  // Create a copy without the javascript field (JavaScript should only be in .js files)
357
484
  const dataWithoutJS = JSON.parse(JSON.stringify(data));
358
485
  if (dataWithoutJS?.component?.javascript !== undefined) {
359
486
  delete dataWithoutJS.component.javascript;
360
487
  }
361
488
 
362
- // Determine target directory
363
- // If category is explicitly provided (including ''), use it
364
- // Otherwise, use existing category or root
365
- const targetCategory = category !== undefined
366
- ? (category || undefined) // Convert '' to undefined for root
367
- : this.componentCategories.get(name);
368
-
369
- const componentDir = this.getComponentDir(name, targetCategory);
489
+ // Determine target directory. Resolver consults the explicit arg, then
490
+ // the in-memory cache, then disk so a save during the file-watcher
491
+ // reload window can't accidentally land at root next to a categorized
492
+ // file.
493
+ const targetCategory = this.resolveComponentCategory(name, category);
494
+ const componentsDir = this.componentsBaseDir();
495
+ const componentDir = targetCategory ? join(componentsDir, targetCategory) : componentsDir;
496
+
497
+ if (this.writer) {
498
+ // Format-aware write (e.g. emit a `.astro` file). The writer owns directory
499
+ // creation + serialization. The category folder is part of `componentDir`.
500
+ await this.writer.writeComponent(componentDir, name, dataWithoutJS);
501
+ } else {
502
+ // JSON path — unchanged, byte-identical to legacy behavior.
503
+ const writeFile = this.fs
504
+ ? this.fs.writeFile.bind(this.fs)
505
+ : (await import('fs/promises')).writeFile;
506
+
507
+ // Create category directory if needed
508
+ if (targetCategory && !existsSync(componentDir)) {
509
+ mkdirSync(componentDir, { recursive: true });
510
+ }
370
511
 
371
- // Create category directory if needed
372
- if (targetCategory && !existsSync(componentDir)) {
373
- mkdirSync(componentDir, { recursive: true });
512
+ const filePath = join(componentDir, `${name}.json`);
513
+ await writeFile(filePath, JSON.stringify(dataWithoutJS, null, 2), 'utf-8');
374
514
  }
375
515
 
376
- const filePath = join(componentDir, `${name}.json`);
377
- await writeFile(filePath, JSON.stringify(dataWithoutJS, null, 2), 'utf-8');
378
-
379
516
  // Update in-memory cache
380
517
  this.components.set(name, dataWithoutJS);
381
518
  this.componentCategories.set(name, targetCategory);
@@ -399,10 +536,23 @@ export class ComponentService {
399
536
  * ```
400
537
  */
401
538
  async saveComponentJavaScript(name: string, javascript: string): Promise<void> {
539
+ const componentDir = this.getComponentDir(name);
540
+
541
+ if (this.writer) {
542
+ // Single-file formats (astro) fold JS into the `.astro` file. Re-emit from
543
+ // the latest in-memory definition with the new JS so structure/CSS survive.
544
+ const current = this.components.get(name);
545
+ await this.writer.writeJavaScript(componentDir, name, javascript || '', current);
546
+ const next: ComponentDefinition = current
547
+ ? { ...current, component: { ...current.component, javascript: javascript || '' } }
548
+ : { component: { javascript: javascript || '' } };
549
+ this.components.set(name, next);
550
+ return;
551
+ }
552
+
402
553
  const writeFile = this.fs
403
554
  ? this.fs.writeFile.bind(this.fs)
404
555
  : (await import('fs/promises')).writeFile;
405
- const componentDir = this.getComponentDir(name);
406
556
  const jsFilePath = join(componentDir, `${name}.js`);
407
557
  await writeFile(jsFilePath, javascript || '', 'utf-8');
408
558
 
@@ -444,10 +594,23 @@ export class ComponentService {
444
594
  * ```
445
595
  */
446
596
  async saveComponentCSS(name: string, css: string): Promise<void> {
597
+ const componentDir = this.getComponentDir(name);
598
+
599
+ if (this.writer) {
600
+ // Single-file formats (astro) fold CSS into the `.astro` file. Re-emit from
601
+ // the latest in-memory definition with the new CSS so structure/JS survive.
602
+ const current = this.components.get(name);
603
+ await this.writer.writeCSS(componentDir, name, css || '', current);
604
+ const next: ComponentDefinition = current
605
+ ? { ...current, component: { ...current.component, css: css || '' } }
606
+ : { component: { css: css || '' } };
607
+ this.components.set(name, next);
608
+ return;
609
+ }
610
+
447
611
  const writeFile = this.fs
448
612
  ? this.fs.writeFile.bind(this.fs)
449
613
  : (await import('fs/promises')).writeFile;
450
- const componentDir = this.getComponentDir(name);
451
614
  const cssFilePath = join(componentDir, `${name}.css`);
452
615
  await writeFile(cssFilePath, css || '', 'utf-8');
453
616
 
@@ -486,7 +649,7 @@ export class ComponentService {
486
649
  * ```
487
650
  */
488
651
  getAllFolders(): string[] {
489
- const componentsDir = projectPaths.components();
652
+ const componentsDir = this.componentsBaseDir();
490
653
  if (!existsSync(componentsDir)) {
491
654
  return [];
492
655
  }
@@ -529,7 +692,7 @@ export class ComponentService {
529
692
  throw new Error('Folder name can only contain lowercase letters, numbers, dashes, and underscores');
530
693
  }
531
694
 
532
- const componentsDir = projectPaths.components();
695
+ const componentsDir = this.componentsBaseDir();
533
696
  const folderPath = join(componentsDir, folderName);
534
697
 
535
698
  if (existsSync(folderPath)) {
@@ -569,25 +732,31 @@ export class ComponentService {
569
732
  return;
570
733
  }
571
734
 
572
- const componentsDir = projectPaths.components();
735
+ const componentsDir = this.componentsBaseDir();
573
736
  const sourceDir = currentCategory ? join(componentsDir, currentCategory) : componentsDir;
574
737
  const targetDir = targetCategory ? join(componentsDir, targetCategory) : componentsDir;
575
738
 
576
- // Create target directory if needed
577
- if (targetCategory && !existsSync(targetDir)) {
578
- mkdirSync(targetDir, { recursive: true });
579
- }
739
+ if (this.writer) {
740
+ // Format-aware move (e.g. relocate the single `.astro` file). The writer
741
+ // owns target-dir creation + which files belong to the component.
742
+ await this.writer.moveComponent(sourceDir, targetDir, name);
743
+ } else {
744
+ // Create target directory if needed
745
+ if (targetCategory && !existsSync(targetDir)) {
746
+ mkdirSync(targetDir, { recursive: true });
747
+ }
580
748
 
581
- // Move all component files (.json, .js, .css)
582
- const extensions = ['.json', '.js', '.css'];
583
- const { rename } = await import('fs/promises');
749
+ // Move all component files (.json, .js, .css)
750
+ const extensions = ['.json', '.js', '.css'];
751
+ const { rename } = await import('fs/promises');
584
752
 
585
- for (const ext of extensions) {
586
- const sourcePath = join(sourceDir, `${name}${ext}`);
587
- const targetPath = join(targetDir, `${name}${ext}`);
753
+ for (const ext of extensions) {
754
+ const sourcePath = join(sourceDir, `${name}${ext}`);
755
+ const targetPath = join(targetDir, `${name}${ext}`);
588
756
 
589
- if (existsSync(sourcePath)) {
590
- await rename(sourcePath, targetPath);
757
+ if (existsSync(sourcePath)) {
758
+ await rename(sourcePath, targetPath);
759
+ }
591
760
  }
592
761
  }
593
762
 
@@ -606,5 +775,141 @@ export class ComponentService {
606
775
  }
607
776
  }
608
777
  }
778
+
779
+ /**
780
+ * Rename a component across the project.
781
+ *
782
+ * Renames the component's `.json`/`.js`/`.css` files on disk (respecting its
783
+ * category folder), updates the in-memory caches, rewrites every
784
+ * `{ type: "component", component: "<oldName>" }` reference inside other
785
+ * component structures, and — if `pageService` is provided — also walks every
786
+ * page/template and rewrites references there, saving any pages that changed.
787
+ *
788
+ * Matching is structural (only `node.component` string equality), not
789
+ * text-based, so it never touches HTML tags, CSS classes, prop values, or
790
+ * embedded markup that happen to contain the name as a substring.
791
+ *
792
+ * @throws {Error} If old/new name is missing, names are identical, new name
793
+ * isn't a valid JS identifier, the source component doesn't exist, or the
794
+ * new name is already taken.
795
+ */
796
+ async renameComponent(
797
+ oldName: string,
798
+ newName: string,
799
+ pageService?: PageService,
800
+ ): Promise<{
801
+ oldName: string;
802
+ newName: string;
803
+ category: string | undefined;
804
+ componentRefs: number;
805
+ pageRefs: number;
806
+ }> {
807
+ if (!oldName || typeof oldName !== 'string') {
808
+ throw new Error('oldName is required');
809
+ }
810
+ if (!newName || typeof newName !== 'string') {
811
+ throw new Error('newName is required');
812
+ }
813
+ const trimmedNew = newName.trim();
814
+ if (oldName === trimmedNew) {
815
+ throw new Error('newName must differ from oldName');
816
+ }
817
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedNew)) {
818
+ throw new Error('newName must be a valid JavaScript identifier');
819
+ }
820
+ if (!this.components.has(oldName)) {
821
+ throw new Error(`Component "${oldName}" not found`);
822
+ }
823
+ if (this.components.has(trimmedNew)) {
824
+ throw new Error(`Component "${trimmedNew}" already exists`);
825
+ }
826
+
827
+ const category = this.componentCategories.get(oldName);
828
+ const componentsDir = this.componentsBaseDir();
829
+ const dir = category ? join(componentsDir, category) : componentsDir;
830
+
831
+ if (this.writer) {
832
+ // On-disk collision check (format-aware) + move the component's file(s).
833
+ if (await this.writer.componentExists(dir, trimmedNew)) {
834
+ throw new Error(`Component "${trimmedNew}" already exists in components/${category ?? ''}`);
835
+ }
836
+ await this.writer.moveComponent(dir, dir, oldName, trimmedNew);
837
+ } else {
838
+ // On-disk collision check — defends against a stale cache where the file
839
+ // exists but wasn't loaded (e.g. a parse error masked it).
840
+ for (const ext of ['.json', '.js', '.css'] as const) {
841
+ if (existsSync(join(dir, `${trimmedNew}${ext}`))) {
842
+ throw new Error(`File "${trimmedNew}${ext}" already exists in components/${category ?? ''}`);
843
+ }
844
+ }
845
+
846
+ const { rename } = await import('fs/promises');
847
+ for (const ext of ['.json', '.js', '.css'] as const) {
848
+ const src = join(dir, `${oldName}${ext}`);
849
+ const dst = join(dir, `${trimmedNew}${ext}`);
850
+ if (existsSync(src)) {
851
+ await rename(src, dst);
852
+ }
853
+ }
854
+ }
855
+
856
+ // Move the cache entries to the new key.
857
+ const def = this.components.get(oldName)!;
858
+ this.components.delete(oldName);
859
+ this.componentCategories.delete(oldName);
860
+ this.components.set(trimmedNew, def);
861
+ this.componentCategories.set(trimmedNew, category);
862
+
863
+ // Rewrite references in every OTHER component's structure and persist
864
+ // each one that actually changed. The owning component's own structure
865
+ // shouldn't reference itself, so we don't bother walking it.
866
+ let componentRefs = 0;
867
+ const writeFile = this.fs
868
+ ? this.fs.writeFile.bind(this.fs)
869
+ : (await import('fs/promises')).writeFile;
870
+ for (const [name, otherDef] of this.components.entries()) {
871
+ if (name === trimmedNew) continue;
872
+ const structure = otherDef.component?.structure;
873
+ if (!structure) continue;
874
+ if (rewriteComponentRefs(structure, oldName, trimmedNew)) {
875
+ const otherCategory = this.componentCategories.get(name);
876
+ const otherDir = otherCategory ? join(componentsDir, otherCategory) : componentsDir;
877
+ // Strip the runtime-only javascript field on save, matching saveComponent.
878
+ const persisted = JSON.parse(JSON.stringify(otherDef));
879
+ if (persisted?.component?.javascript !== undefined) {
880
+ delete persisted.component.javascript;
881
+ }
882
+ if (this.writer) {
883
+ await this.writer.writeComponent(otherDir, name, persisted);
884
+ } else {
885
+ await writeFile(join(otherDir, `${name}.json`), JSON.stringify(persisted, null, 2), 'utf-8');
886
+ }
887
+ componentRefs++;
888
+ }
889
+ }
890
+
891
+ // Walk every page (and CMS template — they're loaded under /templates/*)
892
+ // and rewrite refs. Save pages that actually changed.
893
+ let pageRefs = 0;
894
+ if (pageService) {
895
+ const pagePaths = pageService.getAllPagePaths();
896
+ for (const path of pagePaths) {
897
+ const pageData = pageService.getPageData(path);
898
+ if (!pageData || !('root' in pageData) || !pageData.root) continue;
899
+ if (rewriteComponentRefs(pageData.root, oldName, trimmedNew)) {
900
+ await pageService.savePage(path, pageData as JSONPage);
901
+ pageRefs++;
902
+ }
903
+ }
904
+ }
905
+
906
+ return {
907
+ oldName,
908
+ newName: trimmedNew,
909
+ category,
910
+ componentRefs,
911
+ pageRefs,
912
+ };
913
+ }
609
914
  }
610
915
 
@@ -3,8 +3,6 @@
3
3
  * Manages file watching for pages and components
4
4
  */
5
5
 
6
- import { join } from 'path';
7
- import { existsSync } from 'fs';
8
6
  import { FileWatcher, type FileWatchCallbacks } from '../fileWatcher';
9
7
  import { WebSocketManager } from '../websocketManager';
10
8
  import { ComponentService } from './componentService';
@@ -15,9 +13,6 @@ import { colorService } from './ColorService';
15
13
  import { variableService } from './VariableService';
16
14
  import { enumService } from './EnumService';
17
15
  import { configService } from './configService';
18
- import { loadJSONFile, mapPageNameToPath } from '../jsonLoader';
19
- import { buildLineMap } from '../utils/jsonLineMapper';
20
- import { projectPaths } from '../projectContext';
21
16
 
22
17
  export class FileWatcherService {
23
18
  private fileWatcher: FileWatcher | null = null;
@@ -43,26 +38,11 @@ export class FileWatcherService {
43
38
  this.wsManager.broadcastUpdate('all');
44
39
  },
45
40
  onPageChange: async (pagePath: string) => {
46
- const pageName = pagePath === '/' ? 'index' : pagePath.substring(1);
47
- // Route template paths to the root templates/ directory
48
- const filePath = pageName.startsWith('templates/')
49
- ? join(projectPaths.templates(), `${pageName.substring('templates/'.length)}.json`)
50
- : join(projectPaths.pages(), `${pageName}.json`);
51
- const content = await loadJSONFile(filePath);
52
- if (content) {
53
- const lineMap = buildLineMap(content);
54
- this.pageCache.set(pagePath, content, lineMap);
41
+ // Provider-based reload so .astro and JSON projects both live-reload. The
42
+ // provider re-reads (and, for astro, re-parses) the file; a transient
43
+ // mid-write returns false and is left for the next watcher event.
44
+ if (await this.pageService.reloadPageFromDisk(pagePath)) {
55
45
  this.wsManager.broadcastUpdate(pagePath);
56
- } else {
57
- // Only delete from cache if file truly doesn't exist
58
- // If file exists but couldn't be read (being written), skip this update
59
- // The file watcher will trigger again when write completes
60
- if (!existsSync(filePath)) {
61
- this.pageCache.delete(pagePath);
62
- this.wsManager.broadcastUpdate(pagePath);
63
- }
64
- // If file exists but read failed, don't delete from cache or broadcast
65
- // This prevents race condition where partial write triggers premature cache deletion
66
46
  }
67
47
 
68
48
  // Refresh CMS schemas when template files change
@@ -255,4 +255,27 @@ describe('PageService', () => {
255
255
  expect(mappings[0].slugs).toEqual({ _default: '' });
256
256
  });
257
257
  });
258
+
259
+ describe('renamePage validation', () => {
260
+ test('no-op when oldPath === newPath', async () => {
261
+ await expect(pageService.renamePage('/about', '/about')).resolves.toBeUndefined();
262
+ });
263
+
264
+ test('rejects rename to "/" (index)', async () => {
265
+ await expect(pageService.renamePage('/about', '/')).rejects.toThrow(/index/i);
266
+ });
267
+
268
+ test('rejects newPath without leading slash', async () => {
269
+ await expect(pageService.renamePage('/about', 'about-us')).rejects.toThrow(/start with/);
270
+ });
271
+
272
+ test('rejects cross-folder rename (use movePage)', async () => {
273
+ await expect(pageService.renamePage('/about', '/blog/about')).rejects.toThrow(/movePage/);
274
+ });
275
+
276
+ test('rejects invalid slug characters', async () => {
277
+ await expect(pageService.renamePage('/about', '/About Us')).rejects.toThrow(/lowercase/);
278
+ await expect(pageService.renamePage('/about', '/about!')).rejects.toThrow(/lowercase/);
279
+ });
280
+ });
258
281
  });