meno-core 1.1.5 → 1.1.7
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/dist/chunks/{chunk-RLBV2R6J.js → chunk-KX2LPIZZ.js} +68 -1
- package/dist/chunks/{chunk-RLBV2R6J.js.map → chunk-KX2LPIZZ.js.map} +2 -2
- package/dist/chunks/{chunk-H6EOTPJA.js → chunk-WOJS25K3.js} +45 -13
- package/dist/chunks/chunk-WOJS25K3.js.map +7 -0
- package/dist/lib/client/index.js +56 -12
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +85 -19
- package/dist/lib/server/index.js.map +3 -3
- package/dist/lib/shared/index.js +7 -1
- package/dist/lib/shared/index.js.map +1 -1
- package/lib/client/core/ComponentBuilder.test.ts +137 -0
- package/lib/client/core/ComponentBuilder.ts +52 -13
- package/lib/client/core/builders/embedBuilder.ts +15 -1
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -1
- package/lib/client/core/builders/localeListBuilder.ts +15 -1
- package/lib/client/templateEngine.test.ts +106 -3
- package/lib/client/templateEngine.ts +50 -10
- package/lib/server/ssr/htmlGenerator.test.ts +105 -2
- package/lib/server/ssr/htmlGenerator.ts +92 -32
- package/lib/server/ssr/ssrRenderer.test.ts +133 -0
- package/lib/server/ssr/ssrRenderer.ts +59 -14
- package/lib/shared/attributeNodeUtils.test.ts +25 -0
- package/lib/shared/attributeNodeUtils.ts +9 -1
- package/lib/shared/templateStyleVars.ts +49 -0
- package/lib/shared/utilityClassMapper.test.ts +80 -0
- package/lib/shared/utilityClassMapper.ts +116 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-H6EOTPJA.js.map +0 -7
|
@@ -46,8 +46,13 @@ import {
|
|
|
46
46
|
} from '../../shared/itemTemplateUtils';
|
|
47
47
|
import { DEFAULT_I18N_CONFIG, isI18nValue, resolveI18nValue } from '../../shared/i18n';
|
|
48
48
|
import { getChildPath, pathToString } from '../../shared/pathArrayUtils';
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
responsiveStylesToClasses,
|
|
51
|
+
mergeInstanceClasses,
|
|
52
|
+
stripClassOverriddenStyleProps,
|
|
53
|
+
} from '../../shared/utilityClassMapper';
|
|
50
54
|
import { getCachedResponsiveScalesConfig } from '../responsiveStyleResolver';
|
|
55
|
+
import { getTemplateVars, TEMPLATE_VARS_KEY } from '../../shared/templateStyleVars';
|
|
51
56
|
import { UtilityClassCollector } from '../styles/UtilityClassCollector';
|
|
52
57
|
import { processCMSTemplate, processCMSPropsTemplate, RAW_HTML_PREFIX } from './cmsTemplateProcessor';
|
|
53
58
|
import type { PrefetchService } from '../services/PrefetchService';
|
|
@@ -762,6 +767,14 @@ export class ComponentBuilder {
|
|
|
762
767
|
): Record<string, unknown> {
|
|
763
768
|
const nodeStyle = node.style || (isComponentNode(node) ? node.props?.style : undefined);
|
|
764
769
|
|
|
770
|
+
// Template-bound style values were var-bridged by processStructure (the style carries
|
|
771
|
+
// `var(--m-<bp>-<prop>)`, the concrete value lives on the node) — thread the assignments
|
|
772
|
+
// through props so buildHtmlElement can set them inline via its ref. See templateStyleVars.ts.
|
|
773
|
+
const templateVars = getTemplateVars(node);
|
|
774
|
+
if (templateVars) {
|
|
775
|
+
props = { ...props, [TEMPLATE_VARS_KEY]: templateVars };
|
|
776
|
+
}
|
|
777
|
+
|
|
765
778
|
if (nodeStyle && typeof nodeStyle === 'object') {
|
|
766
779
|
// Process item templates in style values (for List context)
|
|
767
780
|
let processedStyle = nodeStyle as Record<string, unknown>;
|
|
@@ -1128,26 +1141,38 @@ export class ComponentBuilder {
|
|
|
1128
1141
|
// root. A `<Heading class="text-muted p-[24px]" />` instance carries its
|
|
1129
1142
|
// override as props.class — the class-based equivalent of the legacy
|
|
1130
1143
|
// instance style object. SSR applies it onto the component root
|
|
1131
|
-
// (ssrRenderer.ts
|
|
1144
|
+
// (ssrRenderer.ts renderComponent); the live client canvas must too, or
|
|
1132
1145
|
// instance-level classes silently vanish in Meno Select while rendering
|
|
1133
|
-
// correctly in a real Astro build.
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1146
|
+
// correctly in a real Astro build. Conflict-aware, instance-over-root: a
|
|
1147
|
+
// root class targeting the same (breakpoint, CSS property) is dropped, and
|
|
1148
|
+
// the same properties are stripped from the root's style object — a
|
|
1149
|
+
// prop-bound root style (maxWidth: {_mapping} → "100%") would otherwise
|
|
1150
|
+
// mint a root utility class (max-w-full) that fights the instance's
|
|
1151
|
+
// max-w-[283px] on stylesheet order (equal specificity). Mirrors the
|
|
1152
|
+
// meno-astro runtime's mergeInstanceClasses/inlineStyle pair. Collect the
|
|
1153
|
+
// tokens so their CSS is injected into the canvas's utility sheet
|
|
1154
|
+
// (html/link roots also get it via the recursive mergeAttributes;
|
|
1155
|
+
// collecting here covers component roots).
|
|
1137
1156
|
const instanceClass = typeof finalProps.class === 'string' ? finalProps.class : '';
|
|
1138
1157
|
if (instanceClass) {
|
|
1139
1158
|
const root = processedStructure as ComponentNode & {
|
|
1140
1159
|
props?: Record<string, unknown>;
|
|
1141
1160
|
attributes?: Record<string, string | number | boolean>;
|
|
1161
|
+
style?: StyleValue;
|
|
1142
1162
|
};
|
|
1163
|
+
if (root.style) {
|
|
1164
|
+
const stripped = stripClassOverriddenStyleProps(root.style, instanceClass);
|
|
1165
|
+
if (stripped) root.style = stripped;
|
|
1166
|
+
else delete root.style;
|
|
1167
|
+
}
|
|
1143
1168
|
if (isHtmlNode(root) || isLinkNode(root)) {
|
|
1144
1169
|
if (!root.attributes) root.attributes = {};
|
|
1145
|
-
const
|
|
1146
|
-
root.attributes.class =
|
|
1170
|
+
const own = ((root.attributes.class || '') as string).split(/\s+/).filter(Boolean);
|
|
1171
|
+
root.attributes.class = mergeInstanceClasses(own, instanceClass).join(' ');
|
|
1147
1172
|
} else {
|
|
1148
1173
|
if (!root.props) root.props = {};
|
|
1149
|
-
const
|
|
1150
|
-
root.props.className =
|
|
1174
|
+
const own = ((root.props.className || '') as string).split(/\s+/).filter(Boolean);
|
|
1175
|
+
root.props.className = mergeInstanceClasses(own, instanceClass).join(' ');
|
|
1151
1176
|
}
|
|
1152
1177
|
const tokens = instanceClass.split(/\s+/).filter(Boolean);
|
|
1153
1178
|
if (tokens.length > 0) UtilityClassCollector.collect(tokens);
|
|
@@ -1230,19 +1255,24 @@ export class ComponentBuilder {
|
|
|
1230
1255
|
const { __componentProps, ...customPropsForDOM } = customProps || {};
|
|
1231
1256
|
const isComponentRoot = !!(customProps && '__componentProps' in customProps);
|
|
1232
1257
|
|
|
1258
|
+
// Pull the var-bridge assignments off props (threaded by applyStyleClasses) — they're
|
|
1259
|
+
// applied to the DOM element in the ref below, never spread as a React prop.
|
|
1260
|
+
const { [TEMPLATE_VARS_KEY]: templateVarsRaw, ...propsSansTemplateVars } = props;
|
|
1261
|
+
const templateVars = templateVarsRaw as Record<string, string> | undefined;
|
|
1262
|
+
|
|
1233
1263
|
// Strip responsive style objects (e.g. { base: { objectFit: 'cover' } }) from inline style.
|
|
1234
1264
|
// These produce invalid React inline styles — utility classes handle all styling.
|
|
1235
1265
|
// Flat style objects are kept as inline style for backward compatibility.
|
|
1236
1266
|
// The style prop is preserved in applyStyleClasses for component instance style merging
|
|
1237
1267
|
// (buildCustomComponent reads finalProps.style via mergeNodeStyles).
|
|
1238
|
-
let propsForElement =
|
|
1239
|
-
const nodeStyle =
|
|
1268
|
+
let propsForElement = propsSansTemplateVars;
|
|
1269
|
+
const nodeStyle = propsSansTemplateVars.style;
|
|
1240
1270
|
if (
|
|
1241
1271
|
nodeStyle &&
|
|
1242
1272
|
typeof nodeStyle === 'object' &&
|
|
1243
1273
|
('base' in (nodeStyle as object) || 'tablet' in (nodeStyle as object) || 'mobile' in (nodeStyle as object))
|
|
1244
1274
|
) {
|
|
1245
|
-
const { style: _, ...rest } =
|
|
1275
|
+
const { style: _, ...rest } = propsSansTemplateVars;
|
|
1246
1276
|
propsForElement = rest;
|
|
1247
1277
|
}
|
|
1248
1278
|
|
|
@@ -1311,6 +1341,15 @@ export class ComponentBuilder {
|
|
|
1311
1341
|
el.style.setProperty(varName, value);
|
|
1312
1342
|
}
|
|
1313
1343
|
}
|
|
1344
|
+
|
|
1345
|
+
// Apply var-bridge assignments for template-bound style values — the element's
|
|
1346
|
+
// utility classes read var(--m-<bp>-<prop>); the concrete value is set here,
|
|
1347
|
+
// mirroring the meno-astro runtime's inline style. See templateStyleVars.ts.
|
|
1348
|
+
if (templateVars) {
|
|
1349
|
+
for (const [varName, value] of Object.entries(templateVars)) {
|
|
1350
|
+
el.style.setProperty(varName, value);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1314
1353
|
}
|
|
1315
1354
|
|
|
1316
1355
|
const componentProps =
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveExtractedMappings,
|
|
19
19
|
} from '../../../shared/interactiveStyleMappings';
|
|
20
20
|
import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
|
|
21
|
+
import { getTemplateVars } from '../../../shared/templateStyleVars';
|
|
21
22
|
import DOMPurify from 'isomorphic-dompurify';
|
|
22
23
|
import { inlineSvgStyleRules } from '../../../shared/inlineSvgStyleRules';
|
|
23
24
|
import type { ElementRegistry } from '../../elementRegistry';
|
|
@@ -291,6 +292,14 @@ export function buildEmbed(node: EmbedNode, ctx: BuilderContext, deps: EmbedBuil
|
|
|
291
292
|
el.style.setProperty(varName, value);
|
|
292
293
|
}
|
|
293
294
|
}
|
|
295
|
+
// Var-bridge for template-bound style values (see templateStyleVars.ts) —
|
|
296
|
+
// the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
|
|
297
|
+
const templateVars = getTemplateVars(node);
|
|
298
|
+
if (templateVars) {
|
|
299
|
+
for (const [varName, value] of Object.entries(templateVars)) {
|
|
300
|
+
el.style.setProperty(varName, value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
294
303
|
}
|
|
295
304
|
},
|
|
296
305
|
dangerouslySetInnerHTML: { __html: sanitizedHtml },
|
|
@@ -399,7 +408,12 @@ export function buildEmbed(node: EmbedNode, ctx: BuilderContext, deps: EmbedBuil
|
|
|
399
408
|
(extractedAttributes as Record<string, unknown>).className ??
|
|
400
409
|
'') as string;
|
|
401
410
|
if (attrClass) {
|
|
402
|
-
|
|
411
|
+
// Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
|
|
412
|
+
// must inject their CSS into the live canvas, same as ComponentBuilder does for
|
|
413
|
+
// element nodes. collect() skips foreign tokens that generate no rule.
|
|
414
|
+
const classTokens = attrClass.split(/\s+/).filter(Boolean);
|
|
415
|
+
UtilityClassCollector.collect(classTokens);
|
|
416
|
+
classNames.push(...classTokens);
|
|
403
417
|
}
|
|
404
418
|
delete (extractedAttributes as Record<string, unknown>).class;
|
|
405
419
|
delete (extractedAttributes as Record<string, unknown>).className;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '../../../shared/interactiveStyleMappings';
|
|
20
20
|
import { processItemPropsTemplate, type ValueResolver } from '../../../shared/itemTemplateUtils';
|
|
21
21
|
import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
|
|
22
|
+
import { getTemplateVars } from '../../../shared/templateStyleVars';
|
|
22
23
|
import { resolveI18nValue, DEFAULT_I18N_CONFIG } from '../../../shared/i18n';
|
|
23
24
|
import { isCurrentLink } from '../../../shared/linkUtils';
|
|
24
25
|
import type { ElementRegistry } from '../../elementRegistry';
|
|
@@ -87,6 +88,14 @@ export function buildLinkNode(
|
|
|
87
88
|
el.style.setProperty(varName, value);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
91
|
+
// Var-bridge for template-bound style values (see templateStyleVars.ts) —
|
|
92
|
+
// the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
|
|
93
|
+
const templateVars = getTemplateVars(node);
|
|
94
|
+
if (templateVars) {
|
|
95
|
+
for (const [varName, value] of Object.entries(templateVars)) {
|
|
96
|
+
el.style.setProperty(varName, value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
90
99
|
}
|
|
91
100
|
},
|
|
92
101
|
};
|
|
@@ -194,7 +203,12 @@ export function buildLinkNode(
|
|
|
194
203
|
(extractedAttributes as Record<string, unknown>).className ??
|
|
195
204
|
'') as string;
|
|
196
205
|
if (attrClass) {
|
|
197
|
-
|
|
206
|
+
// Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
|
|
207
|
+
// must inject their CSS into the live canvas, same as ComponentBuilder does for
|
|
208
|
+
// element nodes. collect() skips foreign tokens that generate no rule.
|
|
209
|
+
const classTokens = attrClass.split(/\s+/).filter(Boolean);
|
|
210
|
+
UtilityClassCollector.collect(classTokens);
|
|
211
|
+
classNames.push(...classTokens);
|
|
198
212
|
}
|
|
199
213
|
delete (extractedAttributes as Record<string, unknown>).class;
|
|
200
214
|
delete (extractedAttributes as Record<string, unknown>).className;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveExtractedMappings,
|
|
19
19
|
} from '../../../shared/interactiveStyleMappings';
|
|
20
20
|
import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
|
|
21
|
+
import { getTemplateVars } from '../../../shared/templateStyleVars';
|
|
21
22
|
import type { ElementRegistry } from '../../elementRegistry';
|
|
22
23
|
import type { BuilderContext } from './types';
|
|
23
24
|
|
|
@@ -68,6 +69,14 @@ export function buildLocaleList(node: LocaleListNode, ctx: BuilderContext, deps:
|
|
|
68
69
|
el.style.setProperty(varName, value);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
// Var-bridge for template-bound style values (see templateStyleVars.ts) —
|
|
73
|
+
// the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
|
|
74
|
+
const templateVars = getTemplateVars(node);
|
|
75
|
+
if (templateVars) {
|
|
76
|
+
for (const [varName, value] of Object.entries(templateVars)) {
|
|
77
|
+
el.style.setProperty(varName, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
71
80
|
}
|
|
72
81
|
},
|
|
73
82
|
};
|
|
@@ -160,7 +169,12 @@ export function buildLocaleList(node: LocaleListNode, ctx: BuilderContext, deps:
|
|
|
160
169
|
(extractedAttributes as Record<string, unknown>).className ??
|
|
161
170
|
'') as string;
|
|
162
171
|
if (attrClass) {
|
|
163
|
-
|
|
172
|
+
// Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
|
|
173
|
+
// must inject their CSS into the live canvas, same as ComponentBuilder does for
|
|
174
|
+
// element nodes. collect() skips foreign tokens that generate no rule.
|
|
175
|
+
const classTokens = attrClass.split(/\s+/).filter(Boolean);
|
|
176
|
+
UtilityClassCollector.collect(classTokens);
|
|
177
|
+
classNames.push(...classTokens);
|
|
164
178
|
}
|
|
165
179
|
delete (extractedAttributes as Record<string, unknown>).class;
|
|
166
180
|
delete (extractedAttributes as Record<string, unknown>).className;
|
|
@@ -605,8 +605,14 @@ describe('Template Engine - processStructure', () => {
|
|
|
605
605
|
const node = result as ComponentNode;
|
|
606
606
|
const baseStyle = (node.style as any)?.base;
|
|
607
607
|
expect(baseStyle).toBeDefined();
|
|
608
|
-
|
|
609
|
-
|
|
608
|
+
// Template-bound values are var-bridged (templateStyleVars.ts): the style reads a
|
|
609
|
+
// CSS variable (so the class is never auto-responsive-scaled, matching the real
|
|
610
|
+
// Astro build) and the concrete resolved value rides on __templateVars for the
|
|
611
|
+
// element's inline style.
|
|
612
|
+
expect(baseStyle?.opacity).toBe('var(--m-base-opacity)');
|
|
613
|
+
expect(baseStyle?.fontSize).toBe('var(--m-base-font-size)');
|
|
614
|
+
const vars = (node as any).__templateVars;
|
|
615
|
+
expect(vars).toEqual({ '--m-base-opacity': '0', '--m-base-font-size': '16px' });
|
|
610
616
|
});
|
|
611
617
|
|
|
612
618
|
test('defers full templates containing dotted terms (e.g. decimals) to item processing', () => {
|
|
@@ -647,12 +653,109 @@ describe('Template Engine - processStructure', () => {
|
|
|
647
653
|
const result = processStructure(structure, createContext({}));
|
|
648
654
|
const node = result as ComponentNode;
|
|
649
655
|
const baseStyle = (node.style as any)?.base;
|
|
650
|
-
|
|
656
|
+
// Var-bridged (see templateStyleVars.ts) — resolved value lands on __templateVars.
|
|
657
|
+
expect(baseStyle?.display).toBe('var(--m-base-display)');
|
|
658
|
+
expect((node as any).__templateVars).toEqual({ '--m-base-display': 'none' });
|
|
651
659
|
|
|
652
660
|
resetGlobalTemplateContext();
|
|
653
661
|
});
|
|
654
662
|
});
|
|
655
663
|
|
|
664
|
+
describe('Template style var-bridge (templateStyleVars.ts)', () => {
|
|
665
|
+
test('bridges per breakpoint: tablet/mobile templates get breakpoint-scoped var names', () => {
|
|
666
|
+
const structure: ComponentNode = {
|
|
667
|
+
type: 'node',
|
|
668
|
+
tag: 'div',
|
|
669
|
+
style: {
|
|
670
|
+
base: { gap: '{{gap}}px' },
|
|
671
|
+
tablet: { gap: '{{gapTablet}}px' },
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
const result = processStructure(structure, createContext({ gap: 40, gapTablet: 24 })) as ComponentNode;
|
|
675
|
+
expect((result.style as any).base.gap).toBe('var(--m-base-gap)');
|
|
676
|
+
expect((result.style as any).tablet.gap).toBe('var(--m-tablet-gap)');
|
|
677
|
+
expect((result as any).__templateVars).toEqual({
|
|
678
|
+
'--m-base-gap': '40px',
|
|
679
|
+
'--m-tablet-gap': '24px',
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('bridges flat (non-responsive) style objects as base', () => {
|
|
684
|
+
const structure: ComponentNode = {
|
|
685
|
+
type: 'node',
|
|
686
|
+
tag: 'div',
|
|
687
|
+
style: { maxWidth: '{{maxWidth}}' } as any,
|
|
688
|
+
};
|
|
689
|
+
const result = processStructure(structure, createContext({ maxWidth: '283px' })) as ComponentNode;
|
|
690
|
+
expect((result.style as any).maxWidth).toBe('var(--m-base-max-width)');
|
|
691
|
+
expect((result as any).__templateVars).toEqual({ '--m-base-max-width': '283px' });
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('does NOT bridge _mapping values — they resolve to static, auto-scalable classes', () => {
|
|
695
|
+
const structure: ComponentNode = {
|
|
696
|
+
type: 'node',
|
|
697
|
+
tag: 'div',
|
|
698
|
+
style: {
|
|
699
|
+
base: { padding: { _mapping: true, prop: 'size', values: { small: '8px', large: '24px' } } },
|
|
700
|
+
} as any,
|
|
701
|
+
};
|
|
702
|
+
const result = processStructure(structure, createContext({ size: 'large' })) as ComponentNode;
|
|
703
|
+
expect((result.style as any).base.padding).toBe('24px');
|
|
704
|
+
expect((result as any).__templateVars).toBeUndefined();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('undefined prop still bridges — the empty substitution yields an invalid var value, so the property is unset (matching Astro)', () => {
|
|
708
|
+
const structure: ComponentNode = {
|
|
709
|
+
type: 'node',
|
|
710
|
+
tag: 'div',
|
|
711
|
+
style: { base: { gap: '{{gap}}px' } },
|
|
712
|
+
};
|
|
713
|
+
const result = processStructure(structure, createContext({})) as ComponentNode;
|
|
714
|
+
expect((result.style as any).base.gap).toBe('var(--m-base-gap)');
|
|
715
|
+
expect((result as any).__templateVars).toEqual({ '--m-base-gap': 'px' });
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('does NOT bridge a template preserved verbatim by processCodeTemplates', () => {
|
|
719
|
+
// When the template survives resolution unchanged (here: the DoS length guard
|
|
720
|
+
// returns the original {{...}} match), the resolved value still contains a
|
|
721
|
+
// template and the bridge skips it — legacy pass-through.
|
|
722
|
+
const tooLong = `{{${'a'.repeat(501)}}}px`;
|
|
723
|
+
const structure: ComponentNode = {
|
|
724
|
+
type: 'node',
|
|
725
|
+
tag: 'div',
|
|
726
|
+
style: { base: { gap: tooLong } },
|
|
727
|
+
};
|
|
728
|
+
const result = processStructure(structure, createContext({ gap: 40 })) as ComponentNode;
|
|
729
|
+
expect((result.style as any).base.gap).toBe(tooLong);
|
|
730
|
+
expect((result as any).__templateVars).toBeUndefined();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test('does NOT bridge item templates ({{item.x}} resolves later during rendering)', () => {
|
|
734
|
+
const structure: ComponentNode = {
|
|
735
|
+
type: 'node',
|
|
736
|
+
tag: 'div',
|
|
737
|
+
style: { base: { color: '{{item.color}}' } },
|
|
738
|
+
};
|
|
739
|
+
const result = processStructure(structure, createContext({})) as ComponentNode;
|
|
740
|
+
expect((result.style as any).base.color).toBe('{{item.color}}');
|
|
741
|
+
expect((result as any).__templateVars).toBeUndefined();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test('does NOT bridge component INSTANCE node styles (they merge into the child root)', () => {
|
|
745
|
+
const structure: ComponentNode = {
|
|
746
|
+
type: 'component',
|
|
747
|
+
component: 'Card',
|
|
748
|
+
props: {},
|
|
749
|
+
style: { base: { gap: '{{gap}}px' } },
|
|
750
|
+
} as ComponentNode;
|
|
751
|
+
const result = processStructure(structure, createContext({ gap: 40 })) as ComponentNode;
|
|
752
|
+
// Instance styles keep the concrete resolved value — the child structure root
|
|
753
|
+
// renders them, and the vars would otherwise be applied to no element.
|
|
754
|
+
expect((result.style as any).base.gap).toBe('40px');
|
|
755
|
+
expect((result as any).__templateVars).toBeUndefined();
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
656
759
|
describe('Edge cases and error handling', () => {
|
|
657
760
|
test('should return null for null structure', () => {
|
|
658
761
|
const result = processStructure(null, createContext({}));
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { applyStylesToNode } from '../shared/styleNodeUtils';
|
|
34
34
|
import { isRichTextMarker, richTextMarkerToHtml } from '../shared/propResolver';
|
|
35
35
|
import { hasItemTemplates } from '../shared/itemTemplateUtils';
|
|
36
|
+
import { templateVarName, TEMPLATE_VARS_KEY } from '../shared/templateStyleVars';
|
|
36
37
|
import { safeEvaluate } from '../shared/expressionEvaluator';
|
|
37
38
|
import { RAW_HTML_PREFIX } from '../shared/constants';
|
|
38
39
|
import { getGlobalTemplateContext } from '../shared/globalTemplateContext';
|
|
@@ -274,6 +275,21 @@ function processStyleValue(
|
|
|
274
275
|
return undefined;
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* True when an ORIGINAL style value was a prop-template string that processStyleValue
|
|
280
|
+
* successfully resolved — the case the var-bridge applies to (see templateStyleVars.ts).
|
|
281
|
+
* Item templates ({{item.x}}) resolve later during rendering, and an unresolved template
|
|
282
|
+
* (undefined prop) keeps its legacy pass-through behavior, so neither bridges.
|
|
283
|
+
*/
|
|
284
|
+
function isBridgeableTemplateValue(original: unknown, resolved: string | number): boolean {
|
|
285
|
+
return (
|
|
286
|
+
typeof original === 'string' &&
|
|
287
|
+
hasTemplates(original) &&
|
|
288
|
+
!hasItemTemplates(original) &&
|
|
289
|
+
!(typeof resolved === 'string' && resolved.includes('{{'))
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
277
293
|
/**
|
|
278
294
|
* Build evaluation context for template processing.
|
|
279
295
|
* Merges global context, props, componentDef, and optionally itemContext.
|
|
@@ -670,6 +686,12 @@ export function processStructure(
|
|
|
670
686
|
|
|
671
687
|
// First pass: process all keys and resolve style mappings
|
|
672
688
|
let resolvedStyle: StyleValue | null = null;
|
|
689
|
+
// Var-bridge assignments for template-resolved style values (see templateStyleVars.ts).
|
|
690
|
+
// Component INSTANCE nodes are exempt: their style merges into the child structure
|
|
691
|
+
// root (mergeNodeStyles / SSR renderComponent), which renders a different element
|
|
692
|
+
// than the one these vars would be applied to.
|
|
693
|
+
const templateVars: Record<string, string> = {};
|
|
694
|
+
const bridgeTemplates = preservedType !== NODE_TYPE.COMPONENT;
|
|
673
695
|
|
|
674
696
|
for (const [key, value] of Object.entries(structure)) {
|
|
675
697
|
try {
|
|
@@ -777,6 +799,22 @@ export function processStructure(
|
|
|
777
799
|
if (processedStyle && typeof processedStyle === 'object' && !Array.isArray(processedStyle)) {
|
|
778
800
|
// Check if it's a responsive style object
|
|
779
801
|
const styleEvalContext = buildEvalContext(context, false);
|
|
802
|
+
// Land one resolved declaration, var-bridging template-resolved values: the
|
|
803
|
+
// style gets `var(--m-<bp>-<prop>)` and the concrete value is stashed for the
|
|
804
|
+
// element's inline style — mirroring the meno-astro runtime, so a prop-bound
|
|
805
|
+
// value renders constant across breakpoints (no auto-responsive scaling) in
|
|
806
|
+
// Fast Design Mode exactly as it does in a real build. See templateStyleVars.ts.
|
|
807
|
+
const landStyleValue = (target: StyleObject, bp: string, styleKey: string, styleValue: unknown): void => {
|
|
808
|
+
const processedValue = processStyleValue(styleValue, styleEvalContext);
|
|
809
|
+
if (processedValue === undefined) return;
|
|
810
|
+
if (bridgeTemplates && isBridgeableTemplateValue(styleValue, processedValue)) {
|
|
811
|
+
const varName = templateVarName(bp, styleKey);
|
|
812
|
+
templateVars[varName] = String(processedValue);
|
|
813
|
+
target[styleKey] = `var(${varName})`;
|
|
814
|
+
} else {
|
|
815
|
+
target[styleKey] = processedValue;
|
|
816
|
+
}
|
|
817
|
+
};
|
|
780
818
|
if (isResponsiveStyle(processedStyle as StyleValue)) {
|
|
781
819
|
// Preserve responsive styles (for both SSR and editor)
|
|
782
820
|
// responsiveStylesToClasses will generate prefixed classes (tablet:, mobile:)
|
|
@@ -784,25 +822,21 @@ export function processStructure(
|
|
|
784
822
|
const resolvedResponsive: ResponsiveStyleObject = {};
|
|
785
823
|
for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
|
|
786
824
|
if (typeof bkeyValue === 'object' && bkeyValue !== null) {
|
|
787
|
-
|
|
825
|
+
const bpTarget: StyleObject = {};
|
|
826
|
+
resolvedResponsive[bkeyName] = bpTarget;
|
|
788
827
|
for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
|
|
789
|
-
|
|
790
|
-
if (processedValue !== undefined) {
|
|
791
|
-
resolvedResponsive[bkeyName]![styleKey] = processedValue;
|
|
792
|
-
}
|
|
828
|
+
landStyleValue(bpTarget, bkeyName, styleKey, styleValue);
|
|
793
829
|
}
|
|
794
830
|
}
|
|
795
831
|
}
|
|
796
832
|
resolvedStyle = resolvedResponsive;
|
|
797
833
|
} else {
|
|
798
834
|
// Legacy flat style object - resolve mappings and evaluate templates
|
|
799
|
-
|
|
835
|
+
const flatTarget: StyleObject = {};
|
|
800
836
|
for (const [styleKey, styleValue] of Object.entries(processedStyle)) {
|
|
801
|
-
|
|
802
|
-
if (processedValue !== undefined) {
|
|
803
|
-
resolvedStyle[styleKey] = processedValue;
|
|
804
|
-
}
|
|
837
|
+
landStyleValue(flatTarget, 'base', styleKey, styleValue);
|
|
805
838
|
}
|
|
839
|
+
resolvedStyle = flatTarget;
|
|
806
840
|
}
|
|
807
841
|
}
|
|
808
842
|
} else if (key === 'props' && typeof value === 'object' && value !== null) {
|
|
@@ -987,6 +1021,12 @@ export function processStructure(
|
|
|
987
1021
|
}
|
|
988
1022
|
}
|
|
989
1023
|
|
|
1024
|
+
// Attach the var-bridge assignments (ephemeral, render-only) so the element
|
|
1025
|
+
// renderers can set them inline — client builders via ref, SSR via style="…".
|
|
1026
|
+
if (Object.keys(templateVars).length > 0) {
|
|
1027
|
+
(processed as unknown as Record<string, unknown>)[TEMPLATE_VARS_KEY] = templateVars;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
990
1030
|
return processed;
|
|
991
1031
|
}
|
|
992
1032
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for htmlGenerator - SSR HTML document generation
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, test, expect, mock, beforeEach, afterAll } from 'bun:test';
|
|
5
|
+
import { describe, test, expect, mock, beforeEach, afterEach, afterAll } from 'bun:test';
|
|
6
6
|
|
|
7
7
|
// ---- Mock all dependencies before importing the module under test ----
|
|
8
8
|
|
|
@@ -177,7 +177,11 @@ mock.module('../../shared/libraryLoader', () => ({
|
|
|
177
177
|
import { generateSSRHTML } from './htmlGenerator';
|
|
178
178
|
import type { PreloadImage } from './ssrRenderer';
|
|
179
179
|
import type { ClientDataCollection } from './clientDataInjector';
|
|
180
|
-
import type { InteractiveStyles } from '../../shared/types';
|
|
180
|
+
import type { ComponentDefinition, InteractiveStyles, JSONPage } from '../../shared/types';
|
|
181
|
+
import { getProjectRoot, setProjectRoot } from '../projectContext';
|
|
182
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
183
|
+
import { tmpdir } from 'node:os';
|
|
184
|
+
import { join } from 'node:path';
|
|
181
185
|
|
|
182
186
|
// ---- Fixtures ----
|
|
183
187
|
|
|
@@ -342,6 +346,105 @@ describe('generateSSRHTML', () => {
|
|
|
342
346
|
expect(result).not.toContain('data-meno-page-css');
|
|
343
347
|
});
|
|
344
348
|
|
|
349
|
+
// Local-file imports need a real project root: these tests point projectContext
|
|
350
|
+
// at a temp dir with CSS files on disk, covering page AND component frontmatter
|
|
351
|
+
// imports (a component's `import './card.css'` is bundled by the real Astro build,
|
|
352
|
+
// so the select canvas must surface it too).
|
|
353
|
+
describe('local CSS files on disk', () => {
|
|
354
|
+
let tmpRoot: string;
|
|
355
|
+
let prevRoot: string;
|
|
356
|
+
|
|
357
|
+
beforeEach(() => {
|
|
358
|
+
prevRoot = getProjectRoot();
|
|
359
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'meno-htmlgen-css-'));
|
|
360
|
+
mkdirSync(join(tmpRoot, 'src/components'), { recursive: true });
|
|
361
|
+
mkdirSync(join(tmpRoot, 'src/styles'), { recursive: true });
|
|
362
|
+
setProjectRoot(tmpRoot);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
setProjectRoot(prevRoot);
|
|
367
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const cardImporter: ComponentDefinition = { component: { _frontmatter: "import './card.css';" } };
|
|
371
|
+
const pageUsing = (...names: string[]) =>
|
|
372
|
+
({
|
|
373
|
+
root: { type: 'node', tag: 'div', children: names.map((n) => ({ type: n })) },
|
|
374
|
+
meta: { title: 'Test' },
|
|
375
|
+
}) as unknown as JSONPage;
|
|
376
|
+
|
|
377
|
+
test('inlines a CSS file imported by a component used on the page', async () => {
|
|
378
|
+
writeFileSync(join(tmpRoot, 'src/components/card.css'), '.card { color: rebeccapurple; }');
|
|
379
|
+
const result = (await generateSSRHTML({
|
|
380
|
+
pageData: pageUsing('Card'),
|
|
381
|
+
globalComponents: { Card: cardImporter },
|
|
382
|
+
})) as string;
|
|
383
|
+
expect(result).toContain('data-meno-page-css="./card.css"');
|
|
384
|
+
expect(result).toContain('rebeccapurple');
|
|
385
|
+
// After the generated stylesheet, so hand-authored CSS can override utilities.
|
|
386
|
+
expect(result.indexOf('id="meno-styles"')).toBeLessThan(result.indexOf('data-meno-page-css'));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('skips CSS from a component the page does not use', async () => {
|
|
390
|
+
writeFileSync(join(tmpRoot, 'src/components/card.css'), '.card { color: rebeccapurple; }');
|
|
391
|
+
const result = (await generateSSRHTML({
|
|
392
|
+
pageData: pageUsing('Hero'),
|
|
393
|
+
globalComponents: { Card: cardImporter },
|
|
394
|
+
})) as string;
|
|
395
|
+
expect(result).not.toContain('data-meno-page-css');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('reaches components transitively (page → Layout → Header)', async () => {
|
|
399
|
+
writeFileSync(join(tmpRoot, 'src/components/header.css'), '.hdr { outline: 1px solid red; }');
|
|
400
|
+
const components: Record<string, ComponentDefinition> = {
|
|
401
|
+
Layout: { component: { structure: { type: 'node', tag: 'div', children: [{ type: 'Header' }] } as never } },
|
|
402
|
+
Header: { component: { _frontmatter: "import './header.css';" } },
|
|
403
|
+
};
|
|
404
|
+
const result = (await generateSSRHTML({
|
|
405
|
+
pageData: pageUsing('Layout'),
|
|
406
|
+
globalComponents: components,
|
|
407
|
+
})) as string;
|
|
408
|
+
expect(result).toContain('data-meno-page-css="./header.css"');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('inlines a stylesheet shared by two components only once', async () => {
|
|
412
|
+
writeFileSync(join(tmpRoot, 'src/components/shared.css'), '.shared { --shared-marker: 1; }');
|
|
413
|
+
const importer: ComponentDefinition = { component: { _frontmatter: "import './shared.css';" } };
|
|
414
|
+
const result = (await generateSSRHTML({
|
|
415
|
+
pageData: pageUsing('Card', 'Hero'),
|
|
416
|
+
globalComponents: { Card: importer, Hero: importer },
|
|
417
|
+
})) as string;
|
|
418
|
+
expect(result.split('--shared-marker').length - 1).toBe(1);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('resolves a page-relative ./sibling.css against the page directory from options.pagePath', async () => {
|
|
422
|
+
// Regression: the call site once passed the positional `pagePath` parameter,
|
|
423
|
+
// which is stuck at '/' for options-object calls, so `./post.css` on a nested
|
|
424
|
+
// page resolved against src/pages instead of src/pages/blog and vanished.
|
|
425
|
+
mkdirSync(join(tmpRoot, 'src/pages/blog'), { recursive: true });
|
|
426
|
+
writeFileSync(join(tmpRoot, 'src/pages/blog/post.css'), '.post { letter-spacing: 2px; }');
|
|
427
|
+
const page = { ...minimalPage, _frontmatter: "import './post.css';" };
|
|
428
|
+
const result = (await generateSSRHTML({ pageData: page, pagePath: '/blog/my-post' })) as string;
|
|
429
|
+
expect(result).toContain('data-meno-page-css="./post.css"');
|
|
430
|
+
expect(result).toContain('letter-spacing: 2px');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('orders component CSS before page CSS so the page wins the cascade', async () => {
|
|
434
|
+
writeFileSync(join(tmpRoot, 'src/components/card.css'), '.card { color: rebeccapurple; }');
|
|
435
|
+
writeFileSync(join(tmpRoot, 'src/styles/extra.css'), '.extra { color: teal; }');
|
|
436
|
+
const page = {
|
|
437
|
+
...(pageUsing('Card') as object),
|
|
438
|
+
_frontmatter: "import '../styles/extra.css';",
|
|
439
|
+
} as unknown as JSONPage;
|
|
440
|
+
const result = (await generateSSRHTML({
|
|
441
|
+
pageData: page,
|
|
442
|
+
globalComponents: { Card: cardImporter },
|
|
443
|
+
})) as string;
|
|
444
|
+
expect(result.indexOf('rebeccapurple')).toBeLessThan(result.indexOf('teal'));
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
345
448
|
test('should minify CSS in production mode (useBuiltBundle=true)', async () => {
|
|
346
449
|
mockRenderPageSSR.mockImplementation(async () => ({
|
|
347
450
|
html: '<div>Content</div>',
|