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.
Files changed (52) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/dist/chunks/{chunk-XTKNX4FW.js → chunk-AMNAKQAI.js} +25 -11
  3. package/dist/chunks/{chunk-XTKNX4FW.js.map → chunk-AMNAKQAI.js.map} +2 -2
  4. package/dist/chunks/{chunk-YMBUSHII.js → chunk-CG3O7H5V.js} +9 -4
  5. package/dist/chunks/chunk-CG3O7H5V.js.map +7 -0
  6. package/dist/chunks/{chunk-4ZRU52J2.js → chunk-EJ6QOX7C.js} +14 -10
  7. package/dist/chunks/chunk-EJ6QOX7C.js.map +7 -0
  8. package/dist/chunks/{chunk-KX2LPIZZ.js → chunk-EKB7GGQK.js} +223 -38
  9. package/dist/chunks/chunk-EKB7GGQK.js.map +7 -0
  10. package/dist/chunks/{chunk-WOJS25K3.js → chunk-G6OXV2IV.js} +2 -2
  11. package/dist/lib/client/index.js +9 -5
  12. package/dist/lib/client/index.js.map +2 -2
  13. package/dist/lib/server/index.js +14 -5
  14. package/dist/lib/server/index.js.map +2 -2
  15. package/dist/lib/shared/index.js +18 -6
  16. package/dist/lib/shared/index.js.map +2 -2
  17. package/lib/client/core/ComponentBuilder.test.ts +40 -0
  18. package/lib/client/core/ComponentBuilder.ts +8 -1
  19. package/lib/server/routes/static.test.ts +67 -0
  20. package/lib/server/routes/static.ts +9 -1
  21. package/lib/server/ssr/htmlGenerator.ts +7 -0
  22. package/lib/server/ssr/ssrRenderer.test.ts +31 -0
  23. package/lib/server/ssr/ssrRenderer.ts +8 -1
  24. package/lib/shared/cssGeneration.test.ts +150 -9
  25. package/lib/shared/cssGeneration.ts +116 -31
  26. package/lib/shared/cssProperties.ts +40 -14
  27. package/lib/shared/i18n.test.ts +21 -0
  28. package/lib/shared/i18n.ts +24 -10
  29. package/lib/shared/nodeUtils.test.ts +22 -0
  30. package/lib/shared/nodeUtils.ts +39 -4
  31. package/lib/shared/permissions.test.ts +46 -6
  32. package/lib/shared/permissions.ts +14 -2
  33. package/lib/shared/pxToRem.ts +2 -0
  34. package/lib/shared/responsiveScaling.test.ts +19 -0
  35. package/lib/shared/responsiveScaling.ts +8 -3
  36. package/lib/shared/styleUtils.ts +53 -0
  37. package/lib/shared/tailwindThemeScale.ts +91 -0
  38. package/lib/shared/types/components.ts +45 -7
  39. package/lib/shared/types/index.ts +1 -0
  40. package/lib/shared/types/permissions.ts +27 -11
  41. package/lib/shared/utilityClassMapper.test.ts +105 -0
  42. package/lib/shared/utilityClassMapper.ts +43 -5
  43. package/lib/shared/utilityClassNames.ts +14 -1
  44. package/lib/shared/validation/propValidator.test.ts +47 -0
  45. package/lib/shared/validation/propValidator.ts +8 -0
  46. package/lib/shared/validation/schemas.test.ts +149 -16
  47. package/lib/shared/validation/schemas.ts +101 -5
  48. package/package.json +1 -1
  49. package/dist/chunks/chunk-4ZRU52J2.js.map +0 -7
  50. package/dist/chunks/chunk-KX2LPIZZ.js.map +0 -7
  51. package/dist/chunks/chunk-YMBUSHII.js.map +0 -7
  52. /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
- * Resolve `{ property, value }` for a utility class, consulting the style-value
135
- * registry for hash-fallback classes (e.g. `text-h1glej9a` for `222.3px`).
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(className: string): { property: string; value: string } | null {
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
- if (root === 'size') {
218
- return `width: ${value}; height: ${value};`;
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.split(/\s+/).filter(Boolean);
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[2]) {
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 `${propValue.property}: ${fluidValue};`;
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: `${propValue.property}: ${finalScaledValue};`,
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}: ${finalScaledValue}; }\n}`,
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
- * Filter CSS properties based on input value
1034
- * Supports both startsWith matching and camelCase abbreviation matching
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
- // First, try startsWith matching (exact prefix match)
1043
- const startsWithMatches = CSS_PROPERTIES.filter((prop) =>
1044
- prop.toLowerCase().startsWith(normalizedInput.toLowerCase()),
1045
- );
1063
+ const lowerInput = normalizedInput.toLowerCase();
1064
+
1065
+ const startsWithMatches = new Set(CSS_PROPERTIES.filter((prop) => prop.toLowerCase().startsWith(lowerInput)));
1046
1066
 
1047
- // Then, try abbreviation/fuzzy matching
1048
- const abbreviationMatches = CSS_PROPERTIES.filter(
1049
- (prop) => matchesAbbreviation(prop, normalizedInput) && !startsWithMatches.includes(prop), // Don't duplicate startsWith matches
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
- // Sort each group by popularity, then combine: exact matches first, then fuzzy matches
1053
- const byPriority = (a: string, b: string) => getPropertyPriority(a) - getPropertyPriority(b);
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
- const combined = [...startsWithMatches.sort(byPriority), ...abbreviationMatches.sort(byPriority)];
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) {
@@ -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: {
@@ -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
- if (isI18nValue(value)) {
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,
@@ -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 mapping
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
- export function evaluateNodeIf(node: ComponentNode, props?: Record<string, unknown>): boolean {
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 { resolvePermissions, canEdit, matchesPermissionPattern, matchesAnyPermissionPattern } from './permissions';
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('colors'))).toBe(true);
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('colors'))).toBe(false);
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('variables'))).toBe(true);
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: ['colors', 'variables'] } }, all: { 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('colors'))).toBe(true);
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) merged[kind].push(...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
- return matchesAnyPermissionPattern(resolved.allow[RULE_KEY[ref.kind]], ref.id);
172
+ const id = ref.kind === 'config' ? normalizeConfigArea(ref.id) : ref.id;
173
+ return matchesAnyPermissionPattern(resolved.allow[RULE_KEY[ref.kind]], id);
162
174
  }
@@ -35,6 +35,8 @@ const PX_KEEP_PROPERTIES = new Set([
35
35
  'border-right-width',
36
36
  'border-bottom-width',
37
37
  'border-left-width',
38
+ 'border-inline-width',
39
+ 'border-block-width',
38
40
  'outline-width',
39
41
  'outline-offset',
40
42
  'border',
@@ -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', () => {