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.
- package/build-astro.ts +183 -13
- package/build-next.ts +1361 -0
- package/build-static.ts +7 -5
- package/dist/bin/cli.js +2 -2
- package/dist/build-static.js +6 -6
- package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
- package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
- package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
- package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
- package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
- package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
- package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
- package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
- package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
- package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
- package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
- package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
- package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
- package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
- package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
- package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
- package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
- package/dist/chunks/chunk-X754AHS5.js.map +7 -0
- package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
- package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
- package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +354 -59
- package/dist/lib/client/index.js.map +4 -4
- package/dist/lib/server/index.js +1458 -190
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +202 -34
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/test-utils/index.js +1 -1
- package/entries/client-router.tsx +5 -165
- package/lib/client/ErrorBoundary.test.tsx +27 -25
- package/lib/client/ErrorBoundary.tsx +34 -19
- package/lib/client/core/ComponentBuilder.ts +19 -2
- package/lib/client/core/builders/embedBuilder.ts +8 -4
- package/lib/client/core/builders/listBuilder.ts +23 -4
- package/lib/client/fontFamiliesService.test.ts +76 -0
- package/lib/client/fontFamiliesService.ts +69 -0
- package/lib/client/hmrCssReload.ts +160 -0
- package/lib/client/hooks/useColorVariables.ts +2 -0
- package/lib/client/index.ts +4 -0
- package/lib/client/meno-filter/ui.ts +2 -0
- package/lib/client/routing/RouteLoader.test.ts +2 -2
- package/lib/client/routing/RouteLoader.ts +8 -2
- package/lib/client/routing/Router.tsx +81 -15
- package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
- package/lib/client/scripts/ScriptExecutor.ts +56 -2
- package/lib/client/styles/StyleInjector.ts +20 -5
- package/lib/client/styles/UtilityClassCollector.ts +7 -1
- package/lib/client/styles/cspNonce.test.ts +67 -0
- package/lib/client/styles/cspNonce.ts +63 -0
- package/lib/client/templateEngine.test.ts +80 -0
- package/lib/client/templateEngine.ts +5 -0
- package/lib/server/astro/cmsPageEmitter.ts +35 -5
- package/lib/server/astro/componentEmitter.ts +61 -5
- package/lib/server/astro/nodeToAstro.ts +149 -11
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
- package/lib/server/createServer.ts +11 -0
- package/lib/server/draftPageStore.ts +49 -0
- package/lib/server/fileWatcher.ts +62 -2
- package/lib/server/index.ts +13 -1
- package/lib/server/providers/fileSystemPageProvider.ts +8 -0
- package/lib/server/routes/api/components.ts +9 -4
- package/lib/server/routes/api/core-routes.ts +2 -2
- package/lib/server/routes/api/pages.ts +14 -22
- package/lib/server/routes/api/shared.ts +56 -0
- package/lib/server/routes/index.ts +90 -0
- package/lib/server/routes/pages.ts +13 -6
- package/lib/server/services/componentService.test.ts +199 -2
- package/lib/server/services/componentService.ts +354 -49
- package/lib/server/services/fileWatcherService.ts +4 -24
- package/lib/server/services/pageService.test.ts +23 -0
- package/lib/server/services/pageService.ts +124 -6
- package/lib/server/ssr/attributeBuilder.ts +8 -2
- package/lib/server/ssr/buildErrorOverlay.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +21 -2
- package/lib/server/ssr/errorOverlay.ts +38 -11
- package/lib/server/ssr/htmlGenerator.test.ts +53 -13
- package/lib/server/ssr/htmlGenerator.ts +71 -27
- package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
- package/lib/server/ssr/ssrRenderer.test.ts +67 -0
- package/lib/server/ssr/ssrRenderer.ts +94 -9
- package/lib/server/websocketManager.ts +0 -1
- package/lib/shared/componentRefs.ts +45 -0
- package/lib/shared/constants.ts +8 -0
- package/lib/shared/cssGeneration.ts +2 -0
- package/lib/shared/cssProperties.ts +184 -0
- package/lib/shared/expressionEvaluator.ts +54 -0
- package/lib/shared/fontCss.ts +101 -0
- package/lib/shared/fontLoader.ts +8 -86
- package/lib/shared/friendlyError.test.ts +87 -0
- package/lib/shared/friendlyError.ts +121 -0
- package/lib/shared/hrefRefs.test.ts +130 -0
- package/lib/shared/hrefRefs.ts +100 -0
- package/lib/shared/index.ts +52 -0
- package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
- package/lib/shared/inlineSvgStyleRules.ts +134 -0
- package/lib/shared/interfaces/contentProvider.ts +13 -0
- package/lib/shared/itemTemplateUtils.test.ts +14 -0
- package/lib/shared/itemTemplateUtils.ts +4 -1
- package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
- package/lib/shared/slugTranslator.test.ts +24 -0
- package/lib/shared/slugTranslator.ts +24 -0
- package/lib/shared/styleNodeUtils.ts +4 -1
- package/lib/shared/tree/PathBuilder.test.ts +128 -1
- package/lib/shared/tree/PathBuilder.ts +83 -31
- package/lib/shared/types/comment.ts +99 -0
- package/lib/shared/types/index.ts +12 -0
- package/lib/shared/types/rendering.ts +8 -0
- package/lib/shared/utilityClassConfig.ts +4 -2
- package/lib/shared/utilityClassMapper.test.ts +24 -0
- package/lib/shared/validation/commentValidators.ts +69 -0
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/viewportUnits.integration.test.ts +42 -0
- package/lib/shared/viewportUnits.test.ts +103 -0
- package/lib/shared/viewportUnits.ts +63 -0
- package/lib/test-utils/dom-setup.ts +6 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
- package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
- package/dist/chunks/chunk-A725KYFK.js.map +0 -7
- package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
- package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
- package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
- package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
- package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
- package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
- package/dist/chunks/chunk-LPVETICS.js.map +0 -7
- /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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
166
|
+
nextWarnings = result.warnings;
|
|
96
167
|
for (const error of result.errors) {
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
290
|
-
const cat =
|
|
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
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
749
|
+
// Move all component files (.json, .js, .css)
|
|
750
|
+
const extensions = ['.json', '.js', '.css'];
|
|
751
|
+
const { rename } = await import('fs/promises');
|
|
584
752
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
753
|
+
for (const ext of extensions) {
|
|
754
|
+
const sourcePath = join(sourceDir, `${name}${ext}`);
|
|
755
|
+
const targetPath = join(targetDir, `${name}${ext}`);
|
|
588
756
|
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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
|
});
|