meno-core 1.1.7 → 1.1.8
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/bin/cli.js +1 -1
- package/dist/chunks/{chunk-XTKNX4FW.js → chunk-AMNAKQAI.js} +25 -11
- package/dist/chunks/{chunk-XTKNX4FW.js.map → chunk-AMNAKQAI.js.map} +2 -2
- package/dist/chunks/{chunk-YMBUSHII.js → chunk-CG3O7H5V.js} +9 -4
- package/dist/chunks/chunk-CG3O7H5V.js.map +7 -0
- package/dist/chunks/{chunk-4ZRU52J2.js → chunk-EJ6QOX7C.js} +14 -10
- package/dist/chunks/chunk-EJ6QOX7C.js.map +7 -0
- package/dist/chunks/{chunk-KX2LPIZZ.js → chunk-EKB7GGQK.js} +223 -38
- package/dist/chunks/chunk-EKB7GGQK.js.map +7 -0
- package/dist/chunks/{chunk-WOJS25K3.js → chunk-G6OXV2IV.js} +2 -2
- package/dist/lib/client/index.js +9 -5
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +14 -5
- package/dist/lib/server/index.js.map +2 -2
- package/dist/lib/shared/index.js +18 -6
- package/dist/lib/shared/index.js.map +2 -2
- package/lib/client/core/ComponentBuilder.test.ts +40 -0
- package/lib/client/core/ComponentBuilder.ts +8 -1
- package/lib/server/routes/static.test.ts +67 -0
- package/lib/server/routes/static.ts +9 -1
- package/lib/server/ssr/htmlGenerator.ts +7 -0
- package/lib/server/ssr/ssrRenderer.test.ts +31 -0
- package/lib/server/ssr/ssrRenderer.ts +8 -1
- package/lib/shared/cssGeneration.test.ts +150 -9
- package/lib/shared/cssGeneration.ts +116 -31
- package/lib/shared/cssProperties.ts +40 -14
- package/lib/shared/i18n.test.ts +21 -0
- package/lib/shared/i18n.ts +24 -10
- package/lib/shared/nodeUtils.test.ts +22 -0
- package/lib/shared/nodeUtils.ts +39 -4
- package/lib/shared/permissions.test.ts +46 -6
- package/lib/shared/permissions.ts +14 -2
- package/lib/shared/pxToRem.ts +2 -0
- package/lib/shared/responsiveScaling.test.ts +19 -0
- package/lib/shared/responsiveScaling.ts +8 -3
- package/lib/shared/styleUtils.ts +53 -0
- package/lib/shared/tailwindThemeScale.ts +91 -0
- package/lib/shared/types/components.ts +45 -7
- package/lib/shared/types/index.ts +1 -0
- package/lib/shared/types/permissions.ts +27 -11
- package/lib/shared/utilityClassMapper.test.ts +105 -0
- package/lib/shared/utilityClassMapper.ts +43 -5
- package/lib/shared/utilityClassNames.ts +14 -1
- package/lib/shared/validation/propValidator.test.ts +47 -0
- package/lib/shared/validation/propValidator.ts +8 -0
- package/lib/shared/validation/schemas.test.ts +149 -16
- package/lib/shared/validation/schemas.ts +101 -5
- package/package.json +1 -1
- package/dist/chunks/chunk-4ZRU52J2.js.map +0 -7
- package/dist/chunks/chunk-KX2LPIZZ.js.map +0 -7
- package/dist/chunks/chunk-YMBUSHII.js.map +0 -7
- /package/dist/chunks/{chunk-WOJS25K3.js.map → chunk-G6OXV2IV.js.map} +0 -0
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isBareColorTokenName,
|
|
15
15
|
} from './utilityClassNames';
|
|
16
16
|
import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegistry';
|
|
17
|
+
import { physicalizeBoxSpacing } from './styleUtils';
|
|
17
18
|
import type { BreakpointConfig } from './breakpoints';
|
|
18
19
|
import { DEFAULT_BREAKPOINTS, getBreakpointValues } from './breakpoints';
|
|
19
20
|
import type { ResponsiveScales, CSSPropertyType, ResponsiveMode } from './responsiveScaling';
|
|
@@ -131,8 +132,39 @@ const utilityClassRules: Record<string, string> = (() => {
|
|
|
131
132
|
})();
|
|
132
133
|
|
|
133
134
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
135
|
+
* Class roots that expand to TWO CSS longhands instead of the single property
|
|
136
|
+
* their `prefixToCSSProperty` entry names. `py` is listed there as `padding`
|
|
137
|
+
* (the family, for ordering/scale-category lookups) but a `py-[110px]` means
|
|
138
|
+
* `padding-top` + `padding-bottom` — emitting the `padding` shorthand would
|
|
139
|
+
* paint all four sides. Same for `px`/`mx`/`my`, `size` (width + height) and
|
|
140
|
+
* the side-radius roots.
|
|
141
|
+
*
|
|
142
|
+
* Every path that builds a declaration for a parsed class must go through
|
|
143
|
+
* {@link declarationsForRoot} — the base rule, the auto-responsive `@media`
|
|
144
|
+
* rules and the fluid `clamp()` rewrite alike.
|
|
145
|
+
*/
|
|
146
|
+
const AXIS_ROOT_LONGHANDS: Record<string, [string, string]> = {
|
|
147
|
+
size: ['width', 'height'],
|
|
148
|
+
px: ['padding-left', 'padding-right'],
|
|
149
|
+
py: ['padding-top', 'padding-bottom'],
|
|
150
|
+
mx: ['margin-left', 'margin-right'],
|
|
151
|
+
my: ['margin-top', 'margin-bottom'],
|
|
152
|
+
...ROUNDED_SIDE_CORNERS,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Declaration text for `value` under `root`/`property`, expanding the
|
|
157
|
+
* two-longhand axis roots (see {@link AXIS_ROOT_LONGHANDS}).
|
|
158
|
+
*/
|
|
159
|
+
function declarationsForRoot(root: string | null | undefined, property: string, value: string): string {
|
|
160
|
+
const longhands = root ? AXIS_ROOT_LONGHANDS[root] : undefined;
|
|
161
|
+
if (longhands) return `${longhands[0]}: ${value}; ${longhands[1]}: ${value};`;
|
|
162
|
+
return `${property}: ${value};`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve `{ property, value, root }` for a utility class, consulting the
|
|
167
|
+
* style-value registry for hash-fallback classes (e.g. `text-h1glej9a` for `222.3px`).
|
|
136
168
|
*
|
|
137
169
|
* Used by the auto-responsive scaling path so hashed classes don't silently
|
|
138
170
|
* bypass `clamp()` / `@media` rewriting.
|
|
@@ -143,27 +175,80 @@ const utilityClassRules: Record<string, string> = (() => {
|
|
|
143
175
|
* already-clamped value will pass through untouched via the existing
|
|
144
176
|
* `if (!fluidValue) return rule;` guards.
|
|
145
177
|
*/
|
|
146
|
-
function resolveScalablePropertyValue(
|
|
178
|
+
function resolveScalablePropertyValue(
|
|
179
|
+
className: string,
|
|
180
|
+
): { property: string; value: string; root: string | null } | null {
|
|
147
181
|
const parsed = parseUtilityClass(className);
|
|
148
182
|
if (!parsed) return null;
|
|
149
|
-
if (parsed.value != null) return { property: parsed.property, value: parsed.value };
|
|
183
|
+
if (parsed.value != null) return { property: parsed.property, value: parsed.value, root: parsed.root };
|
|
150
184
|
|
|
151
185
|
// Hash-fallback class: real value lives in the registry
|
|
152
186
|
const registered = getStyleValue(className);
|
|
153
187
|
if (registered == null || registered === '') return null;
|
|
154
188
|
const value = String(registered);
|
|
155
189
|
const property = parsed.root ? resolveRootProperty(parsed.root, value) : parsed.property;
|
|
156
|
-
return { property, value };
|
|
190
|
+
return { property, value, root: parsed.root };
|
|
157
191
|
}
|
|
158
192
|
|
|
159
193
|
/** Border-side class roots → CSS side shorthand base. */
|
|
160
194
|
const borderSideMap: Record<string, string> = {
|
|
195
|
+
border: 'border',
|
|
161
196
|
'border-t': 'border-top',
|
|
162
197
|
'border-r': 'border-right',
|
|
163
198
|
'border-b': 'border-bottom',
|
|
164
199
|
'border-l': 'border-left',
|
|
200
|
+
'border-x': 'border-inline',
|
|
201
|
+
'border-y': 'border-block',
|
|
165
202
|
};
|
|
166
203
|
|
|
204
|
+
/** Every `<line-style>` keyword — a lone one of these is a style, never a width. */
|
|
205
|
+
const BORDER_STYLE_VALUES: ReadonlySet<string> = new Set([
|
|
206
|
+
'none',
|
|
207
|
+
'hidden',
|
|
208
|
+
'solid',
|
|
209
|
+
'dashed',
|
|
210
|
+
'dotted',
|
|
211
|
+
'double',
|
|
212
|
+
'groove',
|
|
213
|
+
'ridge',
|
|
214
|
+
'inset',
|
|
215
|
+
'outset',
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
/** Style composed onto a lone border WIDTH so it renders — mirrors the scale forms (`border-l-3`). */
|
|
219
|
+
const BORDER_DEFAULT_STYLE = 'solid';
|
|
220
|
+
|
|
221
|
+
/** True for a `<line-width>`: a length/percentage, a math function, or a width keyword. */
|
|
222
|
+
function isBorderWidthToken(token: string): boolean {
|
|
223
|
+
return (
|
|
224
|
+
/^-?(?:\d+|\d*\.\d+)[a-z%]*$/i.test(token) ||
|
|
225
|
+
/^(?:thin|medium|thick)$/i.test(token) ||
|
|
226
|
+
/^(?:calc|min|max|clamp)\(/i.test(token)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Split a shorthand value on top-level whitespace, keeping bracketed groups intact so
|
|
232
|
+
* `calc(1px + 2px) solid red` yields 3 tokens rather than being shredded at every space.
|
|
233
|
+
*/
|
|
234
|
+
function splitBorderShorthand(value: string): string[] {
|
|
235
|
+
const tokens: string[] = [];
|
|
236
|
+
let depth = 0;
|
|
237
|
+
let current = '';
|
|
238
|
+
for (const char of value) {
|
|
239
|
+
if (char === '(' || char === '[') depth++;
|
|
240
|
+
else if (char === ')' || char === ']') depth--;
|
|
241
|
+
if (depth === 0 && /\s/.test(char)) {
|
|
242
|
+
if (current) tokens.push(current);
|
|
243
|
+
current = '';
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
current += char;
|
|
247
|
+
}
|
|
248
|
+
if (current) tokens.push(current);
|
|
249
|
+
return tokens;
|
|
250
|
+
}
|
|
251
|
+
|
|
167
252
|
/**
|
|
168
253
|
* Generate CSS rule for a utility class
|
|
169
254
|
* Handles arbitrary values (p-[10px]), variables (bg-(--primary)),
|
|
@@ -213,26 +298,10 @@ export function generateRuleForClass(className: string, knownTokens?: ReadonlySe
|
|
|
213
298
|
value = parsed.value!;
|
|
214
299
|
}
|
|
215
300
|
|
|
216
|
-
// Multi-property roots (Tailwind axis shorthands
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Side border-radius: no CSS per-side shorthand — emit both corner longhands.
|
|
221
|
-
const sideCorners = root ? ROUNDED_SIDE_CORNERS[root] : undefined;
|
|
222
|
-
if (sideCorners) {
|
|
223
|
-
return `${sideCorners[0]}: ${value}; ${sideCorners[1]}: ${value};`;
|
|
224
|
-
}
|
|
225
|
-
if (root === 'px') {
|
|
226
|
-
return `padding-left: ${value}; padding-right: ${value};`;
|
|
227
|
-
}
|
|
228
|
-
if (root === 'py') {
|
|
229
|
-
return `padding-top: ${value}; padding-bottom: ${value};`;
|
|
230
|
-
}
|
|
231
|
-
if (root === 'mx') {
|
|
232
|
-
return `margin-left: ${value}; margin-right: ${value};`;
|
|
233
|
-
}
|
|
234
|
-
if (root === 'my') {
|
|
235
|
-
return `margin-top: ${value}; margin-bottom: ${value};`;
|
|
301
|
+
// Multi-property roots (Tailwind axis shorthands: `size`, `px`/`py`, `mx`/`my`,
|
|
302
|
+
// and the side border-radius roots, which have no CSS per-side shorthand).
|
|
303
|
+
if (root && AXIS_ROOT_LONGHANDS[root]) {
|
|
304
|
+
return declarationsForRoot(root, property, value);
|
|
236
305
|
}
|
|
237
306
|
|
|
238
307
|
// Border-side classes (border-t-, border-r-, …): emit width/style (and color
|
|
@@ -240,14 +309,26 @@ export function generateRuleForClass(className: string, knownTokens?: ReadonlySe
|
|
|
240
309
|
// control the color independently (longhands sort after the side class).
|
|
241
310
|
if (root && borderSideMap[root] && property !== 'border-color') {
|
|
242
311
|
const side = borderSideMap[root];
|
|
243
|
-
const tokens = value
|
|
312
|
+
const tokens = splitBorderShorthand(value);
|
|
244
313
|
if (tokens.length >= 2) {
|
|
245
314
|
const declarations = [`${side}-width: ${tokens[0]};`, `${side}-style: ${tokens[1]};`];
|
|
246
|
-
if (tokens
|
|
315
|
+
if (tokens.length > 2) {
|
|
247
316
|
declarations.push(`${side}-color: ${tokens.slice(2).join(' ')};`);
|
|
248
317
|
}
|
|
249
318
|
return declarations.join(' ');
|
|
250
319
|
}
|
|
320
|
+
const only = tokens[0];
|
|
321
|
+
if (only) {
|
|
322
|
+
// A lone style keyword (`border-solid`, `border-l-[dashed]`) is exactly that.
|
|
323
|
+
if (BORDER_STYLE_VALUES.has(only.toLowerCase())) return `${side}-style: ${only};`;
|
|
324
|
+
// A lone WIDTH (`border-l-[3px]`) must carry a style or it renders nothing: Meno has no
|
|
325
|
+
// Preflight default, and the `border-left: 3px` shorthand would itself RESET the style to
|
|
326
|
+
// the initial `none`. Emitting the two longhands makes the arbitrary form behave like the
|
|
327
|
+
// scale form (`border-l-3`) — one edge, visible — instead of forcing authors to add a
|
|
328
|
+
// separate `border-style: solid`, which paints all four edges at the default width.
|
|
329
|
+
if (isBorderWidthToken(only)) return `${side}-width: ${only}; ${side}-style: ${BORDER_DEFAULT_STYLE};`;
|
|
330
|
+
}
|
|
331
|
+
// Anything else (a color, a `var()` that may hold a whole shorthand) stays a shorthand.
|
|
251
332
|
return `${side}: ${value};`;
|
|
252
333
|
}
|
|
253
334
|
|
|
@@ -291,7 +372,7 @@ function applyFluidToUtilityRule(
|
|
|
291
372
|
const fluidValue = buildFluidPropertyValue(propValue.value, scale, range.min, range.max, baseRef);
|
|
292
373
|
if (!fluidValue) return rule;
|
|
293
374
|
|
|
294
|
-
return
|
|
375
|
+
return declarationsForRoot(propValue.root, propValue.property, fluidValue);
|
|
295
376
|
}
|
|
296
377
|
|
|
297
378
|
/**
|
|
@@ -508,7 +589,7 @@ export function generateUtilityCSS(
|
|
|
508
589
|
: scaledValue;
|
|
509
590
|
autoResponsiveMediaQueries[breakpointName].classes.push({
|
|
510
591
|
className: escapedClassName,
|
|
511
|
-
rule:
|
|
592
|
+
rule: declarationsForRoot(propValue.root, propValue.property, finalScaledValue),
|
|
512
593
|
});
|
|
513
594
|
}
|
|
514
595
|
}
|
|
@@ -653,7 +734,7 @@ export function generateSingleClassCSS(
|
|
|
653
734
|
? convertPxToRem(scaledValue, remConfig.baseFontSize)
|
|
654
735
|
: scaledValue;
|
|
655
736
|
css.push(
|
|
656
|
-
`@media (max-width: ${breakpointValue}px) {\n .${escapedClassName} { ${propValue.property
|
|
737
|
+
`@media (max-width: ${breakpointValue}px) {\n .${escapedClassName} { ${declarationsForRoot(propValue.root, propValue.property, finalScaledValue)} }\n}`,
|
|
657
738
|
);
|
|
658
739
|
}
|
|
659
740
|
}
|
|
@@ -722,11 +803,15 @@ function isResponsiveStyle(style: StyleValue): style is ResponsiveStyleObject {
|
|
|
722
803
|
* the server's utility/theme CSS uses, so the inline preview matches the value
|
|
723
804
|
* the recompiled stylesheet will land with. `_mapping` (binding) values are
|
|
724
805
|
* skipped, so a bound property yields an empty string (no optimistic preview).
|
|
806
|
+
*
|
|
807
|
+
* Logical `px`/`py`/`mx`/`my`-derived box props are physicalized first (see
|
|
808
|
+
* {@link physicalizeBoxSpacing}) so the emitted CSS is regular padding/margin, never
|
|
809
|
+
* `padding-inline`/`padding-block`. A shallow copy keeps the caller's object untouched.
|
|
725
810
|
*/
|
|
726
811
|
export function styleObjectToCSS(style: StyleObject): string {
|
|
727
812
|
const declarations: string[] = [];
|
|
728
813
|
|
|
729
|
-
for (const [prop, value] of Object.entries(style)) {
|
|
814
|
+
for (const [prop, value] of Object.entries(physicalizeBoxSpacing({ ...style }))) {
|
|
730
815
|
// Skip mapping objects
|
|
731
816
|
if (typeof value === 'object' && value !== null && '_mapping' in value) {
|
|
732
817
|
continue;
|
|
@@ -1030,29 +1030,55 @@ const PROPERTY_SHORTCUTS: Record<string, string> = {
|
|
|
1030
1030
|
};
|
|
1031
1031
|
|
|
1032
1032
|
/**
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1033
|
+
* Short forms that stand in for the start of a property name. The alias is
|
|
1034
|
+
* expanded before matching, so "bg" reaches every `background*` property and
|
|
1035
|
+
* "bgColor" / "bgc" narrow to `backgroundColor` — neither of which the prefix
|
|
1036
|
+
* or abbreviation matchers can reach on their own ("background" is a single
|
|
1037
|
+
* word, so there is no `g` word boundary for `bg` to land on).
|
|
1038
|
+
*/
|
|
1039
|
+
const PROPERTY_PREFIX_ALIASES: Record<string, string> = {
|
|
1040
|
+
bg: 'background',
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
function expandPrefixAlias(input: string): string {
|
|
1044
|
+
const lowerInput = input.toLowerCase();
|
|
1045
|
+
for (const [alias, expansion] of Object.entries(PROPERTY_PREFIX_ALIASES)) {
|
|
1046
|
+
if (lowerInput.startsWith(alias)) {
|
|
1047
|
+
return expansion + input.slice(alias.length);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return input;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Filter CSS properties based on input value.
|
|
1055
|
+
* Matches on prefix or camelCase abbreviation, ranked by popularity tier.
|
|
1035
1056
|
*/
|
|
1036
1057
|
export function filterCSSProperties(input: string): string[] {
|
|
1037
|
-
const normalizedInput = (input ?? '').trim();
|
|
1058
|
+
const normalizedInput = expandPrefixAlias((input ?? '').trim());
|
|
1038
1059
|
if (!normalizedInput) {
|
|
1039
1060
|
return CSS_PROPERTIES.slice(0, 15); // Show first 15 by default
|
|
1040
1061
|
}
|
|
1041
1062
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
);
|
|
1063
|
+
const lowerInput = normalizedInput.toLowerCase();
|
|
1064
|
+
|
|
1065
|
+
const startsWithMatches = new Set(CSS_PROPERTIES.filter((prop) => prop.toLowerCase().startsWith(lowerInput)));
|
|
1046
1066
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1067
|
+
const combined = CSS_PROPERTIES.filter(
|
|
1068
|
+
(prop) => startsWithMatches.has(prop) || matchesAbbreviation(prop, normalizedInput),
|
|
1069
|
+
).sort((a, b) => {
|
|
1070
|
+
// A fully typed property always wins, however unpopular it is.
|
|
1071
|
+
const exact = Number(b.toLowerCase() === lowerInput) - Number(a.toLowerCase() === lowerInput);
|
|
1072
|
+
if (exact !== 0) return exact;
|
|
1051
1073
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1074
|
+
// Popularity outranks match kind: a common abbreviation match (`ta` → textAlign)
|
|
1075
|
+
// should beat a rare prefix match (tabSize).
|
|
1076
|
+
const priority = getPropertyPriority(a) - getPropertyPriority(b);
|
|
1077
|
+
if (priority !== 0) return priority;
|
|
1054
1078
|
|
|
1055
|
-
|
|
1079
|
+
// Equally popular: prefer the prefix match.
|
|
1080
|
+
return Number(startsWithMatches.has(b)) - Number(startsWithMatches.has(a));
|
|
1081
|
+
});
|
|
1056
1082
|
|
|
1057
1083
|
const shortcut = PROPERTY_SHORTCUTS[normalizedInput.toLowerCase()];
|
|
1058
1084
|
if (shortcut) {
|
package/lib/shared/i18n.test.ts
CHANGED
|
@@ -316,6 +316,27 @@ describe('i18n', () => {
|
|
|
316
316
|
expect((resolved.data as any).value).toBe(100);
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
test('should resolve i18n leaves nested inside array-of-objects items', () => {
|
|
320
|
+
// Breadcrumb-style list prop: each item is an object whose `label` field is an
|
|
321
|
+
// i18n value. The array branch must recurse into item OBJECTS, not only resolve
|
|
322
|
+
// array items that are themselves i18n values — otherwise the nested label
|
|
323
|
+
// survives and renders as "[object Object]".
|
|
324
|
+
const props = {
|
|
325
|
+
items: [
|
|
326
|
+
{ label: 'Home', link: { href: '/' } },
|
|
327
|
+
{ label: { _i18n: true, en: 'About', pl: 'O nas' }, link: { href: '/who-we-are' } },
|
|
328
|
+
{ label: 'History', link: { href: '/history' } },
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const resolved = resolveI18nInProps(props, 'pl', testConfig);
|
|
333
|
+
const items = resolved.items as any[];
|
|
334
|
+
expect(items[0].label).toBe('Home');
|
|
335
|
+
expect(items[1].label).toBe('O nas');
|
|
336
|
+
expect(items[1].link.href).toBe('/who-we-are');
|
|
337
|
+
expect(items[2].label).toBe('History');
|
|
338
|
+
});
|
|
339
|
+
|
|
319
340
|
test('should resolve i18n list props correctly', () => {
|
|
320
341
|
const props = {
|
|
321
342
|
items: {
|
package/lib/shared/i18n.ts
CHANGED
|
@@ -145,6 +145,29 @@ export function resolveI18nValue(value: unknown, locale: string, config: I18nCon
|
|
|
145
145
|
return value;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Recursively resolve a single value that may contain I18nValue leaves anywhere
|
|
150
|
+
* inside it (the value itself, array items, or nested object fields).
|
|
151
|
+
*
|
|
152
|
+
* `isI18nValue` is checked FIRST so an i18n value is resolved as a unit rather than
|
|
153
|
+
* recursed into as a plain object. Array items are resolved with the same routine so
|
|
154
|
+
* that i18n leaves nested inside array-of-objects props (e.g. a breadcrumb list's
|
|
155
|
+
* `{ label: { _i18n, … }, link: { … } }` items) are reached — not just array items
|
|
156
|
+
* that are themselves i18n values.
|
|
157
|
+
*/
|
|
158
|
+
function resolveI18nDeep(value: unknown, locale: string, config: I18nConfig): unknown {
|
|
159
|
+
if (isI18nValue(value)) {
|
|
160
|
+
return resolveTranslation(value, locale, config);
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
return value.map((item) => resolveI18nDeep(item, locale, config));
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === 'object' && value !== null) {
|
|
166
|
+
return resolveI18nInProps(value as Record<string, unknown>, locale, config);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
/**
|
|
149
172
|
* Recursively resolve all I18nValue objects in a props object
|
|
150
173
|
*/
|
|
@@ -156,16 +179,7 @@ export function resolveI18nInProps(
|
|
|
156
179
|
const resolved: Record<string, unknown> = {};
|
|
157
180
|
|
|
158
181
|
for (const [key, value] of Object.entries(props)) {
|
|
159
|
-
|
|
160
|
-
resolved[key] = resolveTranslation(value, locale, config);
|
|
161
|
-
} else if (Array.isArray(value)) {
|
|
162
|
-
resolved[key] = value.map((item) => (isI18nValue(item) ? resolveTranslation(item, locale, config) : item));
|
|
163
|
-
} else if (typeof value === 'object' && value !== null) {
|
|
164
|
-
// Recursively resolve nested objects (but not I18nValue which is already handled)
|
|
165
|
-
resolved[key] = resolveI18nInProps(value as Record<string, unknown>, locale, config);
|
|
166
|
-
} else {
|
|
167
|
-
resolved[key] = value;
|
|
168
|
-
}
|
|
182
|
+
resolved[key] = resolveI18nDeep(value, locale, config);
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
return resolved;
|
|
@@ -403,6 +403,28 @@ describe('nodeUtils', () => {
|
|
|
403
403
|
expect(evaluateNodeIf(node, { status: 'unknown' })).toBe(true);
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
+
test('resolves localized visibility per locale', () => {
|
|
407
|
+
const node = {
|
|
408
|
+
type: NODE_TYPE.NODE,
|
|
409
|
+
tag: 'div',
|
|
410
|
+
if: { _i18n: true, en: true, pl: false },
|
|
411
|
+
} as any;
|
|
412
|
+
const config = {
|
|
413
|
+
defaultLocale: 'en',
|
|
414
|
+
locales: [
|
|
415
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
|
|
416
|
+
{ code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
expect(evaluateNodeIf(node, undefined, { locale: 'en', config })).toBe(true);
|
|
421
|
+
expect(evaluateNodeIf(node, undefined, { locale: 'pl', config })).toBe(false);
|
|
422
|
+
// A locale with no slot falls back to the default locale's value.
|
|
423
|
+
expect(evaluateNodeIf(node, undefined, { locale: 'de', config })).toBe(true);
|
|
424
|
+
// No locale given → the default locale.
|
|
425
|
+
expect(evaluateNodeIf(node, undefined, { config })).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
406
428
|
test('defaults to true when no props provided for mapping', () => {
|
|
407
429
|
const node = {
|
|
408
430
|
type: NODE_TYPE.NODE,
|
package/lib/shared/nodeUtils.ts
CHANGED
|
@@ -13,7 +13,10 @@ import type {
|
|
|
13
13
|
LocaleListNode,
|
|
14
14
|
ComponentDefinition,
|
|
15
15
|
StructuredComponentDefinition,
|
|
16
|
+
I18nConfig,
|
|
17
|
+
I18nValue,
|
|
16
18
|
} from './types';
|
|
19
|
+
import { DEFAULT_I18N_CONFIG, isI18nValue, resolveI18nValue } from './i18n';
|
|
17
20
|
import type { ListNode } from './registry/nodeTypes/ListNodeType';
|
|
18
21
|
import type { IslandNode } from './registry/nodeTypes/IslandNodeType';
|
|
19
22
|
import type { CustomNode } from './registry/nodeTypes/CustomNodeType';
|
|
@@ -422,9 +425,26 @@ export interface BooleanMapping {
|
|
|
422
425
|
}
|
|
423
426
|
|
|
424
427
|
/**
|
|
425
|
-
* If condition type - can be boolean, string template, or
|
|
428
|
+
* If condition type - can be boolean, string template, mapping, or a per-locale
|
|
429
|
+
* `_i18n` object (localized visibility: `{ _i18n: true, en: true, pl: false }`).
|
|
430
|
+
* The i18n form resolves through the SAME `resolveI18nValue` chain every other
|
|
431
|
+
* localized value uses; the resolved slot is then read for truthiness.
|
|
426
432
|
*/
|
|
427
|
-
export type IfCondition = boolean | string | BooleanMapping;
|
|
433
|
+
export type IfCondition = boolean | string | BooleanMapping | I18nValue;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Resolve a localized `if` condition (`{ _i18n: true, en: true, pl: false }`) to the
|
|
437
|
+
* active locale's slot.
|
|
438
|
+
*
|
|
439
|
+
* Shared by every `if` evaluator (this module's `evaluateNodeIf`, the client
|
|
440
|
+
* `ComponentBuilder`, the SSR renderer) so the editor canvas, the editor SSR and the
|
|
441
|
+
* real Astro build (where the emitted `i18n({…}) && (…)` does the same job through
|
|
442
|
+
* meno-astro's runtime resolver) all agree on which locales a node is visible in.
|
|
443
|
+
*/
|
|
444
|
+
export function resolveIfI18n(value: I18nValue, locale?: string, config?: I18nConfig): unknown {
|
|
445
|
+
const cfg = config || DEFAULT_I18N_CONFIG;
|
|
446
|
+
return resolveI18nValue(value, locale || cfg.defaultLocale, cfg);
|
|
447
|
+
}
|
|
428
448
|
|
|
429
449
|
/**
|
|
430
450
|
* Check if a value is a boolean mapping
|
|
@@ -463,8 +483,16 @@ export function isBooleanMapping(value: unknown): value is BooleanMapping {
|
|
|
463
483
|
* { type: 'node', tag: 'div', if: { _mapping: true, prop: 'visible', values: { true: true, false: false } } },
|
|
464
484
|
* { visible: 'true' }
|
|
465
485
|
* ) // true
|
|
466
|
-
|
|
467
|
-
|
|
486
|
+
*
|
|
487
|
+
* // Localized visibility
|
|
488
|
+
* evaluateNodeIf({ type: 'node', tag: 'div', if: { _i18n: true, en: true, pl: false } }, undefined, { locale: 'pl' })
|
|
489
|
+
* // false
|
|
490
|
+
*/
|
|
491
|
+
export function evaluateNodeIf(
|
|
492
|
+
node: ComponentNode,
|
|
493
|
+
props?: Record<string, unknown>,
|
|
494
|
+
i18n?: { locale?: string; config?: I18nConfig },
|
|
495
|
+
): boolean {
|
|
468
496
|
const ifValue = hasIf(node) ? node.if : undefined;
|
|
469
497
|
|
|
470
498
|
// No if property = render (default true)
|
|
@@ -477,6 +505,13 @@ export function evaluateNodeIf(node: ComponentNode, props?: Record<string, unkno
|
|
|
477
505
|
return ifValue;
|
|
478
506
|
}
|
|
479
507
|
|
|
508
|
+
// Localized visibility (`{ _i18n: true, en: true, pl: false }`) — resolve the active
|
|
509
|
+
// locale's slot, then read it for truthiness. Falls back to the default locale (and
|
|
510
|
+
// then the first available slot) exactly like every other localized value.
|
|
511
|
+
if (isI18nValue(ifValue)) {
|
|
512
|
+
return Boolean(resolveIfI18n(ifValue, i18n?.locale, i18n?.config));
|
|
513
|
+
}
|
|
514
|
+
|
|
480
515
|
// Mapping value - resolve using component props
|
|
481
516
|
if (isBooleanMapping(ifValue)) {
|
|
482
517
|
if (!props) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolvePermissions,
|
|
4
|
+
canEdit,
|
|
5
|
+
matchesPermissionPattern,
|
|
6
|
+
matchesAnyPermissionPattern,
|
|
7
|
+
normalizeConfigArea,
|
|
8
|
+
} from './permissions';
|
|
3
9
|
import type { PermissionsPolicy, ResourceRef } from './types/permissions';
|
|
4
10
|
|
|
5
11
|
const page = (id: string): ResourceRef => ({ kind: 'page', id });
|
|
@@ -47,7 +53,7 @@ describe('resolvePermissions — no policy (unmanaged project)', () => {
|
|
|
47
53
|
expect(r.isAdmin).toBe(true);
|
|
48
54
|
expect(r.role).toBe('admin');
|
|
49
55
|
expect(canEdit(r, page('anything'))).toBe(true);
|
|
50
|
-
expect(canEdit(r, config('
|
|
56
|
+
expect(canEdit(r, config('theme'))).toBe(true);
|
|
51
57
|
});
|
|
52
58
|
|
|
53
59
|
test('canManagePolicy: repo admins and local/unauthenticated runs only', () => {
|
|
@@ -88,7 +94,7 @@ describe('resolvePermissions — present policy', () => {
|
|
|
88
94
|
expect(canEdit(r, cms('posts'))).toBe(true);
|
|
89
95
|
expect(canEdit(r, cms('products'))).toBe(false);
|
|
90
96
|
expect(canEdit(r, component('Card'))).toBe(false);
|
|
91
|
-
expect(canEdit(r, config('
|
|
97
|
+
expect(canEdit(r, config('theme'))).toBe(false);
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
test('login lookup is case-insensitive', () => {
|
|
@@ -103,7 +109,7 @@ describe('resolvePermissions — present policy', () => {
|
|
|
103
109
|
expect(r.isAdmin).toBe(true);
|
|
104
110
|
expect(r.canManagePolicy).toBe(true);
|
|
105
111
|
expect(canEdit(r, page('anything'))).toBe(true);
|
|
106
|
-
expect(canEdit(r, config('
|
|
112
|
+
expect(canEdit(r, config('theme'))).toBe(true);
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
test('explicit viewer role edits nothing', () => {
|
|
@@ -143,15 +149,49 @@ describe('resolvePermissions — present policy', () => {
|
|
|
143
149
|
test('config area allow-lists support wildcards', () => {
|
|
144
150
|
const p: PermissionsPolicy = {
|
|
145
151
|
version: 1,
|
|
146
|
-
members: { ed: { allow: { config: ['
|
|
152
|
+
members: { ed: { allow: { config: ['theme'] } }, all: { allow: { config: ['*'] } } },
|
|
147
153
|
};
|
|
148
154
|
const ed = resolvePermissions(p, { login: 'ed' });
|
|
149
|
-
expect(canEdit(ed, config('
|
|
155
|
+
expect(canEdit(ed, config('theme'))).toBe(true);
|
|
150
156
|
expect(canEdit(ed, config('i18n'))).toBe(false);
|
|
151
157
|
const all = resolvePermissions(p, { login: 'all' });
|
|
152
158
|
expect(canEdit(all, config('i18n'))).toBe(true);
|
|
153
159
|
});
|
|
154
160
|
|
|
161
|
+
test('a pre-"theme" policy still resolves: colors/variables alias to theme', () => {
|
|
162
|
+
const p: PermissionsPolicy = {
|
|
163
|
+
version: 1,
|
|
164
|
+
members: {
|
|
165
|
+
ed: { allow: { config: ['colors', 'variables'] } },
|
|
166
|
+
// A grant of only ONE half now covers the whole stylesheet — the widening
|
|
167
|
+
// documented on LEGACY_CONFIG_AREA_ALIASES.
|
|
168
|
+
half: { allow: { config: ['colors'] } },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const ed = resolvePermissions(p, { login: 'ed' });
|
|
172
|
+
expect(ed.allow.config).toEqual(['theme']); // both aliases collapse + dedupe
|
|
173
|
+
expect(canEdit(ed, config('theme'))).toBe(true);
|
|
174
|
+
expect(canEdit(ed, config('i18n'))).toBe(false);
|
|
175
|
+
expect(canEdit(ed, config('project'))).toBe(false);
|
|
176
|
+
|
|
177
|
+
expect(canEdit(resolvePermissions(p, { login: 'half' }), config('theme'))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a legacy config REF resolves against a current policy', () => {
|
|
181
|
+
const p: PermissionsPolicy = { version: 1, members: { ed: { allow: { config: ['theme'] } } } };
|
|
182
|
+
const ed = resolvePermissions(p, { login: 'ed' });
|
|
183
|
+
expect(canEdit(ed, config('colors'))).toBe(true);
|
|
184
|
+
expect(canEdit(ed, config('variables'))).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('normalizeConfigArea leaves current areas and globs alone', () => {
|
|
188
|
+
expect(normalizeConfigArea('colors')).toBe('theme');
|
|
189
|
+
expect(normalizeConfigArea('variables')).toBe('theme');
|
|
190
|
+
expect(normalizeConfigArea('theme')).toBe('theme');
|
|
191
|
+
expect(normalizeConfigArea('project')).toBe('project');
|
|
192
|
+
expect(normalizeConfigArea('*')).toBe('*');
|
|
193
|
+
});
|
|
194
|
+
|
|
155
195
|
test('defaultRole can widen the baseline (editor default)', () => {
|
|
156
196
|
const p: PermissionsPolicy = { version: 1, defaultRole: 'editor', members: { ed: { allow: { pages: ['x'] } } } };
|
|
157
197
|
// An unlisted user is an editor with NO allow-list → edits nothing.
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
ResourceKind,
|
|
22
22
|
ResourceRef,
|
|
23
23
|
} from './types/permissions';
|
|
24
|
+
import { LEGACY_CONFIG_AREA_ALIASES } from './types/permissions';
|
|
24
25
|
|
|
25
26
|
/** Role capability ordering, low → high. */
|
|
26
27
|
const ROLE_RANK: Record<PermissionRole, number> = { viewer: 0, editor: 1, admin: 2 };
|
|
@@ -48,6 +49,15 @@ function grantRole(grant: PermissionGrant): PermissionRole {
|
|
|
48
49
|
return grant.allow ? 'editor' : 'viewer';
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite a retired config-area name to its current one, leaving everything else
|
|
54
|
+
* (including globs) untouched. Applied to both stored policies and queried refs
|
|
55
|
+
* so a pre-`theme` `.meno/permissions.json` keeps resolving without a migration.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeConfigArea(area: string): string {
|
|
58
|
+
return LEGACY_CONFIG_AREA_ALIASES[area] ?? area;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
function mergeAllow(grants: PermissionGrant[]): Required<PermissionRule> {
|
|
52
62
|
const merged = emptyAllow();
|
|
53
63
|
for (const grant of grants) {
|
|
@@ -55,7 +65,8 @@ function mergeAllow(grants: PermissionGrant[]): Required<PermissionRule> {
|
|
|
55
65
|
if (!allow) continue;
|
|
56
66
|
for (const kind of Object.keys(merged) as (keyof PermissionRule)[]) {
|
|
57
67
|
const patterns = allow[kind];
|
|
58
|
-
if (patterns)
|
|
68
|
+
if (!patterns) continue;
|
|
69
|
+
merged[kind].push(...(kind === 'config' ? patterns.map(normalizeConfigArea) : patterns));
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
// Dedupe each list while preserving order.
|
|
@@ -158,5 +169,6 @@ export function resolvePermissions(
|
|
|
158
169
|
export function canEdit(resolved: ResolvedPermissions, ref: ResourceRef): boolean {
|
|
159
170
|
if (resolved.isAdmin) return true;
|
|
160
171
|
if (resolved.role === 'viewer') return false;
|
|
161
|
-
|
|
172
|
+
const id = ref.kind === 'config' ? normalizeConfigArea(ref.id) : ref.id;
|
|
173
|
+
return matchesAnyPermissionPattern(resolved.allow[RULE_KEY[ref.kind]], id);
|
|
162
174
|
}
|
package/lib/shared/pxToRem.ts
CHANGED
|
@@ -150,6 +150,18 @@ describe('responsiveScaling', () => {
|
|
|
150
150
|
const result = parseMultiValue('20px 40px');
|
|
151
151
|
expect(result).toEqual(['20px', '40px']);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
test('should NOT split hyphens inside a var() token', () => {
|
|
155
|
+
// Regression: `text-6xl` inside `var(--text-6xl)` is a token, not a value separator.
|
|
156
|
+
// Splitting it produced `var(--text 6xl)` in the styles panel at scaled breakpoints.
|
|
157
|
+
const result = parseMultiValue('var(--text-6xl)');
|
|
158
|
+
expect(result).toEqual(['var(--text-6xl)']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('should NOT split hyphens inside calc()', () => {
|
|
162
|
+
const result = parseMultiValue('calc(100% - 20px)');
|
|
163
|
+
expect(result).toEqual(['calc(100%', '-', '20px)']);
|
|
164
|
+
});
|
|
153
165
|
});
|
|
154
166
|
|
|
155
167
|
describe('scaleValue', () => {
|
|
@@ -238,6 +250,13 @@ describe('responsiveScaling', () => {
|
|
|
238
250
|
const result = scalePropertyValue('20px 50% 2em', 16, 0.75);
|
|
239
251
|
expect(result).toBe('19px 50% 2em');
|
|
240
252
|
});
|
|
253
|
+
|
|
254
|
+
test('should return a var() token unchanged (variable owns its own scaling)', () => {
|
|
255
|
+
// Regression: an inherited `fontSize: var(--text-6xl)` at a scaled breakpoint must NOT be
|
|
256
|
+
// mangled into `var(--text 6xl)`. The variable scales itself via resolveVariableValueAtBreakpoint.
|
|
257
|
+
const result = scalePropertyValue('var(--text-6xl)', 16, 0.88);
|
|
258
|
+
expect(result).toBe('var(--text-6xl)');
|
|
259
|
+
});
|
|
241
260
|
});
|
|
242
261
|
|
|
243
262
|
describe('getResponsiveValues', () => {
|