meno-core 1.0.39 → 1.0.41

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 (67) hide show
  1. package/bin/cli.ts +33 -0
  2. package/build-astro.ts +172 -69
  3. package/dist/bin/cli.js +30 -2
  4. package/dist/bin/cli.js.map +2 -2
  5. package/dist/build-static.js +7 -7
  6. package/dist/chunks/{chunk-WK5XLASY.js → chunk-EQOSDQS2.js} +4 -4
  7. package/dist/chunks/{chunk-AIXKUVNG.js → chunk-IBR2F4IL.js} +4 -5
  8. package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-IBR2F4IL.js.map} +2 -2
  9. package/dist/chunks/{chunk-NV25WXCA.js → chunk-IGVQF5GY.js} +11 -7
  10. package/dist/chunks/chunk-IGVQF5GY.js.map +7 -0
  11. package/dist/chunks/{chunk-KULPBDC7.js → chunk-LBWIHPN7.js} +9 -3
  12. package/dist/chunks/chunk-LBWIHPN7.js.map +7 -0
  13. package/dist/chunks/{chunk-A6KWUEA6.js → chunk-MKB2J6AD.js} +9 -1
  14. package/dist/chunks/chunk-MKB2J6AD.js.map +7 -0
  15. package/dist/chunks/{chunk-P3FX5HJM.js → chunk-S2HXJTAF.js} +1 -1
  16. package/dist/chunks/chunk-S2HXJTAF.js.map +7 -0
  17. package/dist/chunks/{chunk-W6HDII4T.js → chunk-SK3TLNUP.js} +140 -114
  18. package/dist/chunks/chunk-SK3TLNUP.js.map +7 -0
  19. package/dist/chunks/{chunk-HNAS6BSS.js → chunk-SNUROC7E.js} +56 -6
  20. package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-SNUROC7E.js.map} +3 -3
  21. package/dist/chunks/{configService-TXBNUBBL.js → configService-MICL4S2L.js} +2 -2
  22. package/dist/chunks/{constants-5CRJRQNR.js → constants-ZEU4TZCA.js} +2 -2
  23. package/dist/entries/server-router.js +7 -7
  24. package/dist/lib/client/index.js +11 -6
  25. package/dist/lib/client/index.js.map +2 -2
  26. package/dist/lib/server/index.js +507 -1587
  27. package/dist/lib/server/index.js.map +4 -4
  28. package/dist/lib/shared/index.js +3 -3
  29. package/dist/lib/test-utils/index.js +1 -1
  30. package/lib/client/core/ComponentBuilder.ts +1 -1
  31. package/lib/client/core/builders/embedBuilder.ts +2 -2
  32. package/lib/client/routing/Router.tsx +6 -0
  33. package/lib/client/templateEngine.test.ts +178 -0
  34. package/lib/client/templateEngine.ts +1 -2
  35. package/lib/server/astro/cmsPageEmitter.ts +420 -0
  36. package/lib/server/astro/componentEmitter.ts +150 -17
  37. package/lib/server/astro/nodeToAstro.test.ts +1101 -0
  38. package/lib/server/astro/nodeToAstro.ts +869 -37
  39. package/lib/server/astro/pageEmitter.ts +43 -3
  40. package/lib/server/astro/tailwindMapper.ts +69 -8
  41. package/lib/server/astro/templateTransformer.ts +107 -0
  42. package/lib/server/index.ts +26 -3
  43. package/lib/server/routes/api/components.ts +62 -0
  44. package/lib/server/routes/api/core-routes.ts +8 -0
  45. package/lib/server/services/configService.ts +12 -0
  46. package/lib/server/ssr/htmlGenerator.ts +0 -5
  47. package/lib/server/ssr/imageMetadata.ts +3 -3
  48. package/lib/server/ssr/ssrRenderer.ts +78 -29
  49. package/lib/server/webflow/buildWebflow.ts +415 -0
  50. package/lib/server/webflow/index.ts +22 -0
  51. package/lib/server/webflow/nodeToWebflow.ts +423 -0
  52. package/lib/server/webflow/styleMapper.ts +241 -0
  53. package/lib/server/webflow/types.ts +196 -0
  54. package/lib/shared/constants.ts +4 -0
  55. package/lib/shared/types/components.ts +9 -4
  56. package/lib/shared/validation/propValidator.ts +2 -1
  57. package/lib/shared/validation/schemas.ts +4 -1
  58. package/package.json +1 -1
  59. package/templates/index-router.html +0 -5
  60. package/dist/chunks/chunk-A6KWUEA6.js.map +0 -7
  61. package/dist/chunks/chunk-KULPBDC7.js.map +0 -7
  62. package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
  63. package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
  64. package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
  65. /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-EQOSDQS2.js.map} +0 -0
  66. /package/dist/chunks/{configService-TXBNUBBL.js.map → configService-MICL4S2L.js.map} +0 -0
  67. /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-ZEU4TZCA.js.map} +0 -0
@@ -3,10 +3,12 @@
3
3
  * Generates .astro page files that import and compose components
4
4
  */
5
5
 
6
- import type { JSONPage, ComponentDefinition } from '../../shared/types';
6
+ import type { JSONPage, ComponentDefinition, I18nConfig } from '../../shared/types';
7
7
  import type { BreakpointConfig } from '../../shared/breakpoints';
8
8
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
9
  import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
+ import type { ImageMetadataMap } from '../ssr/imageMetadata';
11
+ import type { SlugMap } from '../../shared/slugTranslator';
10
12
 
11
13
  // ---------------------------------------------------------------------------
12
14
  // Types
@@ -41,6 +43,16 @@ export interface PageEmitOptions {
41
43
  pageName: string;
42
44
  /** Breakpoint config for responsive Tailwind classes */
43
45
  breakpoints?: BreakpointConfig;
46
+ /** Image metadata map for responsive image generation */
47
+ imageMetadataMap?: ImageMetadataMap;
48
+ /** I18n config for locale list rendering */
49
+ i18nConfig?: I18nConfig;
50
+ /** Locale→slug map for current page (for locale list links) */
51
+ currentPageSlugMap?: Record<string, string>;
52
+ /** Slug mappings for translating internal link hrefs */
53
+ slugMappings?: SlugMap[];
54
+ /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> */
55
+ imageFormat?: 'webp' | 'avif';
44
56
  }
45
57
 
46
58
  // ---------------------------------------------------------------------------
@@ -88,6 +100,11 @@ export function emitAstroPage(options: PageEmitOptions): string {
88
100
  ssrFallbacks,
89
101
  pageName,
90
102
  breakpoints: breakpointsOpt,
103
+ imageMetadataMap,
104
+ i18nConfig,
105
+ currentPageSlugMap,
106
+ slugMappings,
107
+ imageFormat,
91
108
  } = options;
92
109
 
93
110
  const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
@@ -110,6 +127,15 @@ export function emitAstroPage(options: PageEmitOptions): string {
110
127
  fileType: 'page',
111
128
  fileName: pageName,
112
129
  breakpoints,
130
+ imageMetadataMap,
131
+ locale,
132
+ i18nConfig,
133
+ currentPageSlugMap,
134
+ frontmatterLines: [],
135
+ astroImports: new Set<string>(),
136
+ slugMappings,
137
+ i18nDefaultLocale: i18nConfig?.defaultLocale,
138
+ imageFormat,
113
139
  };
114
140
 
115
141
  // Emit the template body
@@ -117,6 +143,13 @@ export function emitAstroPage(options: PageEmitOptions): string {
117
143
 
118
144
  // Build frontmatter with imports
119
145
  const importLines: string[] = [];
146
+
147
+ // Add Astro API imports (e.g., getCollection) if collected during emission
148
+ if (ctx.astroImports && ctx.astroImports.size > 0) {
149
+ const astroImports = Array.from(ctx.astroImports);
150
+ importLines.push(`import { ${astroImports.join(', ')} } from 'astro:content';`);
151
+ }
152
+
120
153
  importLines.push(`import BaseLayout from '${layoutImportPath}';`);
121
154
 
122
155
  // Sort component imports alphabetically
@@ -137,8 +170,13 @@ export function emitAstroPage(options: PageEmitOptions): string {
137
170
  const escapedMeta = escapeTemplateLiteral(meta);
138
171
  const escapedFontPreloads = escapeTemplateLiteral(fontPreloads);
139
172
 
173
+ // Collect frontmatter lines from list emission (e.g., getCollection calls)
174
+ const extraFrontmatter = ctx.frontmatterLines && ctx.frontmatterLines.length > 0
175
+ ? '\n' + ctx.frontmatterLines.join('\n')
176
+ : '';
177
+
140
178
  return `---
141
- ${importLines.join('\n')}
179
+ ${importLines.join('\n')}${extraFrontmatter}
142
180
  ---
143
181
  <BaseLayout
144
182
  title="${escapeJSX(title)}"
@@ -149,7 +187,9 @@ ${importLines.join('\n')}
149
187
  fontPreloads={\`${escapedFontPreloads}\`}
150
188
  libraryTags={${libraryTagsLiteral}}
151
189
  >
152
- ${templateBody}</BaseLayout>
190
+ <div id="root">
191
+ ${templateBody} </div>
192
+ </BaseLayout>
153
193
  `;
154
194
  }
155
195
 
@@ -263,6 +263,10 @@ const singleValueMatches: Record<string, string> = {
263
263
  'right:0': 'right-0',
264
264
  'bottom:0': 'bottom-0',
265
265
  'left:0': 'left-0',
266
+ 'outline:none': '[outline:none]',
267
+ 'background:none': '[background:none]',
268
+ 'background:transparent': '[background:transparent]',
269
+ 'backgroundColor:transparent': 'bg-transparent',
266
270
  };
267
271
 
268
272
  // ---------------------------------------------------------------------------
@@ -331,7 +335,7 @@ const arbitraryPrefixMap: Record<string, string> = {
331
335
  backdropFilter: 'backdrop',
332
336
  transform: '[transform]',
333
337
  transformOrigin: 'origin',
334
- transition: 'transition',
338
+ transition: '[transition]',
335
339
  mixBlendMode: 'mix-blend',
336
340
  clipPath: '[clip-path]',
337
341
 
@@ -408,16 +412,44 @@ export function propertyToTailwind(
408
412
  }
409
413
 
410
414
  // Color variable handling:
411
- // "var(--text)" → "text-[var(--text)]"
412
- // Bare color name like "text" → "text-[var(--text)]"
415
+ // "var(--text)" → "text-[color:var(--text)]" (type hint avoids ambiguity with font-size)
416
+ // Bare color name like "text" → "text-[color:var(--text)]"
413
417
  if (property === 'color' || property === 'backgroundColor' || property === 'borderColor') {
414
418
  const prefix = property === 'color' ? 'text' : property === 'backgroundColor' ? 'bg' : 'border';
415
419
  if (strValue.includes('var(')) {
416
- return `${prefix}-[${strValue}]`;
420
+ return `${prefix}-[color:${strValue}]`;
417
421
  }
418
422
  // Bare name (not a hex, rgb, or number-starting value)
419
423
  if (!strValue.match(/^[#\d]/) && !strValue.includes('rgb') && !strValue.includes('hsl')) {
420
- return `${prefix}-[var(--${strValue})]`;
424
+ return `${prefix}-[color:var(--${strValue})]`;
425
+ }
426
+ }
427
+
428
+ // Color properties with actual color values need type hints to avoid ambiguity
429
+ if (property === 'borderColor' || property === 'color') {
430
+ const prefix = property === 'color' ? 'text' : 'border';
431
+ const sanitized = strValue.replace(/\s+/g, '_');
432
+ return `${prefix}-[color:${sanitized}]`;
433
+ }
434
+
435
+ // Border shorthand (e.g., "1px solid", "2px solid var(--border)") needs arbitrary property syntax
436
+ // because Tailwind's border-[...] only accepts width values, not compound shorthands
437
+ if (property === 'border' || property === 'borderTop' || property === 'borderRight' ||
438
+ property === 'borderBottom' || property === 'borderLeft') {
439
+ if (strValue.includes('solid') || strValue.includes('dashed') || strValue.includes('dotted') || strValue.includes('none')) {
440
+ const cssProp = property.replace(/([A-Z])/g, '-$1').toLowerCase();
441
+ const sanitized = strValue.replace(/\s+/g, '_');
442
+ return `[${cssProp}:${sanitized}]`;
443
+ }
444
+ }
445
+
446
+ // `background` is a CSS shorthand — Tailwind's bg-[...] maps to background-color, not background.
447
+ // For non-color values (none, transparent, gradients, etc.), use arbitrary property syntax.
448
+ if (property === 'background') {
449
+ const isSimpleColor = /^(#[0-9a-fA-F]{3,8}|rgb|hsl|var\()/.test(strValue);
450
+ if (!isSimpleColor) {
451
+ const sanitized = strValue.replace(/\s+/g, '_');
452
+ return `[background:${sanitized}]`;
421
453
  }
422
454
  }
423
455
 
@@ -439,9 +471,16 @@ export function propertyToTailwind(
439
471
  // Standard arbitrary value: prefix-[value]
440
472
  const sanitized = strValue.replace(/\s+/g, '_');
441
473
 
442
- // Font family names need quotes in arbitrary syntax for Tailwind to parse correctly
443
- if (property === 'fontFamily' && !sanitized.includes(',') && !sanitized.startsWith("'")) {
444
- return `${twPrefix}-['${sanitized}']`;
474
+ // Disambiguate properties that share a Tailwind prefix with another CSS property.
475
+ // Without type hints, Tailwind guesses wrong (e.g., text-[var(--x)] color instead of font-size).
476
+ if (property === 'fontSize') {
477
+ return `text-[length:${sanitized}]`;
478
+ }
479
+ if (property === 'fontFamily') {
480
+ return `font-[family-name:${sanitized}]`;
481
+ }
482
+ if (property === 'fontWeight') {
483
+ return `font-[number:${sanitized}]`;
445
484
  }
446
485
 
447
486
  return `${twPrefix}-[${sanitized}]`;
@@ -459,6 +498,16 @@ export function stylesToTailwind(
459
498
  const classes: string[] = [];
460
499
  const dynamicStyles: Record<string, string> = {};
461
500
 
501
+ // When borderColor is present alongside a border shorthand, Tailwind's CSS
502
+ // ordering can cause the shorthand's arbitrary property `[border:...]` to
503
+ // override the longhand `border-[color:...]`. To avoid this, decompose
504
+ // border shorthands into width + style only (dropping the color part) so
505
+ // borderColor can take effect without conflicts.
506
+ const hasBorderColor = 'borderColor' in style && !isStyleMapping(style.borderColor);
507
+ const borderShorthands = new Set([
508
+ 'border', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
509
+ ]);
510
+
462
511
  for (const [prop, value] of Object.entries(style)) {
463
512
  if (isStyleMapping(value)) continue;
464
513
 
@@ -471,6 +520,18 @@ export function stylesToTailwind(
471
520
  continue;
472
521
  }
473
522
 
523
+ // Decompose border shorthands when borderColor is separately specified
524
+ if (hasBorderColor && borderShorthands.has(prop)) {
525
+ const parts = strValue.split(/\s+/);
526
+ // Parse width and style from shorthand (e.g. "1px solid #ccc" → "1px", "solid")
527
+ const width = parts.find(p => /^\d/.test(p) || p === '0');
528
+ const borderStyle = parts.find(p => /^(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)$/.test(p));
529
+ const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
530
+ if (width) classes.push(`[${cssProp}-width:${width}]`);
531
+ if (borderStyle) classes.push(`[${cssProp}-style:${borderStyle}]`);
532
+ continue;
533
+ }
534
+
474
535
  const twClass = propertyToTailwind(prop, value);
475
536
  if (twClass) {
476
537
  classes.push(twClass);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * CMS Template Expression Transformer for Astro Export
3
+ * Converts {{cms.field}} and {{item.field}} template expressions to Astro expressions
4
+ */
5
+
6
+ // CMS template pattern (from cmsSSRProcessor.ts)
7
+ const CMS_TEMPLATE_PATTERN = /\{\{cms\.([^}]+)\}\}/g;
8
+ // Item template pattern for list iteration
9
+ const ITEM_TEMPLATE_PATTERN = /\{\{([^}]+)\}\}/g;
10
+
11
+ export function isTemplateExpression(text: string): boolean {
12
+ return /\{\{.+?\}\}/.test(text);
13
+ }
14
+
15
+ export function transformCMSTemplate(
16
+ text: string,
17
+ binding: string = 'entry',
18
+ richTextFields?: Set<string>,
19
+ wrapFn?: string
20
+ ): string {
21
+ const w = (expr: string) => wrapFn ? `${wrapFn}(${expr})` : expr;
22
+
23
+ // Check if entire text is a single {{cms.field}} expression
24
+ const fullMatch = text.match(/^\{\{cms\.([^}]+)\}\}$/);
25
+ if (fullMatch) {
26
+ const fieldPath = fullMatch[1].trim();
27
+ const topLevelField = fieldPath.split('.')[0];
28
+
29
+ // Rich-text fields use Fragment set:html
30
+ if (richTextFields?.has(topLevelField)) {
31
+ return `<Fragment set:html={${w(`${binding}.data.${fieldPath}`)}} />`;
32
+ }
33
+
34
+ return `{${w(`${binding}.data.${fieldPath}`)}}`;
35
+ }
36
+
37
+ // Mixed content: "Hello {{cms.name}}" → {`Hello ${entry.data.name}`}
38
+ if (CMS_TEMPLATE_PATTERN.test(text)) {
39
+ CMS_TEMPLATE_PATTERN.lastIndex = 0;
40
+ const replaced = text.replace(CMS_TEMPLATE_PATTERN, (_, fieldPath) => {
41
+ return `\${${w(`${binding}.data.${fieldPath.trim()}`)}}`;
42
+ });
43
+ return `{\`${replaced}\`}`;
44
+ }
45
+
46
+ return text;
47
+ }
48
+
49
+ /**
50
+ * Replace legacy itemIndex/itemFirst/itemLast meta-variables with
51
+ * the actual .map() callback index parameter name.
52
+ */
53
+ /**
54
+ * Rewrite the default `item.` prefix to the actual .map() parameter name
55
+ * when itemAs differs from 'item' (e.g., itemAs='link' → item.text → link.text).
56
+ */
57
+ export function rewriteItemVar(expr: string, itemVar: string): string {
58
+ if (itemVar === 'item') return expr;
59
+ return expr.replace(/\bitem\./g, `${itemVar}.`);
60
+ }
61
+
62
+ export function replaceItemMetaVars(expr: string, indexVar: string, sourceVar?: string, itemVar?: string): string {
63
+ const lastExpr = sourceVar
64
+ ? `(${indexVar} === ${sourceVar}.length - 1)`
65
+ : `false /* itemLast not supported */`;
66
+ let result = expr
67
+ .replace(/\bitemIndex\b/g, indexVar)
68
+ .replace(/\bitemFirst\b/g, `(${indexVar} === 0)`)
69
+ .replace(/\bitemLast\b/g, lastExpr);
70
+ if (itemVar) result = rewriteItemVar(result, itemVar);
71
+ return result;
72
+ }
73
+
74
+ export function transformItemTemplate(
75
+ text: string,
76
+ itemVar: string = 'item',
77
+ indexVar?: string,
78
+ sourceVar?: string
79
+ ): string {
80
+ // Check if entire text is a single {{expression}}
81
+ const fullMatch = text.match(/^\{\{(.+)\}\}$/);
82
+ if (fullMatch) {
83
+ let expr = fullMatch[1].trim();
84
+ expr = rewriteItemVar(expr, itemVar);
85
+ if (indexVar) expr = replaceItemMetaVars(expr, indexVar, sourceVar);
86
+ // If it contains a dot, it's item.field
87
+ if (expr.startsWith(`${itemVar}.`)) {
88
+ return `{${expr}}`;
89
+ }
90
+ // Bare expression like {{features}} - keep as prop reference
91
+ return `{${expr}}`;
92
+ }
93
+
94
+ // Mixed content: replace each {{expr}}
95
+ if (ITEM_TEMPLATE_PATTERN.test(text)) {
96
+ ITEM_TEMPLATE_PATTERN.lastIndex = 0;
97
+ const replaced = text.replace(ITEM_TEMPLATE_PATTERN, (_, expr) => {
98
+ let trimmed = expr.trim();
99
+ trimmed = rewriteItemVar(trimmed, itemVar);
100
+ if (indexVar) trimmed = replaceItemMetaVars(trimmed, indexVar, sourceVar);
101
+ return `\${${trimmed}}`;
102
+ });
103
+ return `{\`${replaced}\`}`;
104
+ }
105
+
106
+ return text;
107
+ }
@@ -17,7 +17,7 @@ export { generateBuildErrorPage, type BuildError, type BuildErrorsData } from '.
17
17
  export { PageService } from './services/pageService';
18
18
  export { ComponentService, type ComponentInfo } from './services/componentService';
19
19
  export { CMSService, type ReferenceLocation } from './services/cmsService';
20
- export { configService, ConfigService } from './services/configService';
20
+ export { configService, ConfigService, type ImageFormat } from './services/configService';
21
21
  export { ColorService, colorService } from './services/ColorService';
22
22
  export { VariableService, variableService } from './services/VariableService';
23
23
  export { EnumService, enumService } from './services/EnumService';
@@ -62,8 +62,31 @@ export { migrateTemplatesDirectory } from './migrateTemplates';
62
62
  // Static build
63
63
  export { buildStaticPages } from '../../build-static';
64
64
 
65
- // Astro export
66
- export { buildAstroProject } from '../../build-astro';
65
+ // Webflow export
66
+ export { buildWebflowPayload } from './webflow';
67
+ export type {
68
+ WebflowExportPayload,
69
+ WebflowPage,
70
+ WebflowElement,
71
+ WebflowStyleClass,
72
+ } from './webflow';
73
+
74
+ // JSON loaders
75
+ export {
76
+ loadJSONFile,
77
+ loadComponentDirectory,
78
+ mapPageNameToPath,
79
+ parseJSON,
80
+ loadI18nConfig,
81
+ loadBreakpointConfig,
82
+ loadResponsiveScalesConfig,
83
+ } from './jsonLoader';
84
+
85
+ // CSS generation
86
+ export { generateThemeColorVariablesCSS, generateVariablesCSS } from './cssGenerator';
87
+
88
+ // Font utilities
89
+ export { generateFontCSS, generateFontPreloadTags } from '../shared/fontLoader';
67
90
 
68
91
  // Utilities
69
92
  export * from './utils';
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { ComponentService } from '../../services/componentService';
7
+ import type { PageService } from '../../services/pageService';
8
+ import type { ComponentNode } from '../../../shared/types';
7
9
  import { createCorsHeaders } from '../../middleware/cors';
8
10
  import { jsonResponse } from './shared';
9
11
  import { formatJsonErrorMessage } from '../../../shared/jsonRepair';
@@ -96,6 +98,66 @@ export async function handleComponentJavaScriptRoute(
96
98
  });
97
99
  }
98
100
 
101
+ /**
102
+ * Handle component usage API endpoint - GET /api/component-usage
103
+ * Returns how many times each component is used across all pages and other components
104
+ */
105
+ export function handleComponentUsageRoute(
106
+ pageService: PageService,
107
+ componentService: ComponentService
108
+ ): Response {
109
+ const usageMap = new Map<string, { count: number; files: Map<string, { name: string; path: string; type: 'page' | 'component' }> }>();
110
+
111
+ function walkTree(node: ComponentNode | undefined | null, fileName: string, filePath: string, fileType: 'page' | 'component') {
112
+ if (!node || typeof node !== 'object') return;
113
+ if (node.type === 'component' && 'component' in node && (node as any).component) {
114
+ const compName = (node as any).component as string;
115
+ if (!usageMap.has(compName)) {
116
+ usageMap.set(compName, { count: 0, files: new Map() });
117
+ }
118
+ const entry = usageMap.get(compName)!;
119
+ entry.count++;
120
+ entry.files.set(filePath, { name: fileName, path: filePath, type: fileType });
121
+ }
122
+ if ('children' in node && Array.isArray((node as any).children)) {
123
+ for (const child of (node as any).children) {
124
+ if (child && typeof child === 'object') {
125
+ walkTree(child as ComponentNode, fileName, filePath, fileType);
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Walk all pages
132
+ const pagePaths = pageService.getAllPagePaths();
133
+ for (const path of pagePaths) {
134
+ const pageData = pageService.getPageData(path);
135
+ if (pageData && 'root' in pageData && pageData.root) {
136
+ const name = path === '/' ? 'index' : path.replace(/^\//, '').replace(/\//g, '-');
137
+ walkTree(pageData.root, name, path, 'page');
138
+ }
139
+ }
140
+
141
+ // Walk all component structures
142
+ const allComponents = componentService.getAllComponents();
143
+ for (const [compName, compDef] of Object.entries(allComponents)) {
144
+ if (compDef.component?.structure) {
145
+ walkTree(compDef.component.structure, compName, compName, 'component');
146
+ }
147
+ }
148
+
149
+ // Convert to plain object
150
+ const result: Record<string, { count: number; files: Array<{ name: string; path: string; type: 'page' | 'component' }> }> = {};
151
+ for (const [name, data] of usageMap) {
152
+ result[name] = {
153
+ count: data.count,
154
+ files: Array.from(data.files.values()),
155
+ };
156
+ }
157
+
158
+ return jsonResponse(result);
159
+ }
160
+
99
161
  // Note: Write handlers (handleSaveComponentRoute, handleSaveComponentJavaScriptRoute,
100
162
  // handleSaveComponentCSSRoute, handleComponentCategoryRoute) moved to @meno/studio
101
163
 
@@ -201,6 +201,14 @@ export async function handleCoreApiRoutes(
201
201
  );
202
202
  }
203
203
 
204
+ // Component usage API route (read-only)
205
+ if (url.pathname === API_ROUTES.COMPONENT_USAGE && req.method === 'GET') {
206
+ return await handleRouteError(
207
+ () => Promise.resolve(componentsRoutes.handleComponentUsageRoute(pageService, componentService)),
208
+ 'Failed to fetch component usage'
209
+ );
210
+ }
211
+
204
212
  // Slug mappings API route (read-only)
205
213
  if (url.pathname === '/api/slug-mappings' && req.method === 'GET') {
206
214
  return await handleRouteError(
@@ -28,6 +28,8 @@ export interface IconsConfig {
28
28
  /**
29
29
  * Raw project config structure from project.config.json
30
30
  */
31
+ export type ImageFormat = 'webp' | 'avif';
32
+
31
33
  interface RawProjectConfig {
32
34
  breakpoints?: BreakpointConfigInput;
33
35
  responsiveScales?: Partial<ResponsiveScales>;
@@ -36,6 +38,7 @@ interface RawProjectConfig {
36
38
  libraries?: LibrariesConfig;
37
39
  csp?: CSPConfig;
38
40
  baseComponent?: string;
41
+ imageFormat?: ImageFormat;
39
42
  }
40
43
 
41
44
  /**
@@ -253,6 +256,15 @@ export class ConfigService {
253
256
  return this.config.baseComponent;
254
257
  }
255
258
 
259
+ /**
260
+ * Get image format setting
261
+ * Returns 'webp' (default) or 'avif'
262
+ */
263
+ getImageFormat(): ImageFormat {
264
+ if (this.config?.imageFormat === 'avif') return 'avif';
265
+ return 'webp';
266
+ }
267
+
256
268
  /**
257
269
  * Get raw config value by key (for extension)
258
270
  */
@@ -396,11 +396,6 @@ button {
396
396
  cursor: pointer;
397
397
  outline: inherit;
398
398
  }
399
- img {
400
- display: block;
401
- width: 100%;
402
- height: 100%;
403
- }
404
399
  picture {
405
400
  display: block;
406
401
  }
@@ -135,7 +135,7 @@ export function extractImgSrc(imgTag: string): string | null {
135
135
  * Only rewrites images that exist in the metadata map (project images with generated variants).
136
136
  * External/unrecognized images are left unchanged.
137
137
  */
138
- export function rewriteRichTextImages(html: string, metadataMap: ImageMetadataMap): string {
138
+ export function rewriteRichTextImages(html: string, metadataMap: ImageMetadataMap, imageFormat?: 'webp' | 'avif'): string {
139
139
  return html.replace(/<img\b([^>]*)\/?>/gi, (fullMatch, innerAttrs: string) => {
140
140
  const src = extractImgSrc(fullMatch);
141
141
  if (!src) return fullMatch;
@@ -158,8 +158,8 @@ export function rewriteRichTextImages(html: string, metadataMap: ImageMetadataMa
158
158
 
159
159
  const sizes = DEFAULT_SIZES;
160
160
 
161
- // Render as <picture> with AVIF + WebP sources when AVIF is available
162
- if (metadata.avifSrcset) {
161
+ // Render as <picture> with AVIF + WebP sources when AVIF is available and format allows
162
+ if (metadata.avifSrcset && imageFormat !== 'webp') {
163
163
  return `<picture>` +
164
164
  `<source type="image/avif" srcset="${escapeHtml(metadata.avifSrcset)}" sizes="${escapeHtml(sizes)}" />` +
165
165
  `<source type="image/webp" srcset="${escapeHtml(metadata.srcset)}" sizes="${escapeHtml(sizes)}" />` +