meno-core 1.0.49 → 1.0.50
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 +6 -2
- package/dist/build-static.js +5 -5
- package/dist/chunks/{chunk-JER5NQVM.js → chunk-56EUSC6D.js} +4 -4
- package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
- package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
- package/dist/chunks/{chunk-EQYDSPBB.js → chunk-CVLFID6V.js} +64 -20
- package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
- package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
- package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
- package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
- package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
- package/dist/chunks/{chunk-KPU2XHOS.js → chunk-PQ2HRXDR.js} +1 -1
- package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
- package/dist/chunks/{chunk-CHD5UCFF.js → chunk-YWJJD5D6.js} +116 -32
- package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
- package/dist/chunks/{configService-CCA6AIDI.js → configService-VOY2MY2K.js} +2 -2
- package/dist/entries/server-router.js +5 -5
- package/dist/lib/client/index.js +41 -15
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +11 -9
- package/dist/lib/server/index.js.map +2 -2
- package/dist/lib/shared/index.js +2 -2
- package/lib/client/core/ComponentBuilder.test.ts +34 -0
- package/lib/client/core/ComponentBuilder.ts +25 -3
- package/lib/client/core/builders/embedBuilder.ts +13 -5
- package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
- package/lib/client/core/builders/localeListBuilder.ts +13 -5
- package/lib/client/templateEngine.ts +24 -0
- package/lib/server/fileWatcher.test.ts +134 -0
- package/lib/server/fileWatcher.ts +100 -32
- package/lib/server/jsonLoader.ts +1 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
- package/lib/server/services/configService.ts +1 -0
- package/lib/server/services/fileWatcherService.ts +17 -0
- package/lib/server/ssr/htmlGenerator.ts +11 -3
- package/lib/server/ssr/ssrRenderer.test.ts +258 -0
- package/lib/server/ssr/ssrRenderer.ts +46 -5
- package/lib/server/webflow/buildWebflow.ts +1 -1
- package/lib/server/websocketManager.test.ts +61 -6
- package/lib/server/websocketManager.ts +25 -1
- package/lib/shared/cssProperties.test.ts +28 -0
- package/lib/shared/cssProperties.ts +27 -1
- package/lib/shared/types/api.ts +10 -1
- package/lib/shared/types/cms.ts +18 -9
- package/lib/shared/validation/schemas.test.ts +93 -0
- package/lib/shared/validation/schemas.ts +56 -15
- package/package.json +1 -1
- package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
- package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
- package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
- package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
- /package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-56EUSC6D.js.map} +0 -0
- /package/dist/chunks/{configService-CCA6AIDI.js.map → configService-VOY2MY2K.js.map} +0 -0
package/dist/lib/shared/index.js
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
logNetworkError,
|
|
46
46
|
logRuntimeError,
|
|
47
47
|
setErrorHandler
|
|
48
|
-
} from "../../chunks/chunk-
|
|
48
|
+
} from "../../chunks/chunk-LPVETICS.js";
|
|
49
49
|
import {
|
|
50
50
|
BaseComponentRegistry,
|
|
51
51
|
BaseNodeTypeRegistry,
|
|
@@ -218,7 +218,7 @@ import {
|
|
|
218
218
|
validatePath,
|
|
219
219
|
validatePropDefinition,
|
|
220
220
|
validateStructuredComponentDefinition
|
|
221
|
-
} from "../../chunks/chunk-
|
|
221
|
+
} from "../../chunks/chunk-7NIC4I3V.js";
|
|
222
222
|
import {
|
|
223
223
|
DEFAULT_BREAKPOINTS,
|
|
224
224
|
DEFAULT_FLUID_RANGE,
|
|
@@ -87,6 +87,40 @@ describe("ComponentBuilder", () => {
|
|
|
87
87
|
expect(result).not.toBeNull();
|
|
88
88
|
expect(typeof result).toBe("object");
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
test("resolves an _i18n value object as the node to the default locale string", () => {
|
|
92
|
+
// Mirrors the SSR behavior: authors can pass localized strings anywhere
|
|
93
|
+
// `children` is accepted. The client resolves _i18n at the buildComponent
|
|
94
|
+
// entry point so all downstream code sees a plain string.
|
|
95
|
+
const i18nConfig = {
|
|
96
|
+
defaultLocale: "en",
|
|
97
|
+
locales: [
|
|
98
|
+
{ code: "en", name: "EN", nativeName: "English", langTag: "en-US" },
|
|
99
|
+
{ code: "pl", name: "PL", nativeName: "Polski", langTag: "pl-PL" },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
const result = builder.buildComponent({
|
|
103
|
+
node: { _i18n: true, en: "Hello", pl: "Cześć" } as any,
|
|
104
|
+
i18nConfig,
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe("Hello");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolves an _i18n value object to the active locale", () => {
|
|
110
|
+
const i18nConfig = {
|
|
111
|
+
defaultLocale: "en",
|
|
112
|
+
locales: [
|
|
113
|
+
{ code: "en", name: "EN", nativeName: "English", langTag: "en-US" },
|
|
114
|
+
{ code: "pl", name: "PL", nativeName: "Polski", langTag: "pl-PL" },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
const result = builder.buildComponent({
|
|
118
|
+
node: { _i18n: true, en: "Hello", pl: "Cześć" } as any,
|
|
119
|
+
i18nConfig,
|
|
120
|
+
locale: "pl",
|
|
121
|
+
});
|
|
122
|
+
expect(result).toBe("Cześć");
|
|
123
|
+
});
|
|
90
124
|
});
|
|
91
125
|
|
|
92
126
|
describe("buildComponent - Component Instances", () => {
|
|
@@ -24,7 +24,7 @@ import type { Path } from "../../shared/pathArrayUtils";
|
|
|
24
24
|
import type { I18nConfig } from "../../shared/types/components";
|
|
25
25
|
import type { ItemContext, TemplateContext } from "../../shared/types/cms";
|
|
26
26
|
import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, type ValueResolver } from "../../shared/itemTemplateUtils";
|
|
27
|
-
import { DEFAULT_I18N_CONFIG, resolveI18nValue } from "../../shared/i18n";
|
|
27
|
+
import { DEFAULT_I18N_CONFIG, isI18nValue, resolveI18nValue } from "../../shared/i18n";
|
|
28
28
|
import { getChildPath, pathToString } from "../../shared/pathArrayUtils";
|
|
29
29
|
import { responsiveStylesToClasses } from "../../shared/utilityClassMapper";
|
|
30
30
|
import { getCachedResponsiveScalesConfig } from "../responsiveStyleResolver";
|
|
@@ -318,6 +318,18 @@ export class ComponentBuilder {
|
|
|
318
318
|
|
|
319
319
|
if (!node) return null;
|
|
320
320
|
|
|
321
|
+
// Resolve `_i18n` value objects to a single string before node-shape
|
|
322
|
+
// dispatch. Authors can write a localized string anywhere `children` is
|
|
323
|
+
// accepted — on raw `type: "node"` elements as well as component props.
|
|
324
|
+
// Precedence rule: an object with both `_i18n: true` and `type`/`tag`
|
|
325
|
+
// resolves as i18n.
|
|
326
|
+
if (isI18nValue(node)) {
|
|
327
|
+
const i18nResolveConfig = i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
328
|
+
const i18nEffectiveLocale = locale || i18nResolveConfig.defaultLocale;
|
|
329
|
+
const resolved = resolveI18nValue(node, i18nEffectiveLocale, i18nResolveConfig);
|
|
330
|
+
return this.buildComponent({ ...options, node: resolved as unknown as BuildComponentOptions['node'] });
|
|
331
|
+
}
|
|
332
|
+
|
|
321
333
|
// Build context for specialized builders (needed for if condition evaluation)
|
|
322
334
|
const ctx: BuilderContext = {
|
|
323
335
|
key, elementPath, parentComponentName, viewportWidth, componentContext, componentRootPath,
|
|
@@ -731,10 +743,20 @@ export class ComponentBuilder {
|
|
|
731
743
|
}
|
|
732
744
|
|
|
733
745
|
/**
|
|
734
|
-
* Extract and merge attributes from node
|
|
746
|
+
* Extract and merge attributes from node. Resolves any `_i18n` value
|
|
747
|
+
* objects on attribute values to the active locale's string before
|
|
748
|
+
* downstream template / CMS processing, mirroring the SSR path.
|
|
735
749
|
*/
|
|
736
750
|
private mergeAttributes(props: Record<string, unknown>, node: ComponentNode, ctx: BuilderContext): Record<string, unknown> {
|
|
737
|
-
let extractedAttributes = extractAttributesFromNode(node)
|
|
751
|
+
let extractedAttributes = extractAttributesFromNode(node) as Record<string, unknown>;
|
|
752
|
+
const attrI18nConfig = ctx.i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
753
|
+
const attrEffectiveLocale = ctx.locale || attrI18nConfig.defaultLocale;
|
|
754
|
+
for (const [key, value] of Object.entries(extractedAttributes)) {
|
|
755
|
+
if (isI18nValue(value)) {
|
|
756
|
+
extractedAttributes = { ...extractedAttributes };
|
|
757
|
+
extractedAttributes[key] = resolveI18nValue(value, attrEffectiveLocale, attrI18nConfig);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
738
760
|
const originalAttributes = { ...extractedAttributes };
|
|
739
761
|
|
|
740
762
|
// Process CMS templates in attributes (e.g., src="{{cms.image}}", href="{{cms.link}}")
|
|
@@ -199,11 +199,19 @@ export function buildEmbed(
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// Add extracted attributes className if present
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
// Add extracted attributes class/className if present
|
|
203
|
+
// JSON uses "class"; React uses "className". Without merging, the raw "class"
|
|
204
|
+
// would be spread onto the React element below and override the className that
|
|
205
|
+
// holds the utility classes derived from node.style.
|
|
206
|
+
if ('class' in extractedAttributes || 'className' in extractedAttributes) {
|
|
207
|
+
const attrClass = ((extractedAttributes as Record<string, unknown>).class
|
|
208
|
+
?? (extractedAttributes as Record<string, unknown>).className
|
|
209
|
+
?? '') as string;
|
|
210
|
+
if (attrClass) {
|
|
211
|
+
classNames.push(...attrClass.split(/\s+/));
|
|
212
|
+
}
|
|
213
|
+
delete (extractedAttributes as Record<string, unknown>).class;
|
|
214
|
+
delete (extractedAttributes as Record<string, unknown>).className;
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
// Set final className
|
|
@@ -174,11 +174,19 @@ export function buildLinkNode(
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// Add extracted attributes className if present
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
// Add extracted attributes class/className if present
|
|
178
|
+
// JSON uses "class"; React uses "className". Without merging, the raw "class"
|
|
179
|
+
// would be spread onto the React element below and override the className that
|
|
180
|
+
// holds the utility classes derived from node.style.
|
|
181
|
+
if ('class' in extractedAttributes || 'className' in extractedAttributes) {
|
|
182
|
+
const attrClass = ((extractedAttributes as Record<string, unknown>).class
|
|
183
|
+
?? (extractedAttributes as Record<string, unknown>).className
|
|
184
|
+
?? '') as string;
|
|
185
|
+
if (attrClass) {
|
|
186
|
+
classNames.push(...attrClass.split(/\s+/));
|
|
187
|
+
}
|
|
188
|
+
delete (extractedAttributes as Record<string, unknown>).class;
|
|
189
|
+
delete (extractedAttributes as Record<string, unknown>).className;
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
// Add is-current class when link href matches current page path
|
|
@@ -146,11 +146,19 @@ export function buildLocaleList(
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Add extracted attributes className if present
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
// Add extracted attributes class/className if present
|
|
150
|
+
// JSON uses "class"; React uses "className". Without merging, the raw "class"
|
|
151
|
+
// would be spread onto the React element below and override the className that
|
|
152
|
+
// holds the utility classes derived from node.style.
|
|
153
|
+
if ('class' in extractedAttributes || 'className' in extractedAttributes) {
|
|
154
|
+
const attrClass = ((extractedAttributes as Record<string, unknown>).class
|
|
155
|
+
?? (extractedAttributes as Record<string, unknown>).className
|
|
156
|
+
?? '') as string;
|
|
157
|
+
if (attrClass) {
|
|
158
|
+
classNames.push(...attrClass.split(/\s+/));
|
|
159
|
+
}
|
|
160
|
+
delete (extractedAttributes as Record<string, unknown>).class;
|
|
161
|
+
delete (extractedAttributes as Record<string, unknown>).className;
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
// Set final className
|
|
@@ -797,6 +797,30 @@ export function processStructure(
|
|
|
797
797
|
// Special handling for interactiveStyles - preserve as-is without mangling
|
|
798
798
|
// Interactive styles contain StyleMapping objects that shouldn't be converted to nodes
|
|
799
799
|
(processed as unknown as Record<string, unknown>).interactiveStyles = value;
|
|
800
|
+
} else if (key === 'source' && preservedType === NODE_TYPE.LIST) {
|
|
801
|
+
// Pre-resolve a bare prop-name source against the current component's
|
|
802
|
+
// props. Without this, a list with `sourceType: "prop"` placed inside
|
|
803
|
+
// another component's slot would fail to resolve at SSR time, because
|
|
804
|
+
// by then `ctx.componentResolvedProps` belongs to the slot host (not
|
|
805
|
+
// the component whose prop this references). The {{template}} form is
|
|
806
|
+
// already handled by the default-recursion branch below.
|
|
807
|
+
const structureRec = structure as Record<string, unknown>;
|
|
808
|
+
const sourceType = (structureRec.sourceType as string | undefined) ?? 'prop';
|
|
809
|
+
if (
|
|
810
|
+
sourceType === 'prop' &&
|
|
811
|
+
typeof value === 'string' &&
|
|
812
|
+
value !== '' &&
|
|
813
|
+
!hasTemplates(value) &&
|
|
814
|
+
context.props &&
|
|
815
|
+
Array.isArray((context.props as Record<string, unknown>)[value])
|
|
816
|
+
) {
|
|
817
|
+
(processed as unknown as Record<string, unknown>).source = (context.props as Record<string, unknown>)[value];
|
|
818
|
+
} else {
|
|
819
|
+
const processedValue = processStructure(value as ComponentNode | ComponentNode[] | string | number | null | undefined, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
|
|
820
|
+
if (processedValue !== null && processedValue !== undefined) {
|
|
821
|
+
(processed as unknown as Record<string, unknown>).source = processedValue;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
800
824
|
} else if (key !== 'type' && key !== 'children' && key !== 'style' && key !== 'props') {
|
|
801
825
|
const processedValue = processStructure(value as ComponentNode | ComponentNode[] | string | number | null | undefined, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
|
|
802
826
|
// Only assign if it's a valid value
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { FileWatcher } from './fileWatcher';
|
|
6
|
+
import { setProjectRoot, getProjectRoot } from './projectContext';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression test for the "blank project" case: when `templates/` (or `cms/`)
|
|
10
|
+
* doesn't exist when the dev server starts, the watcher used to silently
|
|
11
|
+
* early-out and never fire onPageChange / onCMSChange even after the
|
|
12
|
+
* directory was created by an external tool. The deferred-attach path in
|
|
13
|
+
* fileWatcher.ts fixes this — the test asserts that callbacks now fire after
|
|
14
|
+
* a target directory is created post-startup.
|
|
15
|
+
*
|
|
16
|
+
* fs.watch on different OSes coalesces events differently, so the test waits
|
|
17
|
+
* generously and only asserts that the callback fires at least once.
|
|
18
|
+
*/
|
|
19
|
+
async function settle(ms = 300): Promise<void> {
|
|
20
|
+
await new Promise(r => setTimeout(r, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('FileWatcher deferred attach', () => {
|
|
24
|
+
let projectRoot: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
projectRoot = join(tmpdir(), `meno-fw-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('watchTemplates fires onPageChange after templates/ is created post-startup', async () => {
|
|
36
|
+
const seen: string[] = [];
|
|
37
|
+
const watcher = new FileWatcher({
|
|
38
|
+
onPageChange: async (p) => { seen.push(p); },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// templates/ does NOT exist yet — pre-fix this returned silently.
|
|
42
|
+
const templatesDir = join(projectRoot, 'templates');
|
|
43
|
+
watcher.watchTemplates(templatesDir);
|
|
44
|
+
|
|
45
|
+
// Simulate the external write that triggers the bug: create templates/
|
|
46
|
+
// and drop a JSON file into it.
|
|
47
|
+
await settle(50);
|
|
48
|
+
mkdirSync(templatesDir);
|
|
49
|
+
await settle(100);
|
|
50
|
+
writeFileSync(join(templatesDir, 'blog.json'), '{}');
|
|
51
|
+
await settle(300);
|
|
52
|
+
|
|
53
|
+
watcher.stopAll();
|
|
54
|
+
expect(seen.length).toBeGreaterThanOrEqual(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('watchCMS fires onCMSChange after cms/ is created post-startup', async () => {
|
|
58
|
+
const seen: string[] = [];
|
|
59
|
+
const watcher = new FileWatcher({
|
|
60
|
+
onCMSChange: async (collection) => { seen.push(collection); },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const cmsDir = join(projectRoot, 'cms');
|
|
64
|
+
watcher.watchCMS(cmsDir);
|
|
65
|
+
|
|
66
|
+
await settle(50);
|
|
67
|
+
mkdirSync(join(cmsDir, 'blog'), { recursive: true });
|
|
68
|
+
await settle(100);
|
|
69
|
+
writeFileSync(join(cmsDir, 'blog', 'post.json'), '{}');
|
|
70
|
+
await settle(300);
|
|
71
|
+
|
|
72
|
+
watcher.stopAll();
|
|
73
|
+
expect(seen.length).toBeGreaterThanOrEqual(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns silently when parent dir also does not exist', () => {
|
|
77
|
+
const watcher = new FileWatcher({});
|
|
78
|
+
expect(() => watcher.watchTemplates(join(projectRoot, 'nonexistent-parent', 'templates'))).not.toThrow();
|
|
79
|
+
watcher.stopAll();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('FileWatcher project.config.json', () => {
|
|
84
|
+
let projectRoot: string;
|
|
85
|
+
let prevRoot: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
prevRoot = getProjectRoot();
|
|
89
|
+
projectRoot = join(tmpdir(), `meno-fw-cfg-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
90
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
91
|
+
// watchProjectConfig reads getProjectRoot() — point it at the temp dir.
|
|
92
|
+
setProjectRoot(projectRoot);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
setProjectRoot(prevRoot);
|
|
97
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('fires onProjectConfigChange when project.config.json is written', async () => {
|
|
101
|
+
let fired = 0;
|
|
102
|
+
const watcher = new FileWatcher({
|
|
103
|
+
onProjectConfigChange: async () => { fired++; },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
watcher.watchProjectConfig();
|
|
107
|
+
await settle(50);
|
|
108
|
+
|
|
109
|
+
// Simulate AI / external tool editing project.config.json to add a locale.
|
|
110
|
+
writeFileSync(join(projectRoot, 'project.config.json'), JSON.stringify({
|
|
111
|
+
i18n: { defaultLocale: 'en', locales: [{ code: 'en' }, { code: 'fr' }] }
|
|
112
|
+
}));
|
|
113
|
+
await settle(300);
|
|
114
|
+
|
|
115
|
+
watcher.stopAll();
|
|
116
|
+
expect(fired).toBeGreaterThanOrEqual(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('ignores writes to unrelated files at the project root', async () => {
|
|
120
|
+
let fired = 0;
|
|
121
|
+
const watcher = new FileWatcher({
|
|
122
|
+
onProjectConfigChange: async () => { fired++; },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
watcher.watchProjectConfig();
|
|
126
|
+
await settle(50);
|
|
127
|
+
|
|
128
|
+
writeFileSync(join(projectRoot, 'README.md'), 'hello');
|
|
129
|
+
await settle(200);
|
|
130
|
+
|
|
131
|
+
watcher.stopAll();
|
|
132
|
+
expect(fired).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -4,11 +4,49 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { watch, existsSync } from 'fs';
|
|
7
|
-
import { dirname } from 'path';
|
|
7
|
+
import { basename, dirname } from 'path';
|
|
8
8
|
import type { FSWatcher } from 'fs';
|
|
9
9
|
import { mapPageNameToPath } from './jsonLoader';
|
|
10
10
|
import { projectPaths, getProjectRoot } from './projectContext';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Attach a recursive watcher to `dirPath`. If the directory doesn't exist
|
|
14
|
+
* yet, watch the parent directory and defer the attach until the target is
|
|
15
|
+
* created (e.g. a blank project that doesn't ship with `templates/` or `cms/`
|
|
16
|
+
* gets one added later by an external tool).
|
|
17
|
+
*
|
|
18
|
+
* Returns the currently active FSWatcher (which may be the deferred parent
|
|
19
|
+
* watcher until the target appears, then the proper recursive watcher).
|
|
20
|
+
*/
|
|
21
|
+
function attachWhenDirExists(
|
|
22
|
+
dirPath: string,
|
|
23
|
+
attach: () => FSWatcher | null,
|
|
24
|
+
setWatcher: (w: FSWatcher | null) => void
|
|
25
|
+
): void {
|
|
26
|
+
if (existsSync(dirPath)) {
|
|
27
|
+
setWatcher(attach());
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parentDir = dirname(dirPath);
|
|
32
|
+
const targetName = basename(dirPath);
|
|
33
|
+
if (!existsSync(parentDir)) {
|
|
34
|
+
// Parent doesn't exist either — give up; nothing reasonable to watch.
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let parentWatcher: FSWatcher | null = null;
|
|
39
|
+
parentWatcher = watch(parentDir, (_event, filename) => {
|
|
40
|
+
if (filename !== targetName) return;
|
|
41
|
+
if (!existsSync(dirPath)) return;
|
|
42
|
+
// Target exists now — tear down the parent watcher and attach the real one.
|
|
43
|
+
parentWatcher?.close();
|
|
44
|
+
parentWatcher = null;
|
|
45
|
+
setWatcher(attach());
|
|
46
|
+
});
|
|
47
|
+
setWatcher(parentWatcher);
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
export interface FileWatchCallbacks {
|
|
13
51
|
onComponentChange?: () => Promise<void>;
|
|
14
52
|
onPageChange?: (pagePath: string) => Promise<void>;
|
|
@@ -18,6 +56,7 @@ export interface FileWatchCallbacks {
|
|
|
18
56
|
onCMSChange?: (collection: string) => Promise<void>;
|
|
19
57
|
onImageAdded?: (filename: string) => Promise<void>;
|
|
20
58
|
onLibraryChange?: () => Promise<void>;
|
|
59
|
+
onProjectConfigChange?: () => Promise<void>;
|
|
21
60
|
}
|
|
22
61
|
|
|
23
62
|
export class FileWatcher {
|
|
@@ -30,6 +69,7 @@ export class FileWatcher {
|
|
|
30
69
|
private cmsWatcher: FSWatcher | null = null;
|
|
31
70
|
private imagesWatcher: FSWatcher | null = null;
|
|
32
71
|
private librariesWatcher: FSWatcher | null = null;
|
|
72
|
+
private projectConfigWatcher: FSWatcher | null = null;
|
|
33
73
|
|
|
34
74
|
constructor(private callbacks: FileWatchCallbacks) {}
|
|
35
75
|
|
|
@@ -85,29 +125,31 @@ export class FileWatcher {
|
|
|
85
125
|
}
|
|
86
126
|
|
|
87
127
|
/**
|
|
88
|
-
* Start watching root templates directory for CMS template changes
|
|
128
|
+
* Start watching root templates directory for CMS template changes.
|
|
129
|
+
* Falls back to a deferred-attach watcher on the project root when
|
|
130
|
+
* `templates/` doesn't exist yet (blank projects, projects that have never
|
|
131
|
+
* had a CMS collection).
|
|
89
132
|
*/
|
|
90
133
|
watchTemplates(dirPath: string = projectPaths.templates()): void {
|
|
91
|
-
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.templatesWatcher = watch(
|
|
134
|
+
attachWhenDirExists(
|
|
96
135
|
dirPath,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
() => watch(
|
|
137
|
+
dirPath,
|
|
138
|
+
{ recursive: true },
|
|
139
|
+
async (event, filename) => {
|
|
140
|
+
if (filename && filename.endsWith('.json')) {
|
|
141
|
+
// Prefix with templates/ so the path is /templates/...
|
|
142
|
+
const pageName = `templates/${filename.replace('.json', '')}`;
|
|
143
|
+
const pagePath = mapPageNameToPath(pageName);
|
|
103
144
|
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
if (this.callbacks.onPageChange) {
|
|
146
|
+
await this.callbacks.onPageChange(pagePath);
|
|
147
|
+
}
|
|
106
148
|
}
|
|
107
149
|
}
|
|
108
|
-
|
|
150
|
+
),
|
|
151
|
+
(w) => { this.templatesWatcher = w; }
|
|
109
152
|
);
|
|
110
|
-
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
/**
|
|
@@ -164,26 +206,27 @@ export class FileWatcher {
|
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
/**
|
|
167
|
-
* Start watching CMS directory
|
|
168
|
-
* Watches for changes in CMS content files (cms/{collection}/*.json)
|
|
209
|
+
* Start watching CMS directory.
|
|
210
|
+
* Watches for changes in CMS content files (cms/{collection}/*.json).
|
|
211
|
+
* Falls back to a deferred-attach watcher when `cms/` doesn't exist yet.
|
|
169
212
|
*/
|
|
170
213
|
watchCMS(dirPath: string = projectPaths.cms()): void {
|
|
171
|
-
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
this.cmsWatcher = watch(
|
|
214
|
+
attachWhenDirExists(
|
|
176
215
|
dirPath,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
216
|
+
() => watch(
|
|
217
|
+
dirPath,
|
|
218
|
+
{ recursive: true },
|
|
219
|
+
async (event, filename) => {
|
|
220
|
+
if (filename && filename.endsWith('.json')) {
|
|
221
|
+
// Extract collection from path: "blog/my-post.json" -> "blog"
|
|
222
|
+
const collection = filename.split('/')[0];
|
|
223
|
+
if (this.callbacks.onCMSChange) {
|
|
224
|
+
await this.callbacks.onCMSChange(collection);
|
|
225
|
+
}
|
|
184
226
|
}
|
|
185
227
|
}
|
|
186
|
-
|
|
228
|
+
),
|
|
229
|
+
(w) => { this.cmsWatcher = w; }
|
|
187
230
|
);
|
|
188
231
|
}
|
|
189
232
|
|
|
@@ -206,6 +249,25 @@ export class FileWatcher {
|
|
|
206
249
|
);
|
|
207
250
|
}
|
|
208
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Start watching project.config.json for changes.
|
|
254
|
+
* Picks up edits to i18n locales, breakpoints, libraries, icons, etc. so the
|
|
255
|
+
* studio reflects external writes (e.g. an AI tool adding a new locale) without
|
|
256
|
+
* a dev-server restart.
|
|
257
|
+
*/
|
|
258
|
+
watchProjectConfig(): void {
|
|
259
|
+
const dirPath = getProjectRoot();
|
|
260
|
+
this.projectConfigWatcher = watch(
|
|
261
|
+
dirPath,
|
|
262
|
+
{ recursive: false },
|
|
263
|
+
async (_event, filename) => {
|
|
264
|
+
if (filename === 'project.config.json' && this.callbacks.onProjectConfigChange) {
|
|
265
|
+
await this.callbacks.onProjectConfigChange();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
209
271
|
/**
|
|
210
272
|
* Start watching libraries directory for CSS/JS changes
|
|
211
273
|
*/
|
|
@@ -240,6 +302,7 @@ export class FileWatcher {
|
|
|
240
302
|
this.watchCMS();
|
|
241
303
|
this.watchImages();
|
|
242
304
|
this.watchLibraries();
|
|
305
|
+
this.watchProjectConfig();
|
|
243
306
|
}
|
|
244
307
|
|
|
245
308
|
/**
|
|
@@ -290,6 +353,11 @@ export class FileWatcher {
|
|
|
290
353
|
this.librariesWatcher.close();
|
|
291
354
|
this.librariesWatcher = null;
|
|
292
355
|
}
|
|
356
|
+
|
|
357
|
+
if (this.projectConfigWatcher) {
|
|
358
|
+
this.projectConfigWatcher.close();
|
|
359
|
+
this.projectConfigWatcher = null;
|
|
360
|
+
}
|
|
293
361
|
}
|
|
294
362
|
|
|
295
363
|
/**
|