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.
Files changed (53) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +5 -5
  3. package/dist/chunks/{chunk-JER5NQVM.js → chunk-56EUSC6D.js} +4 -4
  4. package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-EQYDSPBB.js → chunk-CVLFID6V.js} +64 -20
  7. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  8. package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
  9. package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
  10. package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
  11. package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
  12. package/dist/chunks/{chunk-KPU2XHOS.js → chunk-PQ2HRXDR.js} +1 -1
  13. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  14. package/dist/chunks/{chunk-CHD5UCFF.js → chunk-YWJJD5D6.js} +116 -32
  15. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  16. package/dist/chunks/{configService-CCA6AIDI.js → configService-VOY2MY2K.js} +2 -2
  17. package/dist/entries/server-router.js +5 -5
  18. package/dist/lib/client/index.js +41 -15
  19. package/dist/lib/client/index.js.map +3 -3
  20. package/dist/lib/server/index.js +11 -9
  21. package/dist/lib/server/index.js.map +2 -2
  22. package/dist/lib/shared/index.js +2 -2
  23. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  24. package/lib/client/core/ComponentBuilder.ts +25 -3
  25. package/lib/client/core/builders/embedBuilder.ts +13 -5
  26. package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
  27. package/lib/client/core/builders/localeListBuilder.ts +13 -5
  28. package/lib/client/templateEngine.ts +24 -0
  29. package/lib/server/fileWatcher.test.ts +134 -0
  30. package/lib/server/fileWatcher.ts +100 -32
  31. package/lib/server/jsonLoader.ts +1 -0
  32. package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
  33. package/lib/server/services/configService.ts +1 -0
  34. package/lib/server/services/fileWatcherService.ts +17 -0
  35. package/lib/server/ssr/htmlGenerator.ts +11 -3
  36. package/lib/server/ssr/ssrRenderer.test.ts +258 -0
  37. package/lib/server/ssr/ssrRenderer.ts +46 -5
  38. package/lib/server/webflow/buildWebflow.ts +1 -1
  39. package/lib/server/websocketManager.test.ts +61 -6
  40. package/lib/server/websocketManager.ts +25 -1
  41. package/lib/shared/cssProperties.test.ts +28 -0
  42. package/lib/shared/cssProperties.ts +27 -1
  43. package/lib/shared/types/api.ts +10 -1
  44. package/lib/shared/types/cms.ts +18 -9
  45. package/lib/shared/validation/schemas.test.ts +93 -0
  46. package/lib/shared/validation/schemas.ts +56 -15
  47. package/package.json +1 -1
  48. package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
  49. package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
  50. package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
  51. package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
  52. /package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-56EUSC6D.js.map} +0 -0
  53. /package/dist/chunks/{configService-CCA6AIDI.js.map → configService-VOY2MY2K.js.map} +0 -0
@@ -45,7 +45,7 @@ import {
45
45
  logNetworkError,
46
46
  logRuntimeError,
47
47
  setErrorHandler
48
- } from "../../chunks/chunk-6IVUG7FY.js";
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-S2CX6HFM.js";
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
- if (extractedAttributes.className) {
204
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
205
- classNames.push(...attrClasses);
206
- delete extractedAttributes.className;
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
- if (extractedAttributes.className) {
179
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
180
- classNames.push(...attrClasses);
181
- delete extractedAttributes.className;
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
- if (extractedAttributes.className) {
151
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
152
- classNames.push(...attrClasses);
153
- delete extractedAttributes.className;
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
- if (!existsSync(dirPath)) {
92
- return;
93
- }
94
-
95
- this.templatesWatcher = watch(
134
+ attachWhenDirExists(
96
135
  dirPath,
97
- { recursive: true },
98
- async (event, filename) => {
99
- if (filename && filename.endsWith('.json')) {
100
- // Prefix with templates/ so the path is /templates/...
101
- const pageName = `templates/${filename.replace('.json', '')}`;
102
- const pagePath = mapPageNameToPath(pageName);
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
- if (this.callbacks.onPageChange) {
105
- await this.callbacks.onPageChange(pagePath);
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
- if (!existsSync(dirPath)) {
172
- return;
173
- }
174
-
175
- this.cmsWatcher = watch(
214
+ attachWhenDirExists(
176
215
  dirPath,
177
- { recursive: true },
178
- async (event, filename) => {
179
- if (filename && filename.endsWith('.json')) {
180
- // Extract collection from path: "blog/my-post.json" -> "blog"
181
- const collection = filename.split('/')[0];
182
- if (this.callbacks.onCMSChange) {
183
- await this.callbacks.onCMSChange(collection);
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
  /**
@@ -423,6 +423,7 @@ export function setI18nConfig(config: I18nConfig): void {
423
423
  */
424
424
  export interface IconsConfig {
425
425
  favicon?: string;
426
+ faviconDark?: string;
426
427
  appleTouchIcon?: string;
427
428
  }
428
429