meno-core 1.0.21 → 1.0.23

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 (59) hide show
  1. package/build-static.test.ts +424 -0
  2. package/build-static.ts +100 -13
  3. package/lib/client/ClientInitializer.ts +4 -0
  4. package/lib/client/core/ComponentBuilder.ts +155 -16
  5. package/lib/client/core/builders/embedBuilder.ts +48 -6
  6. package/lib/client/core/builders/linkBuilder.ts +2 -2
  7. package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
  8. package/lib/client/core/builders/listBuilder.ts +12 -3
  9. package/lib/client/routing/Router.tsx +8 -1
  10. package/lib/client/templateEngine.ts +89 -98
  11. package/lib/server/__integration__/api-routes.test.ts +148 -0
  12. package/lib/server/__integration__/cms-integration.test.ts +161 -0
  13. package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
  14. package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
  15. package/lib/server/__integration__/static-assets.test.ts +80 -0
  16. package/lib/server/__integration__/test-helpers.ts +205 -0
  17. package/lib/server/ab/generateFunctions.ts +346 -0
  18. package/lib/server/ab/trackingScript.ts +45 -0
  19. package/lib/server/index.ts +2 -2
  20. package/lib/server/jsonLoader.ts +124 -46
  21. package/lib/server/routes/api/cms.ts +3 -2
  22. package/lib/server/routes/api/components.ts +13 -2
  23. package/lib/server/services/cmsService.ts +0 -5
  24. package/lib/server/services/componentService.ts +255 -29
  25. package/lib/server/services/configService.test.ts +950 -0
  26. package/lib/server/services/configService.ts +39 -0
  27. package/lib/server/services/index.ts +1 -1
  28. package/lib/server/ssr/htmlGenerator.test.ts +992 -0
  29. package/lib/server/ssr/htmlGenerator.ts +3 -3
  30. package/lib/server/ssr/imageMetadata.test.ts +168 -0
  31. package/lib/server/ssr/imageMetadata.ts +58 -0
  32. package/lib/server/ssr/jsCollector.test.ts +287 -0
  33. package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
  34. package/lib/server/ssr/ssrRenderer.ts +131 -15
  35. package/lib/shared/constants.ts +3 -0
  36. package/lib/shared/fontLoader.test.ts +335 -0
  37. package/lib/shared/i18n.test.ts +106 -0
  38. package/lib/shared/i18n.ts +17 -11
  39. package/lib/shared/index.ts +3 -0
  40. package/lib/shared/itemTemplateUtils.ts +43 -1
  41. package/lib/shared/libraryLoader.test.ts +392 -0
  42. package/lib/shared/linkUtils.ts +24 -0
  43. package/lib/shared/nodeUtils.test.ts +100 -0
  44. package/lib/shared/nodeUtils.ts +43 -0
  45. package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
  46. package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
  47. package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
  48. package/lib/shared/richtext/htmlToTiptap.ts +46 -2
  49. package/lib/shared/richtext/tiptapToHtml.ts +65 -0
  50. package/lib/shared/richtext/types.ts +4 -1
  51. package/lib/shared/types/cms.ts +2 -0
  52. package/lib/shared/types/components.ts +12 -3
  53. package/lib/shared/types/experiments.ts +55 -0
  54. package/lib/shared/types/index.ts +10 -0
  55. package/lib/shared/utils.ts +2 -6
  56. package/lib/shared/validation/propValidator.test.ts +50 -0
  57. package/lib/shared/validation/propValidator.ts +2 -2
  58. package/lib/shared/validation/schemas.ts +10 -2
  59. package/package.json +1 -1
@@ -102,6 +102,9 @@ function isBlockNode(type: string): boolean {
102
102
  'tableRow',
103
103
  'tableCell',
104
104
  'tableHeader',
105
+ 'iframe',
106
+ 'rawHtml',
107
+ 'menoComponent',
105
108
  ].includes(type);
106
109
  }
107
110
 
@@ -169,6 +172,8 @@ function parseNode(node: Node): TiptapNode | TiptapNode[] | null {
169
172
  return { type: 'hardBreak' };
170
173
  case 'img':
171
174
  return parseImage(element);
175
+ case 'iframe':
176
+ return parseIframe(element);
172
177
  case 'table':
173
178
  return parseTable(element);
174
179
 
@@ -192,8 +197,19 @@ function parseNode(node: Node): TiptapNode | TiptapNode[] | null {
192
197
  case 'span':
193
198
  return parseSpan(element);
194
199
 
195
- // Container elements - just parse children
196
- case 'div':
200
+ // Container elements - check for special data attributes first
201
+ case 'div': {
202
+ // Raw HTML block
203
+ if (element.hasAttribute('data-raw-html')) {
204
+ return { type: 'rawHtml', attrs: { content: element.innerHTML } };
205
+ }
206
+ // Meno component embed
207
+ if (element.hasAttribute('data-meno-component')) {
208
+ return parseMenoComponent(element);
209
+ }
210
+ // Regular div - parse children
211
+ return parseChildren(element);
212
+ }
197
213
  case 'article':
198
214
  case 'section':
199
215
  case 'main':
@@ -343,6 +359,34 @@ function parseImage(element: HTMLElement): TiptapNode {
343
359
  return { type: 'image', attrs };
344
360
  }
345
361
 
362
+ function parseIframe(element: HTMLElement): TiptapNode {
363
+ const src = element.getAttribute('src') || '';
364
+ const width = element.getAttribute('width') || '100%';
365
+ const height = element.getAttribute('height') || '315';
366
+
367
+ return {
368
+ type: 'iframe',
369
+ attrs: { src, width, height },
370
+ };
371
+ }
372
+
373
+ function parseMenoComponent(element: HTMLElement): TiptapNode {
374
+ const component = element.getAttribute('data-meno-component') || '';
375
+ let props: Record<string, unknown> = {};
376
+ const propsStr = element.getAttribute('data-meno-props');
377
+ if (propsStr) {
378
+ try {
379
+ props = JSON.parse(propsStr);
380
+ } catch {
381
+ // ignore parse errors
382
+ }
383
+ }
384
+ return {
385
+ type: 'menoComponent',
386
+ attrs: { component, props },
387
+ };
388
+ }
389
+
346
390
  function parseTable(element: HTMLElement): TiptapNode {
347
391
  const rows: TiptapNode[] = [];
348
392
 
@@ -47,6 +47,9 @@ export const RICH_TEXT_ALLOWED_TAGS = [
47
47
  'hr',
48
48
  'figure',
49
49
  'figcaption',
50
+ // Embeds
51
+ 'iframe',
52
+ 'div',
50
53
  ];
51
54
 
52
55
  export const RICH_TEXT_ALLOWED_ATTRS = [
@@ -68,6 +71,14 @@ export const RICH_TEXT_ALLOWED_ATTRS = [
68
71
  'data-language',
69
72
  // Alignment (via style)
70
73
  'style',
74
+ // Iframe attributes
75
+ 'frameborder',
76
+ 'allowfullscreen',
77
+ 'allow',
78
+ // Raw HTML / Component attributes
79
+ 'data-raw-html',
80
+ 'data-meno-component',
81
+ 'data-meno-props',
71
82
  ];
72
83
 
73
84
  /**
@@ -184,6 +195,15 @@ function renderNode(node: TiptapNode): string {
184
195
  case 'tableHeader':
185
196
  return renderTableCell(node, 'th');
186
197
 
198
+ case 'iframe':
199
+ return renderIframe(node);
200
+
201
+ case 'rawHtml':
202
+ return renderRawHtml(node);
203
+
204
+ case 'menoComponent':
205
+ return renderMenoComponent(node);
206
+
187
207
  default:
188
208
  // Unknown node type - try to render content
189
209
  if (node.content) {
@@ -299,6 +319,51 @@ function renderTableCell(node: TiptapNode, tag: 'td' | 'th'): string {
299
319
  return `<${tag}${attrStr}>${content}</${tag}>`;
300
320
  }
301
321
 
322
+ /**
323
+ * Render iframe embed
324
+ */
325
+ function renderIframe(node: TiptapNode): string {
326
+ const src = node.attrs?.src as string | undefined;
327
+ if (!src) return '';
328
+
329
+ // Only allow https:// URLs for security
330
+ if (!src.startsWith('https://')) return '';
331
+
332
+ const width = (node.attrs?.width as string | undefined) || '100%';
333
+ const height = (node.attrs?.height as string | undefined) || '315';
334
+
335
+ return `<iframe src="${escapeAttr(src)}" width="${escapeAttr(String(width))}" height="${escapeAttr(String(height))}" frameborder="0" allowfullscreen></iframe>`;
336
+ }
337
+
338
+ /**
339
+ * Render raw HTML block
340
+ */
341
+ function renderRawHtml(node: TiptapNode): string {
342
+ const content = node.attrs?.content as string | undefined;
343
+ if (!content) return '';
344
+
345
+ // Strip script tags and event handlers for safety
346
+ const sanitized = content
347
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
348
+ .replace(/\son\w+\s*=\s*(['"])[^'"]*\1/gi, '')
349
+ .replace(/\son\w+\s*=\s*[^\s>]*/gi, '');
350
+
351
+ return `<div data-raw-html="true">${sanitized}</div>`;
352
+ }
353
+
354
+ /**
355
+ * Render Meno component marker for SSR expansion
356
+ */
357
+ function renderMenoComponent(node: TiptapNode): string {
358
+ const component = node.attrs?.component as string | undefined;
359
+ if (!component) return '';
360
+
361
+ const props = node.attrs?.props as Record<string, unknown> | undefined;
362
+ const propsJson = props ? escapeAttr(JSON.stringify(props)) : '{}';
363
+
364
+ return `<div data-meno-component="${escapeAttr(component)}" data-meno-props="${propsJson}"></div>`;
365
+ }
366
+
302
367
  /**
303
368
  * Render text node with marks
304
369
  */
@@ -68,7 +68,10 @@ export type TiptapNodeType =
68
68
  | 'table'
69
69
  | 'tableRow'
70
70
  | 'tableCell'
71
- | 'tableHeader';
71
+ | 'tableHeader'
72
+ | 'iframe'
73
+ | 'rawHtml'
74
+ | 'menoComponent';
72
75
 
73
76
  /**
74
77
  * Known Tiptap mark types
@@ -11,6 +11,7 @@ export type CMSFieldType =
11
11
  | 'number' // Numeric value
12
12
  | 'boolean' // True/false
13
13
  | 'image' // Image URL/path
14
+ | 'file' // File URL/path (any file type)
14
15
  | 'date' // ISO date string
15
16
  | 'select' // Single selection from options
16
17
  | 'reference' // Reference to another collection item
@@ -25,6 +26,7 @@ export interface CMSFieldDefinition {
25
26
  label?: string; // Display label in editor
26
27
  description?: string; // Help text
27
28
  options?: string[]; // For 'select' type
29
+ accept?: string; // For 'file' type - MIME filter (e.g. "application/pdf", "*/*")
28
30
  collection?: string; // For 'reference' type - target collection ID
29
31
  multiple?: boolean; // For 'reference' type - allows array of IDs
30
32
  }
@@ -10,13 +10,20 @@ import type { ComponentNode } from './nodes';
10
10
  */
11
11
  export type PropType = 'string' | 'select' | 'boolean' | 'number' | 'link' | 'file' | 'rich-text' | 'list';
12
12
 
13
+ /**
14
+ * Project-level enum configuration
15
+ * Keys are enum names, values are arrays of options
16
+ */
17
+ export type EnumsConfig = Record<string, readonly string[]>;
18
+
13
19
  /**
14
20
  * Internationalization (i18n) value object
15
21
  * Keys are locale codes (e.g., 'en', 'pl', 'de')
22
+ * Values can be strings (for string props) or arrays (for list props)
16
23
  */
17
24
  export interface I18nValue {
18
25
  _i18n: true;
19
- [locale: string]: string | true; // true is for the _i18n marker itself
26
+ [locale: string]: string | unknown[] | true; // true is for the _i18n marker, arrays for list props
20
27
  }
21
28
 
22
29
  /**
@@ -58,7 +65,8 @@ export interface LinkPropValue {
58
65
  export interface BasePropDefinition {
59
66
  type: Exclude<PropType, 'list'>;
60
67
  default?: string | number | boolean | I18nValue | LinkPropValue;
61
- options?: readonly string[]; // Required for "select" type
68
+ options?: readonly string[]; // Required for "select" type (inline options)
69
+ enumName?: string; // For "select" type: reference to project-level enum
62
70
  accept?: string; // For "file" type: MIME pattern like "image/*", "video/*"
63
71
  }
64
72
 
@@ -70,8 +78,9 @@ export type ListItemSchema = Record<string, BasePropDefinition>;
70
78
 
71
79
  /**
72
80
  * List item value type
81
+ * Supports i18n values for localized string fields
73
82
  */
74
- export type ListItemValue = Record<string, string | number | boolean | LinkPropValue | null>;
83
+ export type ListItemValue = Record<string, string | number | boolean | LinkPropValue | I18nValue | null>;
75
84
 
76
85
  /**
77
86
  * List prop definition - for array/list data
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A/B Testing Experiment Types
3
+ * Defines experiment configuration, variants, and conversion goals
4
+ */
5
+
6
+ export interface ConversionGoal {
7
+ id: string
8
+ name: string
9
+ type: 'click' | 'navigation' | 'custom'
10
+ /** CSS selector for click goals */
11
+ selector?: string
12
+ /** URL pattern for navigation goals */
13
+ url?: string
14
+ /** Custom event name */
15
+ event?: string
16
+ }
17
+
18
+ export interface ExperimentVariant {
19
+ branch: string
20
+ /** Percentage of traffic (0-100) */
21
+ weight: number
22
+ /** Cloudflare Pages preview deploy URL, e.g. "https://variant-blue.project.pages.dev" */
23
+ deployUrl: string
24
+ }
25
+
26
+ export interface Experiment {
27
+ id: string
28
+ name: string
29
+ status: 'draft' | 'running' | 'paused' | 'completed'
30
+ controlBranch: string
31
+ variants: ExperimentVariant[]
32
+ /** Percentage of traffic for control (0-100) */
33
+ controlWeight: number
34
+ conversionGoals: ConversionGoal[]
35
+ createdAt: string
36
+ startedAt?: string
37
+ endedAt?: string
38
+ }
39
+
40
+ export interface ExperimentResults {
41
+ experimentId: string
42
+ variants: VariantResult[]
43
+ }
44
+
45
+ export interface VariantResult {
46
+ variant: string
47
+ visitors: number
48
+ conversions: number
49
+ conversionRate: number
50
+ /** Lift percentage compared to control */
51
+ lift?: number
52
+ /** p-value from z-test */
53
+ pValue?: number
54
+ isSignificant: boolean
55
+ }
@@ -34,6 +34,7 @@ export type {
34
34
  I18nConfig,
35
35
  LocaleConfig,
36
36
  LinkPropValue,
37
+ EnumsConfig,
37
38
  } from './components';
38
39
 
39
40
  export {
@@ -139,5 +140,14 @@ export { DEFAULT_PREFETCH_CONFIG } from './prefetch';
139
140
  // Export config types
140
141
  export type { CSPConfig } from './config';
141
142
 
143
+ // Export experiment types
144
+ export type {
145
+ ConversionGoal,
146
+ ExperimentVariant,
147
+ Experiment,
148
+ ExperimentResults,
149
+ VariantResult,
150
+ } from './experiments';
151
+
142
152
  // Note: Path types are exported from ../paths/index.ts, not from here
143
153
 
@@ -7,16 +7,12 @@ import { stringToPath, pathToLegacyString, getParentPath, isRootPath as isRootPa
7
7
  import { ROOT_0_STRING } from './pathArrayUtils';
8
8
 
9
9
  /**
10
- * Deep clone an object using JSON serialization
10
+ * Deep clone an object using structuredClone
11
11
  * @param obj - The object to clone
12
12
  * @returns A deep clone of the object
13
13
  */
14
14
  export function deepClone<T>(obj: T): T {
15
- try {
16
- return JSON.parse(JSON.stringify(obj)) as T;
17
- } catch (error) {
18
- throw error;
19
- }
15
+ return structuredClone(obj);
20
16
  }
21
17
 
22
18
  /**
@@ -181,6 +181,56 @@ describe('Prop Validator', () => {
181
181
  expect(result.valid).toBe(true);
182
182
  expect(result.props.text).toBe('<b>Bold text</b>');
183
183
  });
184
+
185
+ test('list prop with I18nValue passes validation', () => {
186
+ const propDefs: Record<string, PropDefinition> = {
187
+ items: {
188
+ type: 'list',
189
+ itemSchema: {
190
+ title: { type: 'string', default: '' },
191
+ text: { type: 'string', default: '' },
192
+ },
193
+ default: [],
194
+ },
195
+ };
196
+ const passedProps = {
197
+ items: {
198
+ _i18n: true,
199
+ en: [{ title: 'English', text: 'Text' }],
200
+ pl: [{ title: 'Polish', text: 'Tekst' }],
201
+ },
202
+ };
203
+
204
+ const result = validateComponentProps(propDefs, passedProps);
205
+
206
+ expect(result.valid).toBe(true);
207
+ // I18nValue should be preserved (resolved later by resolveI18nInProps)
208
+ expect(result.props.items).toEqual({
209
+ _i18n: true,
210
+ en: [{ title: 'English', text: 'Text' }],
211
+ pl: [{ title: 'Polish', text: 'Tekst' }],
212
+ });
213
+ });
214
+
215
+ test('list prop with plain array passes validation', () => {
216
+ const propDefs: Record<string, PropDefinition> = {
217
+ items: {
218
+ type: 'list',
219
+ itemSchema: {
220
+ title: { type: 'string', default: '' },
221
+ },
222
+ default: [],
223
+ },
224
+ };
225
+ const passedProps = {
226
+ items: [{ title: 'Item 1' }, { title: 'Item 2' }],
227
+ };
228
+
229
+ const result = validateComponentProps(propDefs, passedProps);
230
+
231
+ expect(result.valid).toBe(true);
232
+ expect(result.props.items).toEqual([{ title: 'Item 1' }, { title: 'Item 2' }]);
233
+ });
184
234
  });
185
235
 
186
236
  describe('Backward compatibility maintained', () => {
@@ -167,9 +167,9 @@ function validateSingleProp(
167
167
  break;
168
168
 
169
169
  case 'list':
170
- // List type accepts arrays
170
+ // List type accepts arrays and i18n values (i18n values will be resolved later)
171
171
  coercedValue = value;
172
- typeValid = Array.isArray(value);
172
+ typeValid = Array.isArray(value) || isI18nValue(value);
173
173
  break;
174
174
 
175
175
  default:
@@ -29,9 +29,16 @@ export const BasePropDefinitionSchema = z.object({
29
29
  type: BasePropTypeSchema,
30
30
  default: z.union([z.string(), z.number(), z.boolean(), z.object({ href: z.string(), target: z.string().optional() })]).optional(),
31
31
  options: z.array(z.string()).readonly().optional(),
32
+ enumName: z.string().optional(), // For 'select' type: reference to project-level enum
32
33
  accept: z.string().optional(), // For 'file' type: MIME pattern like "image/*", "video/*"
33
34
  }).passthrough();
34
35
 
36
+ /**
37
+ * Project-level enums configuration schema
38
+ * Keys are enum names, values are arrays of options
39
+ */
40
+ export const EnumsConfigSchema = z.record(z.string(), z.array(z.string()));
41
+
35
42
  /**
36
43
  * List item schema - defines structure of each item in a list prop
37
44
  */
@@ -269,7 +276,7 @@ export const ListNodeSchemaBasic: z.ZodType<any> = z.lazy(() => z.object({
269
276
  sourceType: z.enum(['prop', 'collection']).optional(), // defaults to 'prop'
270
277
  source: z.string().optional(), // Source prop name or collection name
271
278
  collection: z.string().optional(), // Legacy field for cms-list migration
272
- tag: z.string().optional(), // Wrapper element tag, defaults to 'div'
279
+ tag: z.union([z.string(), z.literal(false)]).optional(), // Wrapper element tag, defaults to 'div'. Set to false for no container.
273
280
  label: z.string().optional(),
274
281
  if: IfConditionSchema.optional(),
275
282
  style: StyleValueSchema.optional(),
@@ -476,7 +483,7 @@ export const PageDataSchema = z.union([
476
483
  * CMS field type schema
477
484
  */
478
485
  export const CMSFieldTypeSchema = z.enum([
479
- 'string', 'text', 'rich-text', 'number', 'boolean', 'image', 'date', 'select', 'reference', 'i18n', 'i18n-text'
486
+ 'string', 'text', 'rich-text', 'number', 'boolean', 'image', 'file', 'date', 'select', 'reference', 'i18n', 'i18n-text'
480
487
  ]);
481
488
 
482
489
  /**
@@ -489,6 +496,7 @@ export const CMSFieldDefinitionSchema = z.object({
489
496
  label: z.string().optional(),
490
497
  description: z.string().optional(),
491
498
  options: z.array(z.string()).optional(),
499
+ accept: z.string().optional(),
492
500
  collection: z.string().optional(),
493
501
  multiple: z.boolean().optional(),
494
502
  }).passthrough();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./bin/cli.ts"