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
@@ -27,6 +27,7 @@ import { validateStyleCoverage } from '../validateStyleCoverage';
27
27
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
28
28
  import type { InteractiveStyles } from '../../shared/types/styles';
29
29
  import { extractInteractiveStyleMappings, resolveExtractedMappings, hasInteractiveStyleMappings } from '../../shared/interactiveStyleMappings';
30
+ import { isCurrentLink } from '../../shared/linkUtils';
30
31
  import type { SlugMap } from '../../shared/slugTranslator';
31
32
  import { buildSlugIndex, getLocaleLinks, translatePath } from '../../shared/slugTranslator';
32
33
 
@@ -36,7 +37,7 @@ import { extractPageMeta, generateMetaTags } from './metaTagGenerator';
36
37
  import { collectComponentCSS } from './cssCollector';
37
38
  import { collectComponentJavaScript } from './jsCollector';
38
39
  import { CMSContext, processCMSTemplate, processCMSPropsTemplate, createI18nResolver, RAW_HTML_PREFIX } from './cmsSSRProcessor';
39
- import { ImageMetadataMap, DEFAULT_SIZES, buildImageMetadataMap } from './imageMetadata';
40
+ import { ImageMetadataMap, DEFAULT_SIZES, buildImageMetadataMap, rewriteRichTextImages } from './imageMetadata';
40
41
 
41
42
  /**
42
43
  * Image preload info for generating <link rel="preload"> tags in head
@@ -114,6 +115,28 @@ function getI18nResolver(ctx: SSRContext): ValueResolver | undefined {
114
115
  return createI18nResolver(ctx.locale, ctx.i18nConfig);
115
116
  }
116
117
 
118
+ /**
119
+ * Process style templates and convert to utility classes.
120
+ * Handles {{item.field}} patterns within list contexts.
121
+ */
122
+ function processStyleToClasses(
123
+ style: StyleObject | ResponsiveStyleObject | undefined,
124
+ ctx: SSRContext
125
+ ): string[] {
126
+ if (!style) return [];
127
+
128
+ let processedStyle = style;
129
+ const templateCtx = getTemplateContext(ctx);
130
+ if (templateCtx && !ctx.templateMode) {
131
+ processedStyle = processItemPropsTemplate(
132
+ style as Record<string, unknown>,
133
+ templateCtx,
134
+ getI18nResolver(ctx)
135
+ ) as StyleObject | ResponsiveStyleObject;
136
+ }
137
+ return responsiveStylesToClasses(processedStyle as ResponsiveStyleObject);
138
+ }
139
+
117
140
  /**
118
141
  * Evaluate the if condition on a node with full SSR context.
119
142
  * Handles boolean, mapping, and string template values.
@@ -211,6 +234,63 @@ function resolveFilterTemplates(
211
234
  return resolved;
212
235
  }
213
236
 
237
+ /**
238
+ * Expand Meno component markers in rich text HTML output.
239
+ * Scans for <div data-meno-component="Name" data-meno-props='{"key":"val"}'></div>
240
+ * and replaces them with rendered component HTML using the existing SSR component renderer.
241
+ */
242
+ async function expandRichTextComponents(html: string, ctx: SSRContext): Promise<string> {
243
+ // Quick check - if no component markers, return as-is
244
+ if (!html.includes('data-meno-component')) return html;
245
+
246
+ // Match component markers
247
+ const markerRegex = /<div\s+data-meno-component="([^"]+)"\s+data-meno-props="([^"]*)"[^>]*><\/div>/g;
248
+ const parts: (string | Promise<string>)[] = [];
249
+ let lastIndex = 0;
250
+ let match: RegExpExecArray | null;
251
+
252
+ while ((match = markerRegex.exec(html)) !== null) {
253
+ // Add text before this match
254
+ if (match.index > lastIndex) {
255
+ parts.push(html.slice(lastIndex, match.index));
256
+ }
257
+
258
+ const componentName = match[1];
259
+ let props: Record<string, unknown> = {};
260
+ try {
261
+ // Unescape HTML entities in the JSON
262
+ const propsStr = match[2]
263
+ .replace(/&quot;/g, '"')
264
+ .replace(/&#039;/g, "'")
265
+ .replace(/&amp;/g, '&');
266
+ props = JSON.parse(propsStr);
267
+ } catch {
268
+ // ignore parse errors
269
+ }
270
+
271
+ // Render the component using SSR registry
272
+ if (ssrComponentRegistry.has(componentName)) {
273
+ parts.push(
274
+ renderComponent(componentName, props, [], {}, ctx)
275
+ );
276
+ } else {
277
+ // Keep marker for unknown components
278
+ parts.push(match[0]);
279
+ }
280
+
281
+ lastIndex = match.index + match[0].length;
282
+ }
283
+
284
+ // Add remaining text
285
+ if (lastIndex < html.length) {
286
+ parts.push(html.slice(lastIndex));
287
+ }
288
+
289
+ // Resolve all promises
290
+ const resolved = await Promise.all(parts);
291
+ return resolved.join('');
292
+ }
293
+
214
294
  /**
215
295
  * Component registry for SSR (shared between requests)
216
296
  * Uses the shared SSRRegistry for consistency
@@ -551,6 +631,14 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
551
631
  }
552
632
 
553
633
  // Use configurable tag (defaults to 'div')
634
+ // If tag is explicitly false (or string "false" for backwards compatibility),
635
+ // render children without a container (fragment mode)
636
+ if (node.tag === false || node.tag === 'false') {
637
+ // Fragment mode - no container element, just children
638
+ // Note: emitTemplate requires a container, so templateHtml is not included
639
+ return childrenHTML;
640
+ }
641
+
554
642
  const tag = node.tag || 'div';
555
643
  return `<${tag}${classAttr}${listStyleAttr}${attrsStr}>${childrenHTML}${templateHtml}</${tag}>`;
556
644
  }
@@ -694,7 +782,10 @@ async function renderNode(
694
782
  }
695
783
  // Check for raw HTML marker (from rich-text fields) - don't escape
696
784
  if (text.startsWith(RAW_HTML_PREFIX)) {
697
- return text.slice(RAW_HTML_PREFIX.length);
785
+ let rawHtml = text.slice(RAW_HTML_PREFIX.length);
786
+ if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap);
787
+ rawHtml = await expandRichTextComponents(rawHtml, ctx);
788
+ return rawHtml;
698
789
  }
699
790
  return escapeHtml(text);
700
791
  }
@@ -710,7 +801,10 @@ async function renderNode(
710
801
  }
711
802
  // Check for raw HTML marker (from rich-text fields) - don't escape
712
803
  if (text.startsWith(RAW_HTML_PREFIX)) {
713
- return text.slice(RAW_HTML_PREFIX.length);
804
+ let rawHtml = text.slice(RAW_HTML_PREFIX.length);
805
+ if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap);
806
+ rawHtml = await expandRichTextComponents(rawHtml, ctx);
807
+ return rawHtml;
714
808
  }
715
809
  return escapeHtml(text);
716
810
  }
@@ -765,9 +859,12 @@ async function renderNode(
765
859
  // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements (same as client)
766
860
  const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
767
861
  ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
768
- ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity'],
862
+ ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title'],
769
863
  KEEP_CONTENT: true
770
864
  });
865
+ const optimizedHtml = ctx.imageMetadataMap
866
+ ? rewriteRichTextImages(sanitizedHtml, ctx.imageMetadataMap)
867
+ : sanitizedHtml;
771
868
 
772
869
  // Extract attributes from node
773
870
  const nodeAttributes = extractAttributesFromNode(node);
@@ -775,9 +872,9 @@ async function renderNode(
775
872
  // Build className array
776
873
  const classNames: string[] = ['oem'];
777
874
 
778
- // Convert styles to utility classes
875
+ // Convert styles to utility classes (process templates in style values)
779
876
  if (nodeStyle) {
780
- const utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
877
+ const utilityClasses = processStyleToClasses(nodeStyle, ctx);
781
878
  classNames.push(...utilityClasses);
782
879
  }
783
880
 
@@ -837,7 +934,7 @@ async function renderNode(
837
934
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
838
935
 
839
936
  // Always use span for embeds - valid inside <p> and other phrasing content
840
- return `<span${classAttr}${embedStyleAttr}${attrs}>${sanitizedHtml}</span>`;
937
+ return `<span${classAttr}${embedStyleAttr}${attrs}>${optimizedHtml}</span>`;
841
938
  }
842
939
 
843
940
  // Add attribute className if present (fallback when no interactive styles)
@@ -852,7 +949,7 @@ async function renderNode(
852
949
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
853
950
 
854
951
  // Always use span for embeds - valid inside <p> and other phrasing content
855
- return `<span${classAttr}${attrs}>${sanitizedHtml}</span>`;
952
+ return `<span${classAttr}${attrs}>${optimizedHtml}</span>`;
856
953
  }
857
954
 
858
955
  // Handle link nodes (render as <a> tag in SSR)
@@ -894,9 +991,9 @@ async function renderNode(
894
991
  // Build className array - start with olink base class
895
992
  const classNames: string[] = ['olink'];
896
993
 
897
- // Convert styles to utility classes
994
+ // Convert styles to utility classes (process templates in style values)
898
995
  if (nodeStyle) {
899
- const utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
996
+ const utilityClasses = processStyleToClasses(nodeStyle, ctx);
900
997
  classNames.push(...utilityClasses);
901
998
  }
902
999
 
@@ -953,6 +1050,11 @@ async function renderNode(
953
1050
  delete nodeAttributes.className;
954
1051
  delete nodeAttributes.class;
955
1052
 
1053
+ // Add is-current class when link href matches current page path
1054
+ if (pagePath && !ctx.templateMode && isCurrentLink(href, pagePath)) {
1055
+ classNames.push('is-current');
1056
+ }
1057
+
956
1058
  // Add target from link object if present (and not already set in attributes)
957
1059
  if (targetFromLink && !nodeAttributes.target) {
958
1060
  nodeAttributes.target = targetFromLink;
@@ -1013,8 +1115,8 @@ async function renderNode(
1013
1115
  // Validate that all styles can generate utility classes (build-time warnings)
1014
1116
  validateStyleCoverage(nodeStyle, `Node: ${nodeType || 'unknown'}`);
1015
1117
 
1016
- // Convert style object to utility class names
1017
- utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
1118
+ // Convert style object to utility class names (process templates in style values)
1119
+ utilityClasses = processStyleToClasses(nodeStyle, ctx);
1018
1120
  } else if (nodeProps.style) {
1019
1121
  // If no node.style but props have style, keep it for backward compatibility
1020
1122
  if (isResponsiveStyle(nodeProps.style) && breakpoints && viewportWidth) {
@@ -1101,6 +1203,12 @@ async function renderNode(
1101
1203
  return renderComponent(componentName, propsWithStyleAndAttrs, children, nodeAttributes, ctx);
1102
1204
  }
1103
1205
 
1206
+ // Component not found in registry - warn and return empty
1207
+ if (nodeType === NODE_TYPE.COMPONENT && componentName) {
1208
+ console.warn(`[Meno SSR] Component "${componentName}" not found in registry.`);
1209
+ return '';
1210
+ }
1211
+
1104
1212
  // Handle Link component for navigation (only for HTML nodes)
1105
1213
  if (tag === 'Link') {
1106
1214
  return renderLinkNode(propsWithStyleAndAttrs, children, ctx);
@@ -1273,9 +1381,17 @@ async function renderHtmlElement(
1273
1381
  ctx: SSRContext
1274
1382
  ): Promise<string> {
1275
1383
  // Build class attribute from utility classes
1276
- const classAttr = propsWithStyleAndAttrs.className
1277
- ? ` class="${escapeHtml(String(propsWithStyleAndAttrs.className))}"`
1278
- : '';
1384
+ let classValue = propsWithStyleAndAttrs.className ? String(propsWithStyleAndAttrs.className) : '';
1385
+
1386
+ // Add is-current class for <a> tags when href matches current page path
1387
+ if (tag === 'a' && ctx.pagePath && !ctx.templateMode) {
1388
+ const href = propsWithStyleAndAttrs.href as string | undefined;
1389
+ if (href && isCurrentLink(href, ctx.pagePath)) {
1390
+ classValue = classValue ? `${classValue} is-current` : 'is-current';
1391
+ }
1392
+ }
1393
+
1394
+ const classAttr = classValue ? ` class="${escapeHtml(classValue)}"` : '';
1279
1395
 
1280
1396
  // Build style attribute (for CSS variables and remaining inline styles)
1281
1397
  const styleString = styleToString(propsWithStyleAndAttrs.style as Record<string, string | number> | undefined);
@@ -21,6 +21,9 @@ export const API_ROUTES = {
21
21
  SAVE_COMPONENT: '/api/save-component',
22
22
  SAVE_COMPONENT_JS: '/api/save-component-js', // Save JavaScript to .js file
23
23
  SAVE_COMPONENT_CSS: '/api/save-component-css', // Save CSS to .css file
24
+ COMPONENT_CATEGORY: '/api/component-category', // Move component to category folder
25
+ COMPONENT_FOLDER: '/api/component-folder', // Create component folder
26
+ COMPONENT_FOLDERS: '/api/component-folders', // List all component folders
24
27
  COMPONENT_JS: '/api/component-js', // Get JavaScript from .js file
25
28
  CONFIG: '/api/config', // Get project config
26
29
  SAVE_CONFIG: '/api/save-config', // Save project config
@@ -0,0 +1,335 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { join } from 'path';
3
+ import { mkdirSync, writeFileSync, rmSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { setProjectRoot } from '../server/projectContext';
6
+
7
+ // Create a unique temp directory for test configs.
8
+ // setProjectRoot changes where projectPaths.config() points.
9
+ const testDir = join(tmpdir(), `fontLoader-test-${Date.now()}`);
10
+ mkdirSync(testDir, { recursive: true });
11
+ setProjectRoot(testDir);
12
+
13
+ // Helper to write a project.config.json in the test directory
14
+ function writeConfig(config: Record<string, unknown>): void {
15
+ writeFileSync(join(testDir, 'project.config.json'), JSON.stringify(config));
16
+ }
17
+
18
+ function removeConfig(): void {
19
+ try {
20
+ rmSync(join(testDir, 'project.config.json'));
21
+ } catch {
22
+ // file may not exist
23
+ }
24
+ }
25
+
26
+ // Write a rich config BEFORE the import resolves.
27
+ // This config is designed to exercise every code path in fontLoader.ts.
28
+ writeConfig({
29
+ fonts: [
30
+ // woff2 format, explicit family, variable weight range, fontDisplay
31
+ {
32
+ path: '/fonts/inter-variable.woff2',
33
+ family: 'Inter',
34
+ weight: 100,
35
+ weightMax: 900,
36
+ style: 'normal',
37
+ fontDisplay: 'swap',
38
+ },
39
+ // woff format, no explicit family (extract from path), single weight
40
+ {
41
+ path: '/fonts/geomanist-regular.woff',
42
+ weight: 400,
43
+ },
44
+ // ttf format, explicit family, bold weight, italic style
45
+ {
46
+ path: '/fonts/roboto-bold.ttf',
47
+ family: 'Roboto',
48
+ weight: 700,
49
+ style: 'italic',
50
+ },
51
+ // otf format, explicit family, default weight, fontDisplay
52
+ {
53
+ path: '/fonts/playfair.otf',
54
+ family: 'Playfair',
55
+ fontDisplay: 'fallback',
56
+ },
57
+ // unknown extension, no explicit family (extract from path)
58
+ {
59
+ path: '/fonts/mystery.xyz',
60
+ },
61
+ // same family as existing to test weight dedup
62
+ {
63
+ path: '/fonts/roboto-regular.woff2',
64
+ family: 'Roboto',
65
+ weight: 400,
66
+ },
67
+ // variable font for same family as static (tests dedup across variable/static)
68
+ {
69
+ path: '/fonts/roboto-variable.woff2',
70
+ family: 'Roboto',
71
+ weight: 300,
72
+ weightMax: 500,
73
+ },
74
+ // single-weight variable font (weight === weightMax)
75
+ {
76
+ path: '/fonts/mono.woff2',
77
+ family: 'Mono',
78
+ weight: 400,
79
+ weightMax: 400,
80
+ },
81
+ ],
82
+ siteUrl: 'https://example.com',
83
+ });
84
+
85
+ // Import the module under test. Since setProjectRoot points to our temp dir
86
+ // and the config file is already written, loadProjectConfig will find it.
87
+ import {
88
+ loadProjectConfig,
89
+ getProjectConfig,
90
+ generateFontCSS,
91
+ generateFontPreloadTags,
92
+ getFontFamilies,
93
+ } from './fontLoader';
94
+
95
+ describe('fontLoader', () => {
96
+ // --- getProjectConfig before loading ---
97
+
98
+ test('getProjectConfig returns fallback before any config is loaded', () => {
99
+ const config = getProjectConfig();
100
+ expect(config).toEqual({ fonts: [] });
101
+ });
102
+
103
+ // --- loadProjectConfig ---
104
+
105
+ test('loadProjectConfig loads config from file', async () => {
106
+ const config = await loadProjectConfig();
107
+ expect(config.fonts).toHaveLength(8);
108
+ expect(config.siteUrl).toBe('https://example.com');
109
+ });
110
+
111
+ test('loadProjectConfig returns cached config on second call', async () => {
112
+ const config1 = await loadProjectConfig();
113
+ const config2 = await loadProjectConfig();
114
+ expect(config1).toBe(config2);
115
+ });
116
+
117
+ // --- getProjectConfig after loading ---
118
+
119
+ test('getProjectConfig returns loaded config after loadProjectConfig', () => {
120
+ const config = getProjectConfig();
121
+ expect(config.fonts).toHaveLength(8);
122
+ expect(config.siteUrl).toBe('https://example.com');
123
+ });
124
+
125
+ // --- generateFontCSS: format detection (getFontFormat) ---
126
+
127
+ test('generateFontCSS detects woff2 format', () => {
128
+ const css = generateFontCSS();
129
+ expect(css).toContain("url('/fonts/inter-variable.woff2') format('woff2')");
130
+ });
131
+
132
+ test('generateFontCSS detects woff format', () => {
133
+ const css = generateFontCSS();
134
+ expect(css).toContain("url('/fonts/geomanist-regular.woff') format('woff')");
135
+ });
136
+
137
+ test('generateFontCSS detects truetype format for .ttf', () => {
138
+ const css = generateFontCSS();
139
+ expect(css).toContain("url('/fonts/roboto-bold.ttf') format('truetype')");
140
+ });
141
+
142
+ test('generateFontCSS detects opentype format for .otf', () => {
143
+ const css = generateFontCSS();
144
+ expect(css).toContain("url('/fonts/playfair.otf') format('opentype')");
145
+ });
146
+
147
+ test('generateFontCSS defaults to truetype for unknown extension', () => {
148
+ const css = generateFontCSS();
149
+ expect(css).toContain("url('/fonts/mystery.xyz') format('truetype')");
150
+ });
151
+
152
+ // --- generateFontCSS: family name extraction (extractFamilyName) ---
153
+
154
+ test('generateFontCSS extracts family name from path when not provided', () => {
155
+ const css = generateFontCSS();
156
+ // /fonts/geomanist-regular.woff -> "Geomanist Regular"
157
+ expect(css).toContain("font-family: 'Geomanist Regular'");
158
+ });
159
+
160
+ test('generateFontCSS extracts family from path with unrecognized extension', () => {
161
+ const css = generateFontCSS();
162
+ // mystery.xyz -> regex /\.(ttf|woff2?|otf)$/i does not match .xyz
163
+ // so name includes the extension: "Mystery.xyz"
164
+ expect(css).toContain("font-family: 'Mystery.xyz'");
165
+ });
166
+
167
+ test('generateFontCSS uses explicit family names when provided', () => {
168
+ const css = generateFontCSS();
169
+ expect(css).toContain("font-family: 'Inter'");
170
+ expect(css).toContain("font-family: 'Roboto'");
171
+ expect(css).toContain("font-family: 'Playfair'");
172
+ expect(css).toContain("font-family: 'Mono'");
173
+ });
174
+
175
+ // --- generateFontCSS: weight handling ---
176
+
177
+ test('generateFontCSS outputs variable weight range syntax', () => {
178
+ const css = generateFontCSS();
179
+ expect(css).toContain('font-weight: 100 900');
180
+ expect(css).toContain('font-weight: 300 500');
181
+ expect(css).toContain('font-weight: 400 400');
182
+ });
183
+
184
+ test('generateFontCSS outputs single weight values', () => {
185
+ const css = generateFontCSS();
186
+ expect(css).toContain('font-weight: 400;');
187
+ expect(css).toContain('font-weight: 700;');
188
+ });
189
+
190
+ // --- generateFontCSS: style ---
191
+
192
+ test('generateFontCSS outputs font-style normal and italic', () => {
193
+ const css = generateFontCSS();
194
+ expect(css).toContain('font-style: normal');
195
+ expect(css).toContain('font-style: italic');
196
+ });
197
+
198
+ // --- generateFontCSS: fontDisplay ---
199
+
200
+ test('generateFontCSS includes font-display when specified', () => {
201
+ const css = generateFontCSS();
202
+ expect(css).toContain('font-display: swap');
203
+ expect(css).toContain('font-display: fallback');
204
+ });
205
+
206
+ test('generateFontCSS omits font-display when not specified', () => {
207
+ const css = generateFontCSS();
208
+ // The Roboto bold entry has no fontDisplay
209
+ const robotoBlock = css.split('\n\n').find(b => b.includes('roboto-bold.ttf'))!;
210
+ expect(robotoBlock).toBeDefined();
211
+ expect(robotoBlock).not.toContain('font-display');
212
+ });
213
+
214
+ // --- generateFontCSS: full block structure ---
215
+
216
+ test('generateFontCSS produces correct block with fontDisplay', () => {
217
+ const css = generateFontCSS();
218
+ expect(css).toContain(`@font-face {
219
+ font-family: 'Inter';
220
+ src: url('/fonts/inter-variable.woff2') format('woff2');
221
+ font-weight: 100 900;
222
+ font-style: normal;
223
+ font-display: swap;
224
+ }`);
225
+ });
226
+
227
+ test('generateFontCSS produces correct block without fontDisplay', () => {
228
+ const css = generateFontCSS();
229
+ expect(css).toContain(`@font-face {
230
+ font-family: 'Roboto';
231
+ src: url('/fonts/roboto-bold.ttf') format('truetype');
232
+ font-weight: 700;
233
+ font-style: italic;
234
+ }`);
235
+ });
236
+
237
+ test('generateFontCSS separates blocks with blank lines', () => {
238
+ const css = generateFontCSS();
239
+ const blocks = css.split('\n\n');
240
+ expect(blocks.length).toBe(8);
241
+ });
242
+
243
+ // --- generateFontPreloadTags: MIME types (getFontMimeType) ---
244
+
245
+ test('generateFontPreloadTags uses font/woff2 for .woff2', () => {
246
+ const tags = generateFontPreloadTags();
247
+ expect(tags).toContain('href="/fonts/inter-variable.woff2"');
248
+ expect(tags).toContain('type="font/woff2"');
249
+ });
250
+
251
+ test('generateFontPreloadTags uses font/woff for .woff', () => {
252
+ const tags = generateFontPreloadTags();
253
+ expect(tags).toContain('href="/fonts/geomanist-regular.woff"');
254
+ expect(tags).toContain('type="font/woff"');
255
+ });
256
+
257
+ test('generateFontPreloadTags uses font/ttf for .ttf', () => {
258
+ const tags = generateFontPreloadTags();
259
+ expect(tags).toContain('href="/fonts/roboto-bold.ttf"');
260
+ expect(tags).toContain('type="font/ttf"');
261
+ });
262
+
263
+ test('generateFontPreloadTags uses font/otf for .otf', () => {
264
+ const tags = generateFontPreloadTags();
265
+ expect(tags).toContain('href="/fonts/playfair.otf"');
266
+ expect(tags).toContain('type="font/otf"');
267
+ });
268
+
269
+ test('generateFontPreloadTags defaults to font/ttf for unknown extension', () => {
270
+ const tags = generateFontPreloadTags();
271
+ const mysteryTag = tags.split('\n ').find(t => t.includes('mystery.xyz'))!;
272
+ expect(mysteryTag).toContain('type="font/ttf"');
273
+ });
274
+
275
+ // --- generateFontPreloadTags: structure ---
276
+
277
+ test('generateFontPreloadTags includes crossorigin on all tags', () => {
278
+ const tags = generateFontPreloadTags();
279
+ const linkTags = tags.split('\n ');
280
+ expect(linkTags.length).toBe(8);
281
+ for (const tag of linkTags) {
282
+ expect(tag).toContain('crossorigin');
283
+ expect(tag).toContain('rel="preload"');
284
+ expect(tag).toContain('as="font"');
285
+ }
286
+ });
287
+
288
+ test('generateFontPreloadTags produces correct link tag format', () => {
289
+ const tags = generateFontPreloadTags();
290
+ expect(tags).toContain(
291
+ '<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin>'
292
+ );
293
+ });
294
+
295
+ // --- getFontFamilies ---
296
+
297
+ test('getFontFamilies expands variable weight range in 100 increments', () => {
298
+ const families = getFontFamilies();
299
+ expect(families['Inter']).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900]);
300
+ });
301
+
302
+ test('getFontFamilies extracts family name from path', () => {
303
+ const families = getFontFamilies();
304
+ expect(families['Geomanist Regular']).toEqual([400]);
305
+ });
306
+
307
+ test('getFontFamilies groups weights and deduplicates across static and variable', () => {
308
+ const families = getFontFamilies();
309
+ // Roboto: 700 (bold static), 400 (regular static), 300-500 variable (400 deduped)
310
+ expect(families['Roboto']).toContain(700);
311
+ expect(families['Roboto']).toContain(400);
312
+ expect(families['Roboto']).toContain(300);
313
+ expect(families['Roboto']).toContain(500);
314
+ // No duplicates
315
+ const unique = [...new Set(families['Roboto'])];
316
+ expect(families['Roboto'].length).toBe(unique.length);
317
+ });
318
+
319
+ test('getFontFamilies uses default weight 400 when not specified', () => {
320
+ const families = getFontFamilies();
321
+ expect(families['Playfair']).toEqual([400]);
322
+ });
323
+
324
+ test('getFontFamilies handles single-weight variable font', () => {
325
+ const families = getFontFamilies();
326
+ // Mono: weight=400, weightMax=400 -> [400]
327
+ expect(families['Mono']).toEqual([400]);
328
+ });
329
+
330
+ test('getFontFamilies handles extracted name for unknown extension', () => {
331
+ const families = getFontFamilies();
332
+ // mystery.xyz -> "Mystery.xyz"
333
+ expect(families['Mystery.xyz']).toEqual([400]);
334
+ });
335
+ });