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.
- package/build-static.test.ts +424 -0
- package/build-static.ts +100 -13
- package/lib/client/ClientInitializer.ts +4 -0
- package/lib/client/core/ComponentBuilder.ts +155 -16
- package/lib/client/core/builders/embedBuilder.ts +48 -6
- package/lib/client/core/builders/linkBuilder.ts +2 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/routing/Router.tsx +8 -1
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/__integration__/api-routes.test.ts +148 -0
- package/lib/server/__integration__/cms-integration.test.ts +161 -0
- package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
- package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
- package/lib/server/__integration__/static-assets.test.ts +80 -0
- package/lib/server/__integration__/test-helpers.ts +205 -0
- package/lib/server/ab/generateFunctions.ts +346 -0
- package/lib/server/ab/trackingScript.ts +45 -0
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/cms.ts +3 -2
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/cmsService.ts +0 -5
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.test.ts +950 -0
- package/lib/server/services/configService.ts +39 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.test.ts +992 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -3
- package/lib/server/ssr/imageMetadata.test.ts +168 -0
- package/lib/server/ssr/imageMetadata.ts +58 -0
- package/lib/server/ssr/jsCollector.test.ts +287 -0
- package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
- package/lib/server/ssr/ssrRenderer.ts +131 -15
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/fontLoader.test.ts +335 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.ts +43 -1
- package/lib/shared/libraryLoader.test.ts +392 -0
- package/lib/shared/linkUtils.ts +24 -0
- package/lib/shared/nodeUtils.test.ts +100 -0
- package/lib/shared/nodeUtils.ts +43 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
- package/lib/shared/richtext/htmlToTiptap.ts +46 -2
- package/lib/shared/richtext/tiptapToHtml.ts +65 -0
- package/lib/shared/richtext/types.ts +4 -1
- package/lib/shared/types/cms.ts +2 -0
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/experiments.ts +55 -0
- package/lib/shared/types/index.ts +10 -0
- package/lib/shared/utils.ts +2 -6
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +10 -2
- 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(/"/g, '"')
|
|
264
|
+
.replace(/'/g, "'")
|
|
265
|
+
.replace(/&/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
|
-
|
|
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
|
-
|
|
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 =
|
|
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}>${
|
|
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}>${
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1277
|
-
|
|
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);
|
package/lib/shared/constants.ts
CHANGED
|
@@ -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
|
+
});
|