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
|
@@ -206,9 +206,14 @@ export function getScaleMultiplier(
|
|
|
206
206
|
* Returns array of individual values
|
|
207
207
|
*/
|
|
208
208
|
export function parseMultiValue(valueStr: string): string[] {
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
209
|
+
// Convert hyphen separators to spaces (for class-name-derived values like "0-80px" → "0 80px"),
|
|
210
|
+
// but NEVER when the value contains a CSS function/token. A hyphen inside `var(--text-6xl)` or
|
|
211
|
+
// `calc(100% - 20px)` is part of the identifier, not a value separator — splitting it corrupts the
|
|
212
|
+
// token (`var(--text-6xl)` → `var(--text 6xl)`, an invalid var reference the styles panel then
|
|
213
|
+
// shows at scaled breakpoints). Any value with `(` is a function and keeps its hyphens verbatim.
|
|
214
|
+
const normalized = valueStr.includes('(')
|
|
215
|
+
? valueStr
|
|
216
|
+
: valueStr.replace(/(?<=\w)-(?=\d|auto|inherit|initial|unset)/g, ' ');
|
|
212
217
|
return normalized
|
|
213
218
|
.trim()
|
|
214
219
|
.split(/\s+/)
|
package/lib/shared/styleUtils.ts
CHANGED
|
@@ -60,3 +60,56 @@ export function isStyleValue(style: unknown): style is StyleValue {
|
|
|
60
60
|
// Check if it's a StyleObject
|
|
61
61
|
return isStyleObject(style);
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rewrite the LOGICAL box-spacing props `paddingInline`/`paddingBlock`/`marginInline`/`marginBlock`
|
|
66
|
+
* to REGULAR physical padding/margin, in place. The `px`/`py`/`mx`/`my` classes decode (via
|
|
67
|
+
* `ROOT_CAMEL_OVERRIDES`) to those logical properties, which would surface in the Styles panel as
|
|
68
|
+
* "Padding Inline/Block" rows the author never asked for and emit as `padding-inline`/`padding-block`
|
|
69
|
+
* — logical properties that read as wrong to an author who wrote a plain `px-`/`py-` and behave subtly
|
|
70
|
+
* differently from physical padding (writing-mode / `direction`).
|
|
71
|
+
*
|
|
72
|
+
* Called from `classesToStyles` (so every class→style decode is already physical) and from
|
|
73
|
+
* `styleObjectToCSS` (defensive: also physicalizes a stray `[padding-inline:…]` arbitrary property).
|
|
74
|
+
*
|
|
75
|
+
* Conversion per family (`padding`, `margin`):
|
|
76
|
+
* - both axes set, both plain strings, and no physical/shorthand key of that family already present
|
|
77
|
+
* → one compact `padding`/`margin` shorthand `<block> <inline>` (`py-[100px] px-0` →
|
|
78
|
+
* `padding: 100px 0px`, i.e. CSS's `vertical horizontal`)
|
|
79
|
+
* - otherwise → the two physical side longhands for each present axis (`px-[100px]` alone →
|
|
80
|
+
* paddingLeft + paddingRight), never overwriting a side the caller set explicitly so an
|
|
81
|
+
* accompanying `pl-`/`pr-` still wins.
|
|
82
|
+
*
|
|
83
|
+
* A no-op when no logical box props are present. Returns the same object for chaining.
|
|
84
|
+
*/
|
|
85
|
+
export function physicalizeBoxSpacing<T extends Record<string, unknown>>(style: T): T {
|
|
86
|
+
const s = style as Record<string, unknown>;
|
|
87
|
+
for (const family of ['padding', 'margin'] as const) {
|
|
88
|
+
const inline = s[`${family}Inline`];
|
|
89
|
+
const block = s[`${family}Block`];
|
|
90
|
+
if (inline == null && block == null) continue;
|
|
91
|
+
delete s[`${family}Inline`];
|
|
92
|
+
delete s[`${family}Block`];
|
|
93
|
+
const hasPhysical =
|
|
94
|
+
s[family] != null ||
|
|
95
|
+
s[`${family}Top`] != null ||
|
|
96
|
+
s[`${family}Right`] != null ||
|
|
97
|
+
s[`${family}Bottom`] != null ||
|
|
98
|
+
s[`${family}Left`] != null;
|
|
99
|
+
// Both axes with no competing key → one shorthand. Bound (`_mapping`) axis values aren't
|
|
100
|
+
// string-composable, so they fall through to the side-longhand branch (which keeps the object).
|
|
101
|
+
if (typeof inline === 'string' && typeof block === 'string' && !hasPhysical) {
|
|
102
|
+
s[family] = `${block} ${inline}`;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (inline != null) {
|
|
106
|
+
if (s[`${family}Left`] == null) s[`${family}Left`] = inline;
|
|
107
|
+
if (s[`${family}Right`] == null) s[`${family}Right`] = inline;
|
|
108
|
+
}
|
|
109
|
+
if (block != null) {
|
|
110
|
+
if (s[`${family}Top`] == null) s[`${family}Top`] = block;
|
|
111
|
+
if (s[`${family}Bottom`] == null) s[`${family}Bottom`] = block;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return style;
|
|
115
|
+
}
|
|
@@ -217,6 +217,97 @@ export const THEME_SCALE_BY_CSSVAR: ReadonlyMap<string, ThemeScaleEntry> = new M
|
|
|
217
217
|
THEME_SCALE_ENTRIES.map((e) => [e.cssVar, e]),
|
|
218
218
|
);
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Specs indexed by class root. A root can carry MORE than one spec — `font-*` reads both
|
|
222
|
+
* `--font-weight-*` (weight) and `--font-*` (family) — so which namespace a class lands in
|
|
223
|
+
* is decided by the token the project actually declares, not by the class shape.
|
|
224
|
+
*/
|
|
225
|
+
const SPECS_BY_ROOT: ReadonlyMap<string, ScaleSpec[]> = (() => {
|
|
226
|
+
const map = new Map<string, ScaleSpec[]>();
|
|
227
|
+
for (const spec of SPECS) {
|
|
228
|
+
const existing = map.get(spec.root);
|
|
229
|
+
if (existing) existing.push(spec);
|
|
230
|
+
else map.set(spec.root, [spec]);
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
})();
|
|
234
|
+
|
|
235
|
+
export interface ThemeScaleTokenMatch {
|
|
236
|
+
property: string;
|
|
237
|
+
cssVar: string;
|
|
238
|
+
value: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generic token fallback: resolve `<root>-<suffix>` against the PROJECT's own theme tokens
|
|
243
|
+
* when the suffix isn't one of the built-in scale steps above. `font-display` →
|
|
244
|
+
* `font-family: var(--font-display)`, but only when the project declares `--font-display`.
|
|
245
|
+
*
|
|
246
|
+
* This is what makes a project-specific token a first-class utility class. Without it only
|
|
247
|
+
* the built-in suffixes (`font-sans`, `text-lg`, …) resolve, and a custom token needs the
|
|
248
|
+
* explicit `font-(--font-display)` var form — while the natural `font-display` silently
|
|
249
|
+
* lands in `class=""` as dead weight that styles nothing.
|
|
250
|
+
*
|
|
251
|
+
* Gated on `knownTokens` deliberately: an undeclared `font-typo` stays an unrecognized
|
|
252
|
+
* foreign class rather than emitting an inert `var(--font-typo)`. A typo must not look
|
|
253
|
+
* like it worked. When a root spans two namespaces the declared one wins; if both are
|
|
254
|
+
* declared the first spec listed wins, which is why the weight spec precedes family.
|
|
255
|
+
*/
|
|
256
|
+
export function resolveThemeScaleToken(
|
|
257
|
+
root: string,
|
|
258
|
+
suffix: string,
|
|
259
|
+
knownTokens: ReadonlySet<string> | undefined,
|
|
260
|
+
): ThemeScaleTokenMatch | null {
|
|
261
|
+
if (!knownTokens || !suffix) return null;
|
|
262
|
+
const specs = SPECS_BY_ROOT.get(root);
|
|
263
|
+
if (!specs) return null;
|
|
264
|
+
for (const spec of specs) {
|
|
265
|
+
const name = `${spec.varPrefix}-${suffix}`;
|
|
266
|
+
if (knownTokens.has(name)) {
|
|
267
|
+
return { property: spec.property, cssVar: `--${name}`, value: `var(--${name})` };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Inverse of `resolveThemeScaleToken`: the compact class for a project token that isn't a
|
|
275
|
+
* built-in scale step. `--font-display` + `font-family` → `font-display`. Keeps the styles
|
|
276
|
+
* panel from rewriting a hand-authored `font-display` into `font-(--font-display)` on the
|
|
277
|
+
* first round-trip. The property gate is what stops `--font-weight-bold` from being read
|
|
278
|
+
* under the family namespace (both share the `font-` prefix).
|
|
279
|
+
*/
|
|
280
|
+
export function themeScaleClassForToken(cssVar: string, property: string): string | null {
|
|
281
|
+
const name = cssVar.startsWith('--') ? cssVar.slice(2) : cssVar;
|
|
282
|
+
for (const spec of SPECS) {
|
|
283
|
+
if (spec.property !== property) continue;
|
|
284
|
+
const prefix = `${spec.varPrefix}-`;
|
|
285
|
+
if (name.startsWith(prefix) && name.length > prefix.length) {
|
|
286
|
+
return `${spec.root}-${name.slice(prefix.length)}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Class root → camelCase property, for merge-key recovery where the token set ISN'T
|
|
294
|
+
* available (the meno-astro runtime). Lets a project-token class conflict-detect against a
|
|
295
|
+
* built-in one — `font-display` must override `font-sans` on the same element.
|
|
296
|
+
*
|
|
297
|
+
* `text` is deliberately absent: it collides with the color root, and `COLOR_ROOT_TO_PROP`
|
|
298
|
+
* already claims it. `font` resolves to fontFamily, so a project defining a custom
|
|
299
|
+
* `--font-weight-*` token gets a merge key of fontFamily at runtime — an accepted
|
|
300
|
+
* imprecision (custom weight tokens are rare, and studio, which has the token set, is exact).
|
|
301
|
+
*/
|
|
302
|
+
export const THEME_SCALE_ROOT_PROPERTY: Readonly<Record<string, string>> = {
|
|
303
|
+
font: 'fontFamily',
|
|
304
|
+
rounded: 'borderRadius',
|
|
305
|
+
shadow: 'boxShadow',
|
|
306
|
+
leading: 'lineHeight',
|
|
307
|
+
tracking: 'letterSpacing',
|
|
308
|
+
'max-w': 'maxWidth',
|
|
309
|
+
};
|
|
310
|
+
|
|
220
311
|
/**
|
|
221
312
|
* The CSS value a themed class resolves to: a plain `var(--token)` reference with no fallback.
|
|
222
313
|
* Renders the project variable when defined, inert otherwise (token-based by design — no baked
|
|
@@ -9,7 +9,17 @@ import type { LibrariesConfig } from './libraries';
|
|
|
9
9
|
/**
|
|
10
10
|
* Prop type definitions
|
|
11
11
|
*/
|
|
12
|
-
export type PropType =
|
|
12
|
+
export type PropType =
|
|
13
|
+
| 'string'
|
|
14
|
+
| 'select'
|
|
15
|
+
| 'boolean'
|
|
16
|
+
| 'number'
|
|
17
|
+
| 'link'
|
|
18
|
+
| 'file'
|
|
19
|
+
| 'rich-text'
|
|
20
|
+
| 'embed'
|
|
21
|
+
| 'list'
|
|
22
|
+
| 'reference';
|
|
13
23
|
|
|
14
24
|
/**
|
|
15
25
|
* Project-level enum configuration
|
|
@@ -20,11 +30,12 @@ export type EnumsConfig = Record<string, readonly string[]>;
|
|
|
20
30
|
/**
|
|
21
31
|
* Internationalization (i18n) value object
|
|
22
32
|
* Keys are locale codes (e.g., 'en', 'pl', 'de')
|
|
23
|
-
* Values can be strings (for string props)
|
|
33
|
+
* Values can be strings (for string props), arrays (for list props), or booleans
|
|
34
|
+
* (for a node's localized `if` — per-locale visibility)
|
|
24
35
|
*/
|
|
25
36
|
export interface I18nValue {
|
|
26
37
|
_i18n: true;
|
|
27
|
-
[locale: string]: string | unknown[] |
|
|
38
|
+
[locale: string]: string | unknown[] | boolean; // `_i18n: true` is the marker; arrays for list props, booleans for localized visibility
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
@@ -64,7 +75,7 @@ export interface LinkPropValue {
|
|
|
64
75
|
* Base prop definition without list-specific fields
|
|
65
76
|
*/
|
|
66
77
|
export interface BasePropDefinition {
|
|
67
|
-
type: Exclude<PropType, 'list'>;
|
|
78
|
+
type: Exclude<PropType, 'list' | 'reference'>;
|
|
68
79
|
default?: string | number | boolean | I18nValue | LinkPropValue;
|
|
69
80
|
options?: readonly string[]; // Required for "select" type (inline options)
|
|
70
81
|
enumName?: string; // For "select" type: reference to project-level enum
|
|
@@ -95,10 +106,30 @@ export interface ListPropDefinition {
|
|
|
95
106
|
default?: ListItemValue[];
|
|
96
107
|
}
|
|
97
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Reference prop definition — a hand-picked selection of CMS items from a collection.
|
|
111
|
+
*
|
|
112
|
+
* A component declares a reference prop (e.g. `posts`) bound to a `collection`; each
|
|
113
|
+
* instance stores the chosen item id(s) as its value: a single id string when `multiple`
|
|
114
|
+
* is false, an ordered id array when true. The component body iterates the selection with a
|
|
115
|
+
* `list` node whose `sourceType: 'collection'`, `source: <collection>`, and
|
|
116
|
+
* `items: "{{<prop>}}"` — the runtime's getCollectionList resolves the ids to full items
|
|
117
|
+
* (order-preserving, missing ids skipped). Edited per-instance via the CMS item picker.
|
|
118
|
+
*/
|
|
119
|
+
export interface ReferencePropDefinition {
|
|
120
|
+
type: 'reference';
|
|
121
|
+
/** Collection the pickable items come from (e.g. "blog"). */
|
|
122
|
+
collection: string;
|
|
123
|
+
/** Multi-select (ordered id array) vs single (id string). Defaults to multi in the UI. */
|
|
124
|
+
multiple?: boolean;
|
|
125
|
+
/** Default selection: an ordered id array (multi) or a single id (single). */
|
|
126
|
+
default?: string | string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
/**
|
|
99
130
|
* Prop definition with improved type safety
|
|
100
131
|
*/
|
|
101
|
-
export type PropDefinition = BasePropDefinition | ListPropDefinition;
|
|
132
|
+
export type PropDefinition = BasePropDefinition | ListPropDefinition | ReferencePropDefinition;
|
|
102
133
|
|
|
103
134
|
/**
|
|
104
135
|
* Type guard to check if a prop definition is a list type
|
|
@@ -108,10 +139,17 @@ export function isListPropDefinition(def: PropDefinition): def is ListPropDefini
|
|
|
108
139
|
}
|
|
109
140
|
|
|
110
141
|
/**
|
|
111
|
-
* Type guard to check if a prop definition is a
|
|
142
|
+
* Type guard to check if a prop definition is a reference (CMS item picker) type
|
|
143
|
+
*/
|
|
144
|
+
export function isReferencePropDefinition(def: PropDefinition): def is ReferencePropDefinition {
|
|
145
|
+
return def.type === 'reference';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Type guard to check if a prop definition is a base (non-list, non-reference) type
|
|
112
150
|
*/
|
|
113
151
|
export function isBasePropDefinition(def: PropDefinition): def is BasePropDefinition {
|
|
114
|
-
return def.type !== 'list';
|
|
152
|
+
return def.type !== 'list' && def.type !== 'reference';
|
|
115
153
|
}
|
|
116
154
|
|
|
117
155
|
/**
|
|
@@ -33,19 +33,35 @@ export const RESOURCE_KINDS: readonly ResourceKind[] = ['page', 'component', 'cm
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Named global-config capabilities (the `config` resource kind's id namespace):
|
|
36
|
-
* - `project`
|
|
37
|
-
* - `
|
|
38
|
-
* - `
|
|
39
|
-
* - `i18n` → locale config inside `project.config.json`
|
|
36
|
+
* - `project` → `project.config.json` (site URL, breakpoints, fonts, …)
|
|
37
|
+
* - `theme` → `src/styles/theme.css` — colors AND variables (design tokens)
|
|
38
|
+
* - `i18n` → locale config inside `project.config.json`
|
|
40
39
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* Areas track FILE granularity, because the CI checker maps whole changed files
|
|
41
|
+
* back to a resource. Two consequences:
|
|
42
|
+
*
|
|
43
|
+
* - `theme` is a single area (not separate `colors` / `variables`) because both
|
|
44
|
+
* halves live in the one `theme.css` stylesheet — the project's sole token
|
|
45
|
+
* source of truth. Nothing can grant one half without the other.
|
|
46
|
+
* - `i18n` lives *inside* `project.config.json`, so the checker enforces it at
|
|
47
|
+
* `project` granularity — granting `i18n` without `project` is advisory only
|
|
48
|
+
* (no file maps to it on its own), and granting `project` implies `i18n`.
|
|
45
49
|
*/
|
|
46
|
-
export type ConfigArea = 'project' | '
|
|
50
|
+
export type ConfigArea = 'project' | 'theme' | 'i18n';
|
|
51
|
+
|
|
52
|
+
export const CONFIG_AREAS: readonly ConfigArea[] = ['project', 'theme', 'i18n'] as const;
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Pre-`theme` area names, kept readable so policies committed before colors and
|
|
56
|
+
* variables collapsed into `theme.css` still resolve. Both map to `theme`, which
|
|
57
|
+
* WIDENS such a grant: an editor allowed only `colors` may now also change
|
|
58
|
+
* variables. That is the granularity the single stylesheet actually has, and
|
|
59
|
+
* this is a soft (UI-coordination) boundary — see the file header.
|
|
60
|
+
*/
|
|
61
|
+
export const LEGACY_CONFIG_AREA_ALIASES: Readonly<Record<string, ConfigArea>> = {
|
|
62
|
+
colors: 'theme',
|
|
63
|
+
variables: 'theme',
|
|
64
|
+
};
|
|
49
65
|
|
|
50
66
|
/**
|
|
51
67
|
* A reference to one editable resource, checked against a {@link PermissionRule}.
|
|
@@ -72,7 +88,7 @@ export interface PermissionRule {
|
|
|
72
88
|
components?: string[];
|
|
73
89
|
/** CMS collection ids, e.g. `["posts", "*"]`. */
|
|
74
90
|
cms?: string[];
|
|
75
|
-
/** Config areas, e.g. `["
|
|
91
|
+
/** Config areas ({@link ConfigArea}), e.g. `["theme", "i18n"]` or `["*"]`. */
|
|
76
92
|
config?: string[];
|
|
77
93
|
}
|
|
78
94
|
|
|
@@ -461,6 +461,50 @@ describe('utilityClassMapper', () => {
|
|
|
461
461
|
expect(classToStyle('border-wrapper')).toBeNull();
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
+
test('project theme tokens resolve on theme-scale roots, gated on knownTokens', () => {
|
|
465
|
+
const tokens = new Set(['font-display', 'radius-card', 'shadow-glow', 'text-hero']);
|
|
466
|
+
// The whole point: `font-display` is a font-FAMILY, not the font-weight the `font` prefix
|
|
467
|
+
// map would otherwise imply.
|
|
468
|
+
expect(classToStyle('font-display', tokens)).toEqual({
|
|
469
|
+
prop: 'fontFamily',
|
|
470
|
+
value: 'var(--font-display)',
|
|
471
|
+
});
|
|
472
|
+
expect(classToStyle('rounded-card', tokens)).toEqual({
|
|
473
|
+
prop: 'borderRadius',
|
|
474
|
+
value: 'var(--radius-card)',
|
|
475
|
+
});
|
|
476
|
+
expect(classToStyle('shadow-glow', tokens)).toEqual({
|
|
477
|
+
prop: 'boxShadow',
|
|
478
|
+
value: 'var(--shadow-glow)',
|
|
479
|
+
});
|
|
480
|
+
// Undeclared token stays a foreign class — a typo must not emit an inert var().
|
|
481
|
+
expect(classToStyle('font-dispaly', tokens)).toBeNull();
|
|
482
|
+
// No token set at all (the astro runtime) → unresolved, as before.
|
|
483
|
+
expect(classToStyle('font-display')).toBeNull();
|
|
484
|
+
// Built-in scale steps still win and are token-set-free.
|
|
485
|
+
expect(classToStyle('font-sans', tokens)).toEqual({ prop: 'fontFamily', value: 'var(--font-sans)' });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('a bare color token keeps winning a shared root over the theme-scale namespace', () => {
|
|
489
|
+
// `--text-primary` is overwhelmingly a COLOR name in practice; reading `text-primary` as a
|
|
490
|
+
// font-size the moment a theme declares it would break every project that names it that way.
|
|
491
|
+
expect(classToStyle('text-primary', new Set(['primary', 'text-primary']))).toEqual({
|
|
492
|
+
prop: 'color',
|
|
493
|
+
value: 'primary',
|
|
494
|
+
});
|
|
495
|
+
// With no color token of that name, the font-size namespace is free to claim it.
|
|
496
|
+
expect(classToStyle('text-hero', new Set(['text-hero']))).toEqual({
|
|
497
|
+
prop: 'fontSize',
|
|
498
|
+
value: 'var(--text-hero)',
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('theme-token classes round-trip instead of degrading to the var form', () => {
|
|
503
|
+
expect(stylesToClasses({ fontFamily: 'var(--font-display)' })).toContain('font-display');
|
|
504
|
+
// An unknown property/namespace pairing still falls back to the explicit var form.
|
|
505
|
+
expect(stylesToClasses({ color: 'var(--font-display)' })).not.toContain('font-display');
|
|
506
|
+
});
|
|
507
|
+
|
|
464
508
|
test('composes across classes without collision', () => {
|
|
465
509
|
expect(classesToStyles(['border', 'border-b-2', 'border-dashed'])).toEqual({
|
|
466
510
|
base: {
|
|
@@ -889,6 +933,8 @@ describe('utilityClassMapper', () => {
|
|
|
889
933
|
expect(classToStyle('p-4')).toEqual({ prop: 'padding', value: '16px' });
|
|
890
934
|
expect(classToStyle('p-2.5')).toEqual({ prop: 'padding', value: '10px' });
|
|
891
935
|
expect(classToStyle('gap-6')).toEqual({ prop: 'gap', value: '24px' });
|
|
936
|
+
// Singular `classToStyle` yields the LOGICAL intermediate (one class can't carry a two-sided
|
|
937
|
+
// expansion); `classesToStyles` physicalizes the whole object — see the `px/py/mx/my` block below.
|
|
892
938
|
expect(classToStyle('px-4')).toEqual({ prop: 'paddingInline', value: '16px' });
|
|
893
939
|
expect(classToStyle('w-64')).toEqual({ prop: 'width', value: '256px' });
|
|
894
940
|
});
|
|
@@ -904,6 +950,55 @@ describe('utilityClassMapper', () => {
|
|
|
904
950
|
});
|
|
905
951
|
});
|
|
906
952
|
|
|
953
|
+
describe('px/py/mx/my decode to REGULAR padding/margin (not logical inline/block)', () => {
|
|
954
|
+
test('a lone axis → its two physical side longhands', () => {
|
|
955
|
+
expect(classesToStyles(['px-[100px]'])).toEqual({ base: { paddingLeft: '100px', paddingRight: '100px' } });
|
|
956
|
+
expect(classesToStyles(['py-4'])).toEqual({ base: { paddingTop: '16px', paddingBottom: '16px' } });
|
|
957
|
+
expect(classesToStyles(['mx-auto'])).toEqual({ base: { marginLeft: 'auto', marginRight: 'auto' } });
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test('both axes with no competing key → one compact shorthand (block inline)', () => {
|
|
961
|
+
expect(classesToStyles(['px-0', 'py-[100px]'])).toEqual({ base: { padding: '100px 0px' } });
|
|
962
|
+
expect(classesToStyles(['mx-[8px]', 'my-[4px]'])).toEqual({ base: { margin: '4px 8px' } });
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test('an explicit physical side wins over the axis shorthand', () => {
|
|
966
|
+
// `pl-[50px]` must survive: the axis fills only the sides the caller left unset.
|
|
967
|
+
expect(classesToStyles(['px-[100px]', 'pl-[50px]'])).toEqual({
|
|
968
|
+
base: { paddingLeft: '50px', paddingRight: '100px' },
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test('physicalization is per-breakpoint', () => {
|
|
973
|
+
expect(classesToStyles(['px-[100px]', 'max-lg:py-[8px]'])).toEqual({
|
|
974
|
+
base: { paddingLeft: '100px', paddingRight: '100px' },
|
|
975
|
+
tablet: { paddingTop: '8px', paddingBottom: '8px' },
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test('no logical box props ever leak out of a multi-class decode', () => {
|
|
980
|
+
const decoded = classesToStyles(['px-4', 'py-2', 'mx-2', 'my-1']);
|
|
981
|
+
const keys = Object.keys(decoded?.base ?? {});
|
|
982
|
+
expect(keys.some((k) => /Inline$|Block$/.test(k))).toBe(false);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test('physicalize:false keeps the LOGICAL axis prop (one row per axis, for the Styles panel)', () => {
|
|
986
|
+
// The panel opts out so px-/py- show as a single Padding X/Y row and re-emit px-/py- on save.
|
|
987
|
+
expect(classesToStyles(['px-[100px]'], undefined, { physicalize: false })).toEqual({
|
|
988
|
+
base: { paddingInline: '100px' },
|
|
989
|
+
});
|
|
990
|
+
expect(classesToStyles(['py-4'], undefined, { physicalize: false })).toEqual({
|
|
991
|
+
base: { paddingBlock: '16px' },
|
|
992
|
+
});
|
|
993
|
+
expect(classesToStyles(['px-0', 'py-[100px]'], undefined, { physicalize: false })).toEqual({
|
|
994
|
+
base: { paddingInline: '0px', paddingBlock: '100px' }, // two axes, NOT collapsed to `padding`
|
|
995
|
+
});
|
|
996
|
+
expect(classesToStyles(['mx-auto', 'my-[4px]'], undefined, { physicalize: false })).toEqual({
|
|
997
|
+
base: { marginInline: 'auto', marginBlock: '4px' },
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
907
1002
|
describe('bare color tokens (token-aware)', () => {
|
|
908
1003
|
const TOKENS = new Set(['primary', 'text', 'border', 'border-light']);
|
|
909
1004
|
|
|
@@ -1012,6 +1107,16 @@ describe('utilityClassMapper', () => {
|
|
|
1012
1107
|
expect(classMergeKey('m_hero_ab12cd')).toBeNull();
|
|
1013
1108
|
expect(classMergeKey('js-toggle')).toBeNull();
|
|
1014
1109
|
});
|
|
1110
|
+
|
|
1111
|
+
test('project theme-token class conflicts with the built-in scale on the same root', () => {
|
|
1112
|
+
// The runtime has no token set, so `font-display` can't be resolved — but its property is
|
|
1113
|
+
// recoverable from the root, which is what makes it override `font-sans` on a merge.
|
|
1114
|
+
expect(classMergeKey('font-display')).toBe(classMergeKey('font-sans'));
|
|
1115
|
+
expect(classMergeKey('rounded-card')).toBe(classMergeKey('rounded-lg'));
|
|
1116
|
+
expect(classMergeKey('shadow-glow')).toBe(classMergeKey('shadow-md'));
|
|
1117
|
+
// Distinct properties must NOT collide.
|
|
1118
|
+
expect(classMergeKey('font-display')).not.toBe(classMergeKey('shadow-glow'));
|
|
1119
|
+
});
|
|
1015
1120
|
});
|
|
1016
1121
|
|
|
1017
1122
|
describe('mergeInstanceClasses', () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ResponsiveStyleObject, StyleObject, StyleMapping, StyleValue } from './types';
|
|
8
|
-
import { isResponsiveStyle } from './styleUtils';
|
|
8
|
+
import { isResponsiveStyle, physicalizeBoxSpacing } from './styleUtils';
|
|
9
9
|
import {
|
|
10
10
|
propertyMap,
|
|
11
11
|
propertyOrder,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
isBareColorTokenName,
|
|
40
40
|
} from './utilityClassNames';
|
|
41
41
|
import { registerStyleValue, registerDynamicStyle, getStyleValue, getDynamicStyle } from './styleValueRegistry';
|
|
42
|
-
import { THEME_SCALE_BY_CSSVAR } from './tailwindThemeScale';
|
|
42
|
+
import { THEME_SCALE_BY_CSSVAR, THEME_SCALE_ROOT_PROPERTY, themeScaleClassForToken } from './tailwindThemeScale';
|
|
43
43
|
import {
|
|
44
44
|
SCALABLE_CSS_PROPERTIES,
|
|
45
45
|
buildFluidClampWithExplicitMin,
|
|
@@ -115,6 +115,12 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
|
|
|
115
115
|
if (entry && entry.property === camelToKebab(prop)) {
|
|
116
116
|
return entry.className;
|
|
117
117
|
}
|
|
118
|
+
// Project token outside the built-in scale (`var(--font-display)` → `font-display`).
|
|
119
|
+
// Without this the value falls through to the explicit var form below, so a hand-authored
|
|
120
|
+
// `font-display` would be rewritten to `font-(--font-display)` the first time the styles
|
|
121
|
+
// panel round-trips the element — same rendering, gratuitous churn in the .astro diff.
|
|
122
|
+
const tokenClass = themeScaleClassForToken(varRef[1] ?? '', camelToKebab(prop));
|
|
123
|
+
if (tokenClass) return tokenClass;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
// Strip outer quotes from fontFamily values (e.g. "Space Grotesk" → Space Grotesk)
|
|
@@ -564,7 +570,11 @@ export function classToStyle(
|
|
|
564
570
|
* Convert utility classes back to a style object
|
|
565
571
|
* Example: ["p-[10px]", "tablet:p-[24px]"] → { base: { padding: "10px" }, tablet: { padding: "24px" } }
|
|
566
572
|
*/
|
|
567
|
-
export function classesToStyles(
|
|
573
|
+
export function classesToStyles(
|
|
574
|
+
classes: string[],
|
|
575
|
+
knownTokens?: ReadonlySet<string>,
|
|
576
|
+
options?: { physicalize?: boolean },
|
|
577
|
+
): ResponsiveStyleObject | null {
|
|
568
578
|
const styles: ResponsiveStyleObject = { base: {}, tablet: {}, mobile: {} };
|
|
569
579
|
|
|
570
580
|
for (const className of classes) {
|
|
@@ -594,6 +604,25 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
|
|
|
594
604
|
}
|
|
595
605
|
}
|
|
596
606
|
|
|
607
|
+
// Physicalize logical box spacing so `px`/`py`/`mx`/`my` classes decode to REGULAR padding/margin
|
|
608
|
+
// (paddingLeft/Right, paddingTop/Bottom, or the compact `padding`/`margin` shorthand) rather than
|
|
609
|
+
// the logical `paddingInline`/`paddingBlock` their roots map to. Applied per breakpoint bucket, after
|
|
610
|
+
// the size-*/rounded-* mirroring above (which can add a physical side the shorthand collapse must
|
|
611
|
+
// see). The singular `classToStyle` keeps the logical prop — it can't express a two-sided expansion
|
|
612
|
+
// or the both-axes shorthand from one class — so this whole-object pass is the right home. No-op
|
|
613
|
+
// unless a logical box prop is present.
|
|
614
|
+
//
|
|
615
|
+
// `physicalize: false` keeps the LOGICAL axis prop (`paddingInline`/`paddingBlock`/…) so the Styles
|
|
616
|
+
// panel can show one "Padding X/Y" row per axis instead of two physical side rows (and re-emit the
|
|
617
|
+
// idiomatic `px-`/`py-` on save, not `pl-`+`pr-`). Only the panel adapter opts out; every other
|
|
618
|
+
// caller (e.g. the interactive-variant lossless gate) keeps the physical default it round-trips against.
|
|
619
|
+
if (options?.physicalize !== false) {
|
|
620
|
+
for (const bp of ['base', 'tablet', 'mobile'] as const) {
|
|
621
|
+
const bucket = styles[bp];
|
|
622
|
+
if (bucket) physicalizeBoxSpacing(bucket);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
597
626
|
// Clean up empty breakpoints
|
|
598
627
|
if (Object.keys(styles.tablet || {}).length === 0) delete styles.tablet;
|
|
599
628
|
if (Object.keys(styles.mobile || {}).length === 0) delete styles.mobile;
|
|
@@ -648,8 +677,17 @@ export function classMergeKey(className: string): string | null {
|
|
|
648
677
|
// `bg-[var(--gray-200)]` still conflicts with a root's `bg-accent`.
|
|
649
678
|
const dash = base.indexOf('-');
|
|
650
679
|
if (dash > 0) {
|
|
651
|
-
const
|
|
652
|
-
|
|
680
|
+
const root = base.slice(0, dash);
|
|
681
|
+
const suffix = base.slice(dash + 1);
|
|
682
|
+
if (/^[a-zA-Z][\w-]*$/.test(suffix)) {
|
|
683
|
+
const colorProp = COLOR_ROOT_TO_PROP[root];
|
|
684
|
+
if (colorProp) return `${bp}|${colorProp}`;
|
|
685
|
+
// Project theme-token class (`font-display`). Same recovery, for the roots the token
|
|
686
|
+
// fallback in parseUtilityClass covers — without it `font-display` has a null key and
|
|
687
|
+
// would sit alongside an instance's `font-sans` instead of overriding it.
|
|
688
|
+
const themeProp = THEME_SCALE_ROOT_PROPERTY[root];
|
|
689
|
+
if (themeProp) return `${bp}|${themeProp}`;
|
|
690
|
+
}
|
|
653
691
|
}
|
|
654
692
|
return null;
|
|
655
693
|
}
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
filterStepToArg,
|
|
36
36
|
} from './utilityClassConfig';
|
|
37
37
|
import { isCssNamedColor } from './cssNamedColors';
|
|
38
|
-
import { THEME_SCALE_BY_CLASS, themeScaleValue } from './tailwindThemeScale';
|
|
38
|
+
import { THEME_SCALE_BY_CLASS, resolveThemeScaleToken, themeScaleValue } from './tailwindThemeScale';
|
|
39
39
|
|
|
40
40
|
/** Class roots sorted longest-first so `border-t-…` wins over `border-…`. */
|
|
41
41
|
export const SORTED_ROOTS: readonly string[] = Object.keys(prefixToCSSProperty).sort((a, b) => b.length - a.length);
|
|
@@ -759,6 +759,19 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
|
|
|
759
759
|
return { property: resolveRootProperty(root, value), value, root, kind: 'var' };
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
+
// Project theme token on a theme-scale root (`font-display` → var(--font-display)), for
|
|
763
|
+
// suffixes outside the built-in scale that THEME_SCALE_BY_CLASS handles up front. Gated on
|
|
764
|
+
// the project token set, same as the color branch above.
|
|
765
|
+
//
|
|
766
|
+
// Deliberately placed AFTER the color branch so a bare color token keeps winning a shared
|
|
767
|
+
// root: `--text-primary` is a far more common COLOR name than a font-size one, and reading
|
|
768
|
+
// `text-primary` as a font-size the moment a theme declares `--text-primary` would silently
|
|
769
|
+
// break every existing project that names its text color that way.
|
|
770
|
+
const themeToken = resolveThemeScaleToken(root, rest, knownTokens);
|
|
771
|
+
if (themeToken) {
|
|
772
|
+
return { property: themeToken.property, value: themeToken.value, root, kind: 'var' };
|
|
773
|
+
}
|
|
774
|
+
|
|
762
775
|
// Hash fallback: value lives in the style-value registry
|
|
763
776
|
if (HASH_VALUE_RE.test(rest)) {
|
|
764
777
|
return { property: prefixToCSSProperty[root] ?? root, value: null, root, kind: 'hash' };
|
|
@@ -233,6 +233,53 @@ describe('Prop Validator', () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe('Reference props', () => {
|
|
237
|
+
test('single reference (id string) passes validation', () => {
|
|
238
|
+
const propDefs: Record<string, PropDefinition> = {
|
|
239
|
+
featured: { type: 'reference', collection: 'blog', multiple: false, default: '' },
|
|
240
|
+
};
|
|
241
|
+
const result = validateComponentProps(propDefs, { featured: 'post-1' });
|
|
242
|
+
expect(result.valid).toBe(true);
|
|
243
|
+
expect(result.props.featured).toBe('post-1');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('multi reference (ordered id array) passes validation and preserves order', () => {
|
|
247
|
+
const propDefs: Record<string, PropDefinition> = {
|
|
248
|
+
posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
|
|
249
|
+
};
|
|
250
|
+
const result = validateComponentProps(propDefs, { posts: ['post-7', 'post-1', 'post-3'] });
|
|
251
|
+
expect(result.valid).toBe(true);
|
|
252
|
+
expect(result.props.posts).toEqual(['post-7', 'post-1', 'post-3']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('a {{…}} binding string is preserved, not coerced away', () => {
|
|
256
|
+
const propDefs: Record<string, PropDefinition> = {
|
|
257
|
+
posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
|
|
258
|
+
};
|
|
259
|
+
const result = validateComponentProps(propDefs, { posts: '{{cms.relatedPosts}}' });
|
|
260
|
+
expect(result.valid).toBe(true);
|
|
261
|
+
expect(result.props.posts).toBe('{{cms.relatedPosts}}');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('missing reference prop uses its default', () => {
|
|
265
|
+
const propDefs: Record<string, PropDefinition> = {
|
|
266
|
+
posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
|
|
267
|
+
};
|
|
268
|
+
const result = validateComponentProps(propDefs, {});
|
|
269
|
+
expect(result.valid).toBe(true);
|
|
270
|
+
expect(result.props.posts).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('an invalid value (number) degrades gracefully to the default', () => {
|
|
274
|
+
const propDefs: Record<string, PropDefinition> = {
|
|
275
|
+
posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
|
|
276
|
+
};
|
|
277
|
+
const result = validateComponentProps(propDefs, { posts: 123 });
|
|
278
|
+
// Invalid type → validation error, but graceful degradation keeps the default
|
|
279
|
+
expect(result.props.posts).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
236
283
|
describe('Backward compatibility maintained', () => {
|
|
237
284
|
test('unknown props allowed but logged', () => {
|
|
238
285
|
const propDefs: Record<string, PropDefinition> = {
|
|
@@ -180,6 +180,14 @@ function validateSingleProp(
|
|
|
180
180
|
typeValid = Array.isArray(value) || isI18nValue(value);
|
|
181
181
|
break;
|
|
182
182
|
|
|
183
|
+
case 'reference':
|
|
184
|
+
// Reference stores a picked CMS item id (single) or an ordered array of ids (multi).
|
|
185
|
+
// A `{{…}}` binding string (e.g. an instance inside a CMS template) is also valid and
|
|
186
|
+
// resolved later — never coerce it away.
|
|
187
|
+
coercedValue = value;
|
|
188
|
+
typeValid = typeof value === 'string' || Array.isArray(value);
|
|
189
|
+
break;
|
|
190
|
+
|
|
183
191
|
default:
|
|
184
192
|
return {
|
|
185
193
|
valid: false,
|