meno-core 1.0.49 → 1.0.51

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 (66) hide show
  1. package/build-astro.ts +6 -2
  2. package/build-static.ts +8 -1
  3. package/dist/bin/cli.js +1 -1
  4. package/dist/build-static.js +5 -5
  5. package/dist/chunks/{chunk-KPU2XHOS.js → chunk-2MHDV5BF.js} +11 -1
  6. package/dist/chunks/chunk-2MHDV5BF.js.map +7 -0
  7. package/dist/chunks/{chunk-JER5NQVM.js → chunk-3KJ6SJZC.js} +5 -5
  8. package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-3KJ6SJZC.js.map} +2 -2
  9. package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
  10. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  11. package/dist/chunks/{chunk-EQYDSPBB.js → chunk-DM54NPEC.js} +114 -31
  12. package/dist/chunks/chunk-DM54NPEC.js.map +7 -0
  13. package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
  14. package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
  15. package/dist/chunks/{chunk-4OFZP5NQ.js → chunk-HNLUO36W.js} +15 -4
  16. package/dist/chunks/chunk-HNLUO36W.js.map +7 -0
  17. package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
  18. package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
  19. package/dist/chunks/{chunk-CHD5UCFF.js → chunk-V7CD7V7W.js} +149 -46
  20. package/dist/chunks/chunk-V7CD7V7W.js.map +7 -0
  21. package/dist/chunks/{configService-CCA6AIDI.js → configService-R3OGU2UD.js} +2 -2
  22. package/dist/entries/server-router.js +5 -5
  23. package/dist/lib/client/index.js +41 -15
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +12 -10
  26. package/dist/lib/server/index.js.map +2 -2
  27. package/dist/lib/shared/index.js +2 -2
  28. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  29. package/lib/client/core/ComponentBuilder.ts +25 -3
  30. package/lib/client/core/builders/embedBuilder.ts +13 -5
  31. package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
  32. package/lib/client/core/builders/localeListBuilder.ts +13 -5
  33. package/lib/client/templateEngine.ts +24 -0
  34. package/lib/server/fileWatcher.test.ts +134 -0
  35. package/lib/server/fileWatcher.ts +100 -32
  36. package/lib/server/jsonLoader.ts +1 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
  38. package/lib/server/routes/pages.ts +37 -2
  39. package/lib/server/services/cmsService.ts +21 -0
  40. package/lib/server/services/configService.ts +21 -0
  41. package/lib/server/services/fileWatcherService.ts +17 -0
  42. package/lib/server/ssr/buildErrorOverlay.ts +22 -4
  43. package/lib/server/ssr/errorOverlay.ts +11 -3
  44. package/lib/server/ssr/htmlGenerator.nonce.test.ts +165 -0
  45. package/lib/server/ssr/htmlGenerator.ts +36 -9
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +3 -1
  47. package/lib/server/ssr/metaTagGenerator.ts +35 -5
  48. package/lib/server/ssr/ssrRenderer.test.ts +258 -0
  49. package/lib/server/ssr/ssrRenderer.ts +47 -5
  50. package/lib/server/ssrRenderer.test.ts +87 -2
  51. package/lib/server/webflow/buildWebflow.ts +1 -1
  52. package/lib/server/websocketManager.test.ts +61 -6
  53. package/lib/server/websocketManager.ts +25 -1
  54. package/lib/shared/cssProperties.test.ts +28 -0
  55. package/lib/shared/cssProperties.ts +27 -1
  56. package/lib/shared/types/api.ts +10 -1
  57. package/lib/shared/types/cms.ts +18 -9
  58. package/lib/shared/validation/schemas.test.ts +93 -0
  59. package/lib/shared/validation/schemas.ts +56 -15
  60. package/package.json +1 -1
  61. package/dist/chunks/chunk-4OFZP5NQ.js.map +0 -7
  62. package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
  63. package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
  64. package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
  65. package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
  66. /package/dist/chunks/{configService-CCA6AIDI.js.map → configService-R3OGU2UD.js.map} +0 -0
@@ -137,15 +137,100 @@ describe("SSR Renderer - generateMetaTags", () => {
137
137
  ogImage: "https://example.com/image.jpg",
138
138
  ogType: "article"
139
139
  };
140
-
140
+
141
141
  const tags = generateMetaTags(meta);
142
-
142
+
143
143
  expect(tags).toContain(`<meta property="og:title" content="OG Title" />`);
144
144
  expect(tags).toContain(`<meta property="og:description" content="OG Description" />`);
145
145
  expect(tags).toContain(`<meta property="og:image" content="https://example.com/image.jpg" />`);
146
146
  expect(tags).toContain(`<meta property="og:type" content="article" />`);
147
147
  });
148
148
 
149
+ test("should emit twitter:card=summary_large_image when ogImage present", () => {
150
+ const meta = {
151
+ ogTitle: "OG Title",
152
+ ogDescription: "OG Description",
153
+ ogImage: "https://example.com/image.jpg",
154
+ };
155
+
156
+ const tags = generateMetaTags(meta);
157
+
158
+ expect(tags).toContain(`<meta name="twitter:card" content="summary_large_image" />`);
159
+ expect(tags).toContain(`<meta name="twitter:title" content="OG Title" />`);
160
+ expect(tags).toContain(`<meta name="twitter:description" content="OG Description" />`);
161
+ });
162
+
163
+ test("should emit twitter:card=summary when no ogImage", () => {
164
+ const meta = {
165
+ title: "Plain Title",
166
+ description: "Plain Description",
167
+ };
168
+
169
+ const tags = generateMetaTags(meta);
170
+
171
+ expect(tags).toContain(`<meta name="twitter:card" content="summary" />`);
172
+ });
173
+
174
+ test("twitter:title falls back to title when ogTitle is absent", () => {
175
+ const meta = {
176
+ title: "Plain Title",
177
+ ogImage: "https://example.com/image.jpg",
178
+ };
179
+
180
+ const tags = generateMetaTags(meta);
181
+
182
+ expect(tags).toContain(`<meta name="twitter:title" content="Plain Title" />`);
183
+ });
184
+
185
+ test("twitter:description falls back to description when ogDescription is absent", () => {
186
+ const meta = {
187
+ description: "Plain Description",
188
+ ogImage: "https://example.com/image.jpg",
189
+ };
190
+
191
+ const tags = generateMetaTags(meta);
192
+
193
+ expect(tags).toContain(`<meta name="twitter:description" content="Plain Description" />`);
194
+ });
195
+
196
+ test("should emit twitter:site and twitter:creator with @-normalized handle", () => {
197
+ const meta = { title: "Page" };
198
+
199
+ const tags = generateMetaTags(meta, '', 'en', undefined, {
200
+ social: { twitterHandle: 'meno' },
201
+ });
202
+
203
+ expect(tags).toContain(`<meta name="twitter:site" content="@meno" />`);
204
+ expect(tags).toContain(`<meta name="twitter:creator" content="@meno" />`);
205
+ });
206
+
207
+ test("should not double-prefix handle that already starts with @", () => {
208
+ const meta = { title: "Page" };
209
+
210
+ const tags = generateMetaTags(meta, '', 'en', undefined, {
211
+ social: { twitterHandle: '@meno' },
212
+ });
213
+
214
+ expect(tags).toContain(`<meta name="twitter:site" content="@meno" />`);
215
+ expect(tags).not.toContain(`@@meno`);
216
+ });
217
+
218
+ test("should emit no twitter tags when meta is empty", () => {
219
+ const tags = generateMetaTags({});
220
+
221
+ expect(tags).not.toContain('twitter:');
222
+ });
223
+
224
+ test("should escape HTML in twitter content", () => {
225
+ const meta = {
226
+ ogTitle: "Title & <stuff>",
227
+ };
228
+
229
+ const tags = generateMetaTags(meta);
230
+
231
+ expect(tags).toContain(`<meta name="twitter:title" content="Title &amp; &lt;stuff&gt;" />`);
232
+ });
233
+
149
234
  test("should generate canonical URL", () => {
150
235
  const meta = {};
151
236
  const url = "https://example.com/page";
@@ -456,7 +456,7 @@ export async function buildWebflowPayload(
456
456
  // the slug, so a template like `templates/blog.json` lands at
457
457
  // `/blog/<first-slug>` and doesn't collide with a sibling listing
458
458
  // page like `pages/blog.json`.
459
- let itemSlug = item[cmsSchema.slugField] ?? item._slug ?? item._id;
459
+ let itemSlug = item[cmsSchema.slugField] ?? item._slug ?? item._filename ?? item._id;
460
460
  if (isI18nValue(itemSlug)) {
461
461
  itemSlug = resolveI18nValue(itemSlug, locale, i18nConfig) as string;
462
462
  }
@@ -1,9 +1,64 @@
1
- import { describe, test, expect } from 'bun:test';
1
+ import { describe, test, expect, beforeEach } from 'bun:test';
2
+ import { WebSocketManager } from './websocketManager';
3
+ import { WEBSOCKET_STATES } from '../shared/constants';
4
+ import type { RuntimeWSClient } from './runtime';
2
5
 
3
- describe('websocketManager', () => {
4
- test('placeholder test for coverage', () => {
5
- // WebSocket tests are complex and require server setup
6
- // This placeholder ensures the file appears in coverage reports
7
- expect(true).toBe(true);
6
+ function makeMockClient(readyState: number = WEBSOCKET_STATES.OPEN): RuntimeWSClient & { sent: string[] } {
7
+ const sent: string[] = [];
8
+ return {
9
+ readyState,
10
+ send(data) {
11
+ sent.push(typeof data === 'string' ? data : '<binary>');
12
+ },
13
+ close() {},
14
+ sent,
15
+ } as RuntimeWSClient & { sent: string[] };
16
+ }
17
+
18
+ describe('WebSocketManager', () => {
19
+ let manager: WebSocketManager;
20
+ let client: ReturnType<typeof makeMockClient>;
21
+
22
+ beforeEach(() => {
23
+ manager = new WebSocketManager();
24
+ client = makeMockClient();
25
+ manager.addClient(client);
26
+ });
27
+
28
+ describe('broadcastCollectionsUpdate', () => {
29
+ test('sends hmr:cms-collections-update to OPEN clients', () => {
30
+ manager.broadcastCollectionsUpdate();
31
+ expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:cms-collections-update' })]);
32
+ });
33
+
34
+ test('skips clients that are not OPEN', () => {
35
+ const closing = makeMockClient(WEBSOCKET_STATES.CLOSING);
36
+ manager.addClient(closing);
37
+ manager.broadcastCollectionsUpdate();
38
+ expect(client.sent.length).toBe(1);
39
+ expect(closing.sent.length).toBe(0);
40
+ });
41
+ });
42
+
43
+ describe('broadcastCMSUpdate', () => {
44
+ test('includes collection in payload', () => {
45
+ manager.broadcastCMSUpdate('blog');
46
+ expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:cms-update', collection: 'blog' })]);
47
+ });
48
+ });
49
+
50
+ describe('broadcastConfigUpdate', () => {
51
+ test('sends hmr:config-update to OPEN clients', () => {
52
+ manager.broadcastConfigUpdate();
53
+ expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:config-update' })]);
54
+ });
55
+ });
56
+
57
+ describe('client lifecycle', () => {
58
+ test('removed clients no longer receive broadcasts', () => {
59
+ manager.removeClient(client);
60
+ manager.broadcastCollectionsUpdate();
61
+ expect(client.sent.length).toBe(0);
62
+ });
8
63
  });
9
64
  });
@@ -103,7 +103,31 @@ export class WebSocketManager {
103
103
  collection
104
104
  });
105
105
  }
106
-
106
+
107
+ /**
108
+ * Broadcast CMS collections-list update notification.
109
+ * Emitted when a template file is added, removed, or its schema changes —
110
+ * tells connected clients to re-fetch the collections list.
111
+ */
112
+ broadcastCollectionsUpdate(): void {
113
+ this.broadcast({
114
+ type: 'hmr:cms-collections-update'
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Broadcast project.config.json update notification.
120
+ * Emitted when project.config.json changes (e.g. an AI tool adds a new
121
+ * locale) — tells connected clients to re-fetch config-derived state
122
+ * such as the i18n locale list.
123
+ */
124
+ broadcastConfigUpdate(): void {
125
+ this.broadcast({
126
+ type: 'hmr:config-update'
127
+ });
128
+ }
129
+
130
+
107
131
  /**
108
132
  * Get number of connected clients
109
133
  */
@@ -128,6 +128,34 @@ describe('cssProperties', () => {
128
128
  const result = filterCSSProperties('fd');
129
129
  expect(result).toContain('flexDirection');
130
130
  });
131
+
132
+ test('promotes paddingLeft to top for "pl"', () => {
133
+ const result = filterCSSProperties('pl');
134
+ expect(result[0]).toBe('paddingLeft');
135
+ });
136
+
137
+ test('promotes paddingRight to top for "pr"', () => {
138
+ const result = filterCSSProperties('pr');
139
+ expect(result[0]).toBe('paddingRight');
140
+ });
141
+
142
+ test('promotes paddingTop to top for "pt" and "pu"', () => {
143
+ expect(filterCSSProperties('pt')[0]).toBe('paddingTop');
144
+ expect(filterCSSProperties('pu')[0]).toBe('paddingTop');
145
+ });
146
+
147
+ test('promotes paddingBottom to top for "pb"', () => {
148
+ const result = filterCSSProperties('pb');
149
+ expect(result[0]).toBe('paddingBottom');
150
+ });
151
+
152
+ test('promotes marginLeft/Right/Top/Bottom for "ml"/"mr"/"mt"/"mu"/"mb"', () => {
153
+ expect(filterCSSProperties('ml')[0]).toBe('marginLeft');
154
+ expect(filterCSSProperties('mr')[0]).toBe('marginRight');
155
+ expect(filterCSSProperties('mt')[0]).toBe('marginTop');
156
+ expect(filterCSSProperties('mu')[0]).toBe('marginTop');
157
+ expect(filterCSSProperties('mb')[0]).toBe('marginBottom');
158
+ });
131
159
  });
132
160
 
133
161
  describe('getPropertyValues', () => {
@@ -650,6 +650,25 @@ function matchesAbbreviation(property: string, input: string): boolean {
650
650
  return true;
651
651
  }
652
652
 
653
+ /**
654
+ * Direction-abbreviation shortcuts. When the user types one of these exact
655
+ * inputs, the mapped property is forced to the top of the suggestions list
656
+ * (ahead of startsWith matches like `placeContent` for "pl"). "u" stands for
657
+ * "up" — a natural mnemonic for `Top`.
658
+ */
659
+ const PROPERTY_SHORTCUTS: Record<string, string> = {
660
+ pl: 'paddingLeft',
661
+ pr: 'paddingRight',
662
+ pt: 'paddingTop',
663
+ pu: 'paddingTop',
664
+ pb: 'paddingBottom',
665
+ ml: 'marginLeft',
666
+ mr: 'marginRight',
667
+ mt: 'marginTop',
668
+ mu: 'marginTop',
669
+ mb: 'marginBottom',
670
+ };
671
+
653
672
  /**
654
673
  * Filter CSS properties based on input value
655
674
  * Supports both startsWith matching and camelCase abbreviation matching
@@ -675,10 +694,17 @@ export function filterCSSProperties(input: string): string[] {
675
694
  const byPriority = (a: string, b: string) =>
676
695
  getPropertyPriority(a) - getPropertyPriority(b);
677
696
 
678
- return [
697
+ const combined = [
679
698
  ...startsWithMatches.sort(byPriority),
680
699
  ...abbreviationMatches.sort(byPriority),
681
700
  ];
701
+
702
+ const shortcut = PROPERTY_SHORTCUTS[normalizedInput.toLowerCase()];
703
+ if (shortcut) {
704
+ return [shortcut, ...combined.filter(prop => prop !== shortcut)];
705
+ }
706
+
707
+ return combined;
682
708
  }
683
709
 
684
710
  /**
@@ -7,7 +7,16 @@ import type { CMSFieldDefinition } from './cms';
7
7
  import type { PageLibrariesConfig } from './libraries';
8
8
 
9
9
  export interface HMRMessage {
10
- type: 'hmr:update' | 'hmr:colors-update' | 'hmr:variables-update' | 'hmr:enums-update' | 'hmr:fonts-update' | 'hmr:libraries-update' | 'hmr:cms-update';
10
+ type:
11
+ | 'hmr:update'
12
+ | 'hmr:colors-update'
13
+ | 'hmr:variables-update'
14
+ | 'hmr:enums-update'
15
+ | 'hmr:fonts-update'
16
+ | 'hmr:libraries-update'
17
+ | 'hmr:cms-update'
18
+ | 'hmr:cms-collections-update'
19
+ | 'hmr:config-update';
11
20
  path?: string;
12
21
  collection?: string; // For CMS updates
13
22
  }
@@ -73,24 +73,33 @@ export interface CMSSchema {
73
73
  }
74
74
 
75
75
  /**
76
- * CMS Item - actual content entry (stored as individual JSON file)
76
+ * CMS Item - actual content entry (stored as individual JSON file).
77
77
  *
78
- * Note on _filename vs _slug:
79
- * - _filename: Stable identifier used for file storage and API operations. Never changes after creation.
80
- * - _slug: Derived from filename for backward compatibility. Use _filename instead in new code.
78
+ * Identity model:
79
+ * - `_id` is the canonical identifier. For new items written by the editor, it
80
+ * equals the on-disk filename's stem (slug-shaped, human-readable). For
81
+ * legacy items it may be a custom value (e.g. `"post-001"`) that differs
82
+ * from the filename — those continue to work; reference resolution accepts
83
+ * either `_id` or the on-disk filename as a key.
84
+ * - `_filename` is a legacy alias kept for back-compat. The provider's
85
+ * `normalizeItem` always sets it on read to the on-disk filename's stem, so
86
+ * internal file-routing code can rely on it. New items do not need to
87
+ * persist `_filename` to disk — write only `_id`.
88
+ * - `_slug` is the oldest identifier alias, fully deprecated. Use `_id`.
81
89
  */
82
90
  export interface CMSItem {
83
- /** Unique identifier for the item */
91
+ /** Canonical identifier. For new items, equals the on-disk filename's stem. */
84
92
  _id: string;
85
93
  /**
86
- * @deprecated Use _filename instead. Kept for backward compatibility.
94
+ * @deprecated Use _id. Kept for backward compatibility.
87
95
  * Derived from the filename on disk.
88
96
  */
89
97
  _slug?: string;
90
98
  /**
91
- * Stable filename identifier (without .json extension).
92
- * Used for file storage and API operations. Never changes after creation,
93
- * even if the slug field value changes.
99
+ * @deprecated Legacy alias for _id. Always equals the on-disk filename's
100
+ * stem; populated automatically by the file-system provider on read.
101
+ * Internal file-routing code reads this for legacy items where the on-disk
102
+ * filename differs from `_id`. New items do not need to write this to disk.
94
103
  */
95
104
  _filename?: string;
96
105
  /** ISO timestamp when item was created */
@@ -28,6 +28,99 @@ describe('Schema Validation', () => {
28
28
  }
29
29
  });
30
30
 
31
+ test('prop default accepts an _i18n value object', () => {
32
+ // Regression: the SSR pipeline resolves `_i18n` objects in prop defaults
33
+ // (see resolveI18nInProps) and CLAUDE.md tells AI to write them. The
34
+ // schema must accept this shape — otherwise the editor's component-load
35
+ // route rejects the file with a misleading JSONPage error.
36
+ const propDef = {
37
+ type: 'string',
38
+ default: { _i18n: true, en: 'Hello', pl: 'Cześć' },
39
+ };
40
+
41
+ const result = PropDefinitionSchema.safeParse(propDef);
42
+ expect(result.success).toBe(true);
43
+ });
44
+
45
+ test('list prop default accepts _i18n value objects on item fields', () => {
46
+ const propDef = {
47
+ type: 'list',
48
+ itemSchema: { title: { type: 'string' } },
49
+ default: [
50
+ { title: { _i18n: true, en: 'Hello', pl: 'Cześć' } },
51
+ ],
52
+ };
53
+
54
+ const result = PropDefinitionSchema.safeParse(propDef);
55
+ expect(result.success).toBe(true);
56
+ });
57
+
58
+ test('HTML node accepts an _i18n object as direct children', () => {
59
+ // Regression: previously the validator rejected `{type: "node", tag:
60
+ // "h1", children: {_i18n: true, en: "Blog", pl: "Blog"}}` with a
61
+ // misleading JSONPage error. After widening ComponentNodeSchema this
62
+ // shape validates and the runtime resolves it to a locale-string.
63
+ const page = {
64
+ root: {
65
+ type: 'node',
66
+ tag: 'h1',
67
+ children: { _i18n: true, en: 'Blog', pl: 'Blog' },
68
+ },
69
+ };
70
+ const result = PageDataSchema.safeParse(page);
71
+ expect(result.success).toBe(true);
72
+ });
73
+
74
+ test('HTML node accepts an _i18n object inside attribute values', () => {
75
+ const page = {
76
+ root: {
77
+ type: 'node',
78
+ tag: 'img',
79
+ attributes: {
80
+ src: '/x.png',
81
+ alt: { _i18n: true, en: 'Photo', pl: 'Zdjęcie' },
82
+ },
83
+ },
84
+ };
85
+ const result = PageDataSchema.safeParse(page);
86
+ expect(result.success).toBe(true);
87
+ });
88
+
89
+ test('HTML node accepts an _i18n object inside a children array', () => {
90
+ const page = {
91
+ root: {
92
+ type: 'node',
93
+ tag: 'p',
94
+ children: [
95
+ { _i18n: true, en: 'Hello', pl: 'Cześć' },
96
+ ' literal ',
97
+ { type: 'node', tag: 'span', children: 'static' },
98
+ ],
99
+ },
100
+ };
101
+ const result = PageDataSchema.safeParse(page);
102
+ expect(result.success).toBe(true);
103
+ });
104
+
105
+ test('component file with localized prop defaults validates as PageData', () => {
106
+ // End-to-end regression: a real component shape written by AI with
107
+ // localized defaults must pass PageDataSchema so the editor can open it.
108
+ const componentFile = {
109
+ component: {
110
+ interface: {
111
+ title: {
112
+ type: 'string',
113
+ default: { _i18n: true, en: 'From the blog', pl: 'Z bloga' },
114
+ },
115
+ },
116
+ structure: { type: 'node', tag: 'section', children: [] },
117
+ },
118
+ };
119
+
120
+ const result = PageDataSchema.safeParse(componentFile);
121
+ expect(result.success).toBe(true);
122
+ });
123
+
31
124
  test('valid select prop definition', () => {
32
125
  const propDef = {
33
126
  type: 'select',
@@ -23,11 +23,39 @@ export const BasePropTypeSchema = z.enum(['string', 'select', 'boolean', 'number
23
23
  export const PropTypeSchema = z.enum(['string', 'select', 'boolean', 'number', 'link', 'file', 'rich-text', 'embed', 'list']);
24
24
 
25
25
  /**
26
- * Base prop definition schema (for non-list props)
26
+ * `_i18n` value object schema. Used wherever a localizable string can appear:
27
+ * - Prop defaults (`BasePropDefinitionSchema.default`)
28
+ * - Node `children` (text content on `type: "node"`, `"link"`, `"component"`,
29
+ * and basic list nodes)
30
+ * - Attribute values (`attributes` records on every node type)
31
+ *
32
+ * The runtime resolves these objects to the active locale's string at the
33
+ * earliest dispatch point in both the SSR renderer (`renderNode`) and the
34
+ * client builder (`ComponentBuilder.buildComponent`). The schema accepts the
35
+ * shape so the editor's load path doesn't reject files that contain it.
36
+ */
37
+ export const I18nValueObjectSchema = z.object({
38
+ _i18n: z.literal(true),
39
+ }).passthrough();
40
+
41
+ /**
42
+ * Base prop definition schema (for non-list props).
43
+ * `default` accepts a literal value (string/number/boolean/link) OR an
44
+ * `_i18n` value object — the SSR pipeline resolves the latter to the active
45
+ * locale's string via `resolveI18nInProps` at render time, so localized prop
46
+ * defaults are first-class. The `I18nOrStringSchema` proper is defined later
47
+ * in this file; we inline the matching shape here to keep the validator
48
+ * tolerant of localized defaults.
27
49
  */
28
50
  export const BasePropDefinitionSchema = z.object({
29
51
  type: BasePropTypeSchema,
30
- default: z.union([z.string(), z.number(), z.boolean(), z.object({ href: z.string(), target: z.string().optional() })]).optional(),
52
+ default: z.union([
53
+ z.string(),
54
+ z.number(),
55
+ z.boolean(),
56
+ z.object({ href: z.string(), target: z.string().optional() }),
57
+ I18nValueObjectSchema,
58
+ ]).optional(),
31
59
  options: z.array(z.string()).readonly().optional(),
32
60
  enumName: z.string().optional(), // For 'select' type: reference to project-level enum
33
61
  accept: z.string().optional(), // For 'file' type: MIME pattern like "image/*", "video/*"
@@ -51,7 +79,16 @@ export const ListItemSchemaSchema = z.record(z.string(), BasePropDefinitionSchem
51
79
  export const ListPropDefinitionSchema = z.object({
52
80
  type: z.literal('list'),
53
81
  itemSchema: ListItemSchemaSchema,
54
- default: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.object({ href: z.string(), target: z.string().optional() }), z.null()]))).optional(),
82
+ // List-item field values can be any primitive, a link object, an `_i18n`
83
+ // object (resolved by the SSR pipeline), or null.
84
+ default: z.array(z.record(z.string(), z.union([
85
+ z.string(),
86
+ z.number(),
87
+ z.boolean(),
88
+ z.object({ href: z.string(), target: z.string().optional() }),
89
+ I18nValueObjectSchema,
90
+ z.null(),
91
+ ]))).optional(),
55
92
  }).passthrough();
56
93
 
57
94
  /**
@@ -175,8 +212,9 @@ export const InteractiveStylesSchema = z.array(InteractiveStyleRuleSchema);
175
212
  export const SlotMarkerSchema: z.ZodType<any> = z.lazy(() => z.object({
176
213
  type: z.literal(NODE_TYPE.SLOT),
177
214
  default: z.union([
178
- z.array(z.union([ComponentNodeSchema, z.string()])),
215
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
179
216
  z.string(),
217
+ I18nValueObjectSchema,
180
218
  ]).optional(),
181
219
  }).passthrough());
182
220
 
@@ -197,11 +235,12 @@ export const HtmlNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
197
235
  style: StyleValueSchema.optional(),
198
236
  interactiveStyles: InteractiveStylesSchema.optional(),
199
237
  generateElementClass: z.boolean().optional(),
200
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
238
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
201
239
  props: z.record(z.string(), z.any()).optional(), // Allow props for backward compatibility
202
240
  children: z.union([
203
- z.array(z.union([ComponentNodeSchema, z.string()])),
241
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
204
242
  z.string(),
243
+ I18nValueObjectSchema,
205
244
  ]).optional(),
206
245
  }).passthrough());
207
246
 
@@ -217,10 +256,11 @@ export const ComponentInstanceNodeSchema: z.ZodType<any> = z.lazy(() => z.object
217
256
  style: StyleValueSchema.optional(),
218
257
  interactiveStyles: InteractiveStylesSchema.optional(),
219
258
  generateElementClass: z.boolean().optional(),
220
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
259
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
221
260
  children: z.union([
222
- z.array(z.union([ComponentNodeSchema, z.string()])),
261
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
223
262
  z.string(),
263
+ I18nValueObjectSchema,
224
264
  ]).optional(),
225
265
  }).passthrough());
226
266
 
@@ -229,13 +269,13 @@ export const ComponentInstanceNodeSchema: z.ZodType<any> = z.lazy(() => z.object
229
269
  */
230
270
  export const EmbedNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
231
271
  type: z.literal(NODE_TYPE.EMBED),
232
- html: z.union([z.string(), HtmlMappingSchema]),
272
+ html: z.union([z.string(), HtmlMappingSchema, I18nValueObjectSchema]),
233
273
  label: z.string().optional(),
234
274
  if: IfConditionSchema.optional(),
235
275
  style: StyleValueSchema.optional(),
236
276
  interactiveStyles: InteractiveStylesSchema.optional(),
237
277
  generateElementClass: z.boolean().optional(),
238
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
278
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
239
279
  }).passthrough());
240
280
 
241
281
  /**
@@ -243,16 +283,17 @@ export const EmbedNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
243
283
  */
244
284
  export const LinkNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
245
285
  type: z.literal(NODE_TYPE.LINK),
246
- href: z.union([z.string(), LinkMappingSchema]),
286
+ href: z.union([z.string(), LinkMappingSchema, I18nValueObjectSchema]),
247
287
  label: z.string().optional(),
248
288
  if: IfConditionSchema.optional(),
249
289
  style: StyleValueSchema.optional(),
250
290
  interactiveStyles: InteractiveStylesSchema.optional(),
251
291
  generateElementClass: z.boolean().optional(),
252
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
292
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
253
293
  children: z.union([
254
- z.array(z.union([ComponentNodeSchema, z.string()])),
294
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
255
295
  z.string(),
296
+ I18nValueObjectSchema,
256
297
  ]).optional(),
257
298
  }).passthrough());
258
299
 
@@ -273,7 +314,7 @@ export const LocaleListNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
273
314
  showSeparator: z.boolean().optional(),
274
315
  showFlag: z.boolean().optional(),
275
316
  flagStyle: StyleValueSchema.optional(),
276
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
317
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
277
318
  }).passthrough());
278
319
 
279
320
  /**
@@ -298,7 +339,7 @@ export const ListNodeSchemaBasic: z.ZodType<any> = z.lazy(() => z.object({
298
339
  offset: z.number().optional(),
299
340
  excludeCurrentItem: z.boolean().optional(),
300
341
  emitTemplate: z.boolean().optional(),
301
- children: z.array(z.union([ComponentNodeSchema, z.string()])).optional(),
342
+ children: z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])).optional(),
302
343
  }).passthrough());
303
344
 
304
345
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.49",
3
+ "version": "1.0.51",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../lib/server/ssr/buildErrorOverlay.ts"],
4
- "sourcesContent": ["/**\n * Build Error Overlay Generator\n * Generates an HTML page showing build errors when the static server detects _errors.json\n */\n\nexport interface BuildError {\n file: string; // e.g., \"pages/posts.json\"\n message: string; // Error message\n type: string; // 'minify' | 'render' | 'parse' | 'cms'\n}\n\nexport interface BuildErrorsData {\n errors: BuildError[];\n timestamp: number;\n}\n\n/**\n * Escape HTML to prevent XSS in error messages\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\n/**\n * Safely encode data for embedding in a script tag\n */\nfunction safeJsonForScript(data: unknown): string {\n return JSON.stringify(data)\n .replace(/</g, '\\\\u003c')\n .replace(/>/g, '\\\\u003e')\n .replace(/&/g, '\\\\u0026');\n}\n\n/**\n * Generate HTML page showing build errors\n */\nexport function generateBuildErrorPage(data: BuildErrorsData): string {\n const { errors, timestamp } = data;\n const timeStr = new Date(timestamp).toLocaleTimeString();\n\n // Generate plain text for copying to AI\n const plainTextErrors = errors.map(err =>\n `[${err.type.toUpperCase()}] ${err.file}\\n${err.message}`\n ).join('\\n\\n');\n const copyText = `Build failed with ${errors.length} error(s):\\n\\n${plainTextErrors}`;\n\n const errorListHtml = errors.map((err) => `\n <div class=\"error-item\">\n <div class=\"error-item-header\">\n <div class=\"error-type\">${escapeHtml(err.type)}</div>\n <div class=\"error-file\">${escapeHtml(err.file)}</div>\n </div>\n <div class=\"error-message\">${escapeHtml(err.message)}</div>\n </div>\n `).join('');\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Build Failed</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);\n color: #e2e8f0;\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n }\n\n .container {\n max-width: 720px;\n width: 100%;\n }\n\n .card {\n background: rgba(30, 30, 50, 0.8);\n backdrop-filter: blur(20px);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 16px;\n overflow: hidden;\n box-shadow:\n 0 4px 6px rgba(0, 0, 0, 0.1),\n 0 20px 50px rgba(0, 0, 0, 0.3),\n inset 0 1px 0 rgba(255, 255, 255, 0.05);\n }\n\n .header {\n background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);\n padding: 20px 24px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .header-left {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .header-icon {\n width: 32px;\n height: 32px;\n background: rgba(255, 255, 255, 0.2);\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .header-icon svg {\n width: 18px;\n height: 18px;\n stroke: white;\n }\n\n .header-title {\n font-size: 16px;\n font-weight: 600;\n letter-spacing: -0.01em;\n }\n\n .error-count {\n background: rgba(0, 0, 0, 0.25);\n padding: 6px 14px;\n border-radius: 100px;\n font-size: 13px;\n font-weight: 500;\n }\n\n .body {\n padding: 20px;\n max-height: 50vh;\n overflow-y: auto;\n }\n\n .body::-webkit-scrollbar {\n width: 6px;\n }\n\n .body::-webkit-scrollbar-track {\n background: transparent;\n }\n\n .body::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.1);\n border-radius: 3px;\n }\n\n .error-item {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 10px;\n padding: 14px 16px;\n margin-bottom: 10px;\n transition: border-color 0.15s;\n }\n\n .error-item:last-child { margin-bottom: 0; }\n\n .error-item:hover {\n border-color: rgba(255, 255, 255, 0.12);\n }\n\n .error-item-header {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 10px;\n }\n\n .error-type {\n font-size: 10px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n padding: 4px 8px;\n border-radius: 4px;\n background: rgba(239, 68, 68, 0.2);\n color: #fca5a5;\n }\n\n .error-file {\n font-family: 'SF Mono', 'Fira Code', Menlo, Monaco, monospace;\n font-size: 12px;\n color: #94a3b8;\n }\n\n .error-message {\n font-family: 'SF Mono', 'Fira Code', Menlo, Monaco, monospace;\n font-size: 12px;\n line-height: 1.7;\n color: #f87171;\n white-space: pre-wrap;\n word-break: break-word;\n background: rgba(0, 0, 0, 0.2);\n padding: 10px 12px;\n border-radius: 6px;\n border-left: 2px solid #dc2626;\n }\n\n .footer {\n padding: 16px 20px;\n background: rgba(0, 0, 0, 0.2);\n border-top: 1px solid rgba(255, 255, 255, 0.06);\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 12px;\n flex-wrap: wrap;\n }\n\n .footer-info {\n font-size: 12px;\n color: #64748b;\n }\n\n .footer-actions {\n display: flex;\n gap: 8px;\n }\n\n .btn {\n border: none;\n padding: 10px 16px;\n border-radius: 8px;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 6px;\n transition: all 0.15s;\n }\n\n .btn svg {\n width: 14px;\n height: 14px;\n }\n\n .btn-secondary {\n background: rgba(255, 255, 255, 0.08);\n color: #e2e8f0;\n border: 1px solid rgba(255, 255, 255, 0.1);\n }\n\n .btn-secondary:hover {\n background: rgba(255, 255, 255, 0.12);\n border-color: rgba(255, 255, 255, 0.15);\n }\n\n .btn-primary {\n background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);\n color: white;\n }\n\n .btn-primary:hover {\n background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);\n }\n\n .btn-success {\n background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"card\">\n <div class=\"header\">\n <div class=\"header-left\">\n <div class=\"header-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line>\n <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>\n </svg>\n </div>\n <span class=\"header-title\">Build Failed</span>\n </div>\n <span class=\"error-count\">${errors.length} error${errors.length === 1 ? '' : 's'}</span>\n </div>\n <div class=\"body\">\n ${errorListHtml}\n </div>\n <div class=\"footer\">\n <div class=\"footer-info\">Failed at ${timeStr}</div>\n <div class=\"footer-actions\">\n <button class=\"btn btn-secondary\" id=\"copyBtn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect>\n <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path>\n </svg>\n <span>Copy</span>\n </button>\n <button class=\"btn btn-primary\" onclick=\"location.reload()\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"23 4 23 10 17 10\"></polyline>\n <path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\"></path>\n </svg>\n Refresh\n </button>\n </div>\n </div>\n </div>\n </div>\n <script>\n (function() {\n var copyBtn = document.getElementById('copyBtn');\n var copyText = ${safeJsonForScript(copyText)};\n\n copyBtn.addEventListener('click', function() {\n navigator.clipboard.writeText(copyText).then(function() {\n var span = copyBtn.querySelector('span');\n var original = span.textContent;\n span.textContent = 'Copied!';\n copyBtn.classList.add('btn-success');\n setTimeout(function() {\n span.textContent = original;\n copyBtn.classList.remove('btn-success');\n }, 2000);\n }).catch(function(err) {\n console.error('Failed to copy:', err);\n });\n });\n })();\n </script>\n</body>\n</html>`;\n}\n"],
5
- "mappings": "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",
6
- "names": []
7
- }