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
|
@@ -1823,6 +1823,46 @@ describe('ComponentBuilder', () => {
|
|
|
1823
1823
|
|
|
1824
1824
|
expect(result).not.toBeNull();
|
|
1825
1825
|
});
|
|
1826
|
+
|
|
1827
|
+
describe('localized visibility (`if` as an _i18n value)', () => {
|
|
1828
|
+
// The canvas previews ONE locale at a time; a node whose `if` is per-locale must
|
|
1829
|
+
// appear/disappear with it, matching the built site's `i18n({…}) && ( … )`.
|
|
1830
|
+
const i18nConfig = {
|
|
1831
|
+
defaultLocale: 'en',
|
|
1832
|
+
locales: [
|
|
1833
|
+
{ code: 'en', name: 'EN', nativeName: 'English', langTag: 'en-US' },
|
|
1834
|
+
{ code: 'pl', name: 'PL', nativeName: 'Polski', langTag: 'pl-PL' },
|
|
1835
|
+
],
|
|
1836
|
+
};
|
|
1837
|
+
const node = {
|
|
1838
|
+
type: 'node',
|
|
1839
|
+
tag: 'div',
|
|
1840
|
+
if: { _i18n: true, en: true, pl: false },
|
|
1841
|
+
children: ['Content'],
|
|
1842
|
+
} as any as ComponentNode;
|
|
1843
|
+
|
|
1844
|
+
test('renders in a locale whose slot is true', () => {
|
|
1845
|
+
expect(builder.buildComponent({ node, i18nConfig, locale: 'en' })).not.toBeNull();
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
test('is dropped in a locale whose slot is false', () => {
|
|
1849
|
+
expect(builder.buildComponent({ node, i18nConfig, locale: 'pl' })).toBeNull();
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
test('with no active locale, falls back to the default locale slot', () => {
|
|
1853
|
+
expect(builder.buildComponent({ node, i18nConfig })).not.toBeNull();
|
|
1854
|
+
const hiddenByDefault = { ...(node as any), if: { _i18n: true, en: false, pl: true } };
|
|
1855
|
+
expect(builder.buildComponent({ node: hiddenByDefault, i18nConfig })).toBeNull();
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
test('a locale with no slot inherits the default locale slot', () => {
|
|
1859
|
+
expect(builder.buildComponent({ node, i18nConfig, locale: 'de' })).not.toBeNull();
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
test('a CMS locale override wins over the page locale (CMS-list preview parity)', () => {
|
|
1863
|
+
expect(builder.buildComponent({ node, i18nConfig, locale: 'en', cmsLocale: 'pl' })).toBeNull();
|
|
1864
|
+
});
|
|
1865
|
+
});
|
|
1826
1866
|
});
|
|
1827
1867
|
|
|
1828
1868
|
describe('Edge Case 7: List Paths Tracking (Collection Mode)', () => {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isValidNodeType,
|
|
28
28
|
markAsSlotContent,
|
|
29
29
|
isBooleanMapping,
|
|
30
|
+
resolveIfI18n,
|
|
30
31
|
hasIf,
|
|
31
32
|
hasChildren,
|
|
32
33
|
isSlotContent as isSlotContentNode,
|
|
@@ -183,7 +184,7 @@ export class ComponentBuilder {
|
|
|
183
184
|
|
|
184
185
|
/**
|
|
185
186
|
* Evaluate the if condition on a node with full context.
|
|
186
|
-
* Handles boolean, mapping, and string template values.
|
|
187
|
+
* Handles boolean, localized (`_i18n`), mapping, and string template values.
|
|
187
188
|
* Returns true if node should be rendered, false if it should be skipped.
|
|
188
189
|
*/
|
|
189
190
|
private evaluateIfCondition(node: ComponentNode, ctx: BuilderContext): boolean {
|
|
@@ -199,6 +200,12 @@ export class ComponentBuilder {
|
|
|
199
200
|
return ifValue;
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
// Localized visibility (`{ _i18n: true, en: true, pl: false }`) — the canvas previews
|
|
204
|
+
// the locale it is currently rendering, matching the built site's `i18n({…}) && (…)`.
|
|
205
|
+
if (isI18nValue(ifValue)) {
|
|
206
|
+
return Boolean(resolveIfI18n(ifValue, ctx.cmsLocale || ctx.locale, ctx.i18nConfig));
|
|
207
|
+
}
|
|
208
|
+
|
|
202
209
|
// Mapping value - resolve using component props
|
|
203
210
|
if (isBooleanMapping(ifValue)) {
|
|
204
211
|
const props = ctx.componentResolvedProps || {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static asset resolution across the two project layouts.
|
|
3
|
+
*
|
|
4
|
+
* An ASTRO project serves static files from `public/` (Astro's web root); a legacy JSON
|
|
5
|
+
* project keeps `images/`, `fonts/`, … at the project root. `handleStaticRoute` resolved
|
|
6
|
+
* only the root form, so the Meno Select (meno-core SSR) canvas 404'd every `/images/…`
|
|
7
|
+
* in an Astro project while the real Astro render served the same URL — the adlittle-web
|
|
8
|
+
* "photos load in Astro but not in Meno Select" report.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test, expect, describe, beforeAll, afterAll } from 'bun:test';
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { setProjectRoot } from '../projectContext';
|
|
16
|
+
import { handleStaticRoute } from './static';
|
|
17
|
+
|
|
18
|
+
let root: string;
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
root = mkdtempSync(join(tmpdir(), 'meno-static-'));
|
|
22
|
+
// Astro layout: the asset lives under public/.
|
|
23
|
+
mkdirSync(join(root, 'public', 'images', 'leaders'), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, 'public', 'images', 'leaders', 'ada.webp'), 'PUBLIC');
|
|
25
|
+
// Legacy JSON layout: the asset sits at the project root.
|
|
26
|
+
mkdirSync(join(root, 'images'), { recursive: true });
|
|
27
|
+
writeFileSync(join(root, 'images', 'legacy.webp'), 'ROOT');
|
|
28
|
+
// Same relative path present in BOTH — public/ must win (it is the served web root).
|
|
29
|
+
writeFileSync(join(root, 'images', 'both.webp'), 'ROOT');
|
|
30
|
+
mkdirSync(join(root, 'public', 'images'), { recursive: true });
|
|
31
|
+
writeFileSync(join(root, 'public', 'images', 'both.webp'), 'PUBLIC');
|
|
32
|
+
setProjectRoot(root);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
36
|
+
|
|
37
|
+
const get = (p: string) => handleStaticRoute(new URL(`http://localhost${p}`));
|
|
38
|
+
|
|
39
|
+
describe('handleStaticRoute — project asset layouts', () => {
|
|
40
|
+
test('ASTRO layout: /images/… resolves under public/', async () => {
|
|
41
|
+
const res = await get('/images/leaders/ada.webp');
|
|
42
|
+
expect(res).toBeDefined();
|
|
43
|
+
expect(await res!.text()).toBe('PUBLIC');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('LEGACY JSON layout: /images/… still resolves at the project root', async () => {
|
|
47
|
+
const res = await get('/images/legacy.webp');
|
|
48
|
+
expect(res).toBeDefined();
|
|
49
|
+
expect(await res!.text()).toBe('ROOT');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('public/ wins when the same path exists in both layouts', async () => {
|
|
53
|
+
const res = await get('/images/both.webp');
|
|
54
|
+
expect(await res!.text()).toBe('PUBLIC');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a genuinely missing asset is still undefined (404), not the public/ probe', async () => {
|
|
58
|
+
expect(await get('/images/nope.webp')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('path traversal via the public/ probe stays contained', async () => {
|
|
62
|
+
writeFileSync(join(root, 'secret.txt'), 'SECRET');
|
|
63
|
+
// `/images/../../secret.txt` must not escape the project root through either candidate.
|
|
64
|
+
const res = await get('/images/../../secret.txt');
|
|
65
|
+
if (res) expect(await res.text()).not.toBe('SECRET');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -85,7 +85,15 @@ export async function handleStaticRoute(url: URL, _req?: Request): Promise<Respo
|
|
|
85
85
|
decodedPathname.startsWith('/videos/')
|
|
86
86
|
) {
|
|
87
87
|
rootPath = getProjectRoot();
|
|
88
|
-
|
|
88
|
+
const rel = decodedPathname.slice(1); // Remove leading /
|
|
89
|
+
// Asset layout differs by project format: an ASTRO project serves static files from
|
|
90
|
+
// `public/` (Astro's web root), a legacy JSON project keeps them at the project root.
|
|
91
|
+
// Try `public/` first and fall back, so both resolve. Without the `public/` probe the
|
|
92
|
+
// Meno Select (meno-core SSR) canvas 404s EVERY image in an Astro project — the real
|
|
93
|
+
// Astro render serves the same `/images/…` URL fine, so it looks like a renderer bug.
|
|
94
|
+
const fromPublic = resolveProjectPath('public', rel);
|
|
95
|
+
filePath =
|
|
96
|
+
isPathWithinRoot(fromPublic, rootPath) && (await fileExists(fromPublic)) ? fromPublic : resolveProjectPath(rel);
|
|
89
97
|
} else {
|
|
90
98
|
rootPath = getPackageRoot();
|
|
91
99
|
filePath = resolvePackagePath(decodedPathname.slice(1)); // Remove leading /
|
|
@@ -609,11 +609,18 @@ export async function generateSSRHTML(
|
|
|
609
609
|
printMissingStyleWarnings(false);
|
|
610
610
|
|
|
611
611
|
// Build base CSS (reset styles)
|
|
612
|
+
// The border line mirrors meno-astro's BaseLayout `@layer base` reset (Tailwind Preflight
|
|
613
|
+
// parity) so the design canvas and the real Astro build agree on border rendering. Without
|
|
614
|
+
// it, `border-l-[3px] border-solid` — correct Tailwind — paints a full box instead of one
|
|
615
|
+
// edge, because a bare `border-style` with no zero-width baseline arms all four edges.
|
|
612
616
|
const baseCSS = `* {
|
|
613
617
|
margin: 0;
|
|
614
618
|
padding: 0;
|
|
615
619
|
box-sizing: border-box;
|
|
616
620
|
}
|
|
621
|
+
*, ::before, ::after, ::backdrop, ::file-selector-button {
|
|
622
|
+
border: 0 solid;
|
|
623
|
+
}
|
|
617
624
|
body {
|
|
618
625
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
|
619
626
|
}
|
|
@@ -522,6 +522,37 @@ describe('ssrRenderer', () => {
|
|
|
522
522
|
expect(html).toContain('Hello');
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
+
test('resolves _i18n on `if` — the node renders only in the locales left on', async () => {
|
|
526
|
+
const node = {
|
|
527
|
+
type: 'node',
|
|
528
|
+
tag: 'div',
|
|
529
|
+
children: [
|
|
530
|
+
{ type: 'node', tag: 'span', if: { _i18n: true, en: true, pl: false }, children: 'english-only' },
|
|
531
|
+
{ type: 'node', tag: 'em', if: { _i18n: true, en: false, pl: true }, children: 'polish-only' },
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const enHtml = await render(node, { i18nConfig });
|
|
536
|
+
expect(enHtml).toContain('english-only');
|
|
537
|
+
expect(enHtml).not.toContain('polish-only');
|
|
538
|
+
|
|
539
|
+
const plHtml = await render(node, { locale: 'pl', i18nConfig });
|
|
540
|
+
expect(plHtml).toContain('polish-only');
|
|
541
|
+
expect(plHtml).not.toContain('english-only');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('a locale with no `if` slot falls back to the default locale', async () => {
|
|
545
|
+
const node = {
|
|
546
|
+
type: 'node',
|
|
547
|
+
tag: 'div',
|
|
548
|
+
children: [
|
|
549
|
+
{ type: 'node', tag: 'span', if: { _i18n: true, en: false, pl: true }, children: 'hidden-by-default' },
|
|
550
|
+
],
|
|
551
|
+
};
|
|
552
|
+
const html = await render(node, { locale: 'de', i18nConfig });
|
|
553
|
+
expect(html).not.toContain('hidden-by-default');
|
|
554
|
+
});
|
|
555
|
+
|
|
525
556
|
test('precedence: _i18n: true wins over a stray type/tag on the same object', async () => {
|
|
526
557
|
// If someone accidentally writes both _i18n and type on the same object,
|
|
527
558
|
// i18n wins — the renderer treats it as a localized string, not a node.
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
isCustomNode,
|
|
47
47
|
markAsSlotContent,
|
|
48
48
|
isBooleanMapping,
|
|
49
|
+
resolveIfI18n,
|
|
49
50
|
hasIf,
|
|
50
51
|
isSlotContent,
|
|
51
52
|
} from '../../shared/nodeUtils';
|
|
@@ -328,7 +329,7 @@ function processStyleToClasses(style: StyleObject | ResponsiveStyleObject | unde
|
|
|
328
329
|
|
|
329
330
|
/**
|
|
330
331
|
* Evaluate the if condition on a node with full SSR context.
|
|
331
|
-
* Handles boolean, mapping, and string template values.
|
|
332
|
+
* Handles boolean, localized (`_i18n`), mapping, and string template values.
|
|
332
333
|
* Returns true if node should be rendered, false if it should be skipped.
|
|
333
334
|
*/
|
|
334
335
|
function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
|
|
@@ -344,6 +345,12 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
|
|
|
344
345
|
return ifValue;
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
// Localized visibility (`{ _i18n: true, en: true, pl: false }`) — resolve the route's
|
|
349
|
+
// locale slot, mirroring the emitted `i18n({…}) && (…)` in a real Astro build.
|
|
350
|
+
if (isI18nValue(ifValue)) {
|
|
351
|
+
return Boolean(resolveIfI18n(ifValue, ctx.locale, ctx.i18nConfig));
|
|
352
|
+
}
|
|
353
|
+
|
|
347
354
|
// Mapping value - resolve using component props
|
|
348
355
|
if (isBooleanMapping(ifValue)) {
|
|
349
356
|
const props = ctx.componentResolvedProps || {};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
applyContainerPattern,
|
|
9
9
|
styleObjectToCSS,
|
|
10
10
|
} from './cssGeneration';
|
|
11
|
+
import { physicalizeBoxSpacing } from './styleUtils';
|
|
11
12
|
import { DEFAULT_BREAKPOINTS } from './breakpoints';
|
|
12
13
|
import type { ResponsiveScales } from './responsiveScaling';
|
|
13
14
|
import type { InteractiveStyles } from './types/styles';
|
|
@@ -116,6 +117,54 @@ describe('generateUtilityCSS end-to-end', () => {
|
|
|
116
117
|
expect(posShorthand).toBeGreaterThanOrEqual(0);
|
|
117
118
|
expect(posLonghand).toBeGreaterThan(posShorthand); // shorthand precedes longhand → mb wins
|
|
118
119
|
});
|
|
120
|
+
|
|
121
|
+
test('auto-responsive @media keeps axis roots expanded to longhands', () => {
|
|
122
|
+
// `py`/`px`/`mx`/`my` map to the padding/margin FAMILY for scale lookups, but the
|
|
123
|
+
// emitted declaration must stay two longhands — a scaled `padding: 63px` would paint
|
|
124
|
+
// all four sides, so a `py-[110px]` silently grows horizontal padding on mobile.
|
|
125
|
+
const scales: ResponsiveScales = {
|
|
126
|
+
enabled: true,
|
|
127
|
+
mode: 'breakpoints',
|
|
128
|
+
baseReference: 16,
|
|
129
|
+
padding: { mobile: 0.5 },
|
|
130
|
+
margin: { mobile: 0.5 },
|
|
131
|
+
};
|
|
132
|
+
const css = generateUtilityCSS(new Set(['py-[110px]', 'px-[40px]', 'my-[80px]']), DEFAULT_BREAKPOINTS, scales);
|
|
133
|
+
const mobileBlock = css.slice(css.indexOf('@media (max-width: 540px)'));
|
|
134
|
+
// 110 + (110-16)*(0.5-1) = 63 · 40 + (40-16)*(0.5-1) = 28 · 80 + (80-16)*(0.5-1) = 48
|
|
135
|
+
expect(mobileBlock).toContain('.py-\\[110px\\] { padding-top: 63px; padding-bottom: 63px; }');
|
|
136
|
+
expect(mobileBlock).toContain('.px-\\[40px\\] { padding-left: 28px; padding-right: 28px; }');
|
|
137
|
+
expect(mobileBlock).toContain('.my-\\[80px\\] { margin-top: 48px; margin-bottom: 48px; }');
|
|
138
|
+
expect(mobileBlock).not.toContain('padding: ');
|
|
139
|
+
expect(mobileBlock).not.toContain('margin: ');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('generateSingleClassCSS keeps axis roots expanded in its @media rules', () => {
|
|
143
|
+
const scales: ResponsiveScales = {
|
|
144
|
+
enabled: true,
|
|
145
|
+
mode: 'breakpoints',
|
|
146
|
+
baseReference: 16,
|
|
147
|
+
padding: { mobile: 0.5 },
|
|
148
|
+
};
|
|
149
|
+
const css = generateSingleClassCSS('py-[110px]', DEFAULT_BREAKPOINTS, scales);
|
|
150
|
+
expect(css).toContain('.py-\\[110px\\] { padding-top: 110px; padding-bottom: 110px; }');
|
|
151
|
+
expect(css).toContain('padding-top: 63px; padding-bottom: 63px;');
|
|
152
|
+
expect(css).not.toContain('padding: ');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('fluid mode keeps axis roots expanded on the base rule', () => {
|
|
156
|
+
const scales: ResponsiveScales = {
|
|
157
|
+
enabled: true,
|
|
158
|
+
mode: 'fluid',
|
|
159
|
+
baseReference: 16,
|
|
160
|
+
fluidRange: { min: 320, max: 1440 },
|
|
161
|
+
padding: { mobile: 0.5 },
|
|
162
|
+
};
|
|
163
|
+
const css = generateUtilityCSS(new Set(['py-[110px]']), DEFAULT_BREAKPOINTS, scales);
|
|
164
|
+
expect(css).toContain('padding-top: clamp(63px,');
|
|
165
|
+
expect(css).toContain('padding-bottom: clamp(63px,');
|
|
166
|
+
expect(css).not.toContain('padding: clamp(');
|
|
167
|
+
});
|
|
119
168
|
});
|
|
120
169
|
|
|
121
170
|
describe('cssGeneration', () => {
|
|
@@ -147,15 +196,15 @@ describe('cssGeneration', () => {
|
|
|
147
196
|
|
|
148
197
|
test('border (full shorthand) still includes color', () => {
|
|
149
198
|
const rule = generateRuleForClass('border-[1px_solid_var(--primary)]');
|
|
150
|
-
expect(rule).toBe('border: 1px solid var(--primary);');
|
|
199
|
+
expect(rule).toBe('border-width: 1px; border-style: solid; border-color: var(--primary);');
|
|
151
200
|
});
|
|
152
201
|
|
|
153
|
-
test('bare / numeric border widths render
|
|
202
|
+
test('bare / numeric border widths render visibly (no Preflight)', () => {
|
|
154
203
|
// `border` alone would be invisible without a border-style, so it expands to `<width> solid`
|
|
155
204
|
// (color omitted → defaults to currentColor).
|
|
156
|
-
expect(generateRuleForClass('border')).toBe('border: 1px solid;');
|
|
157
|
-
expect(generateRuleForClass('border-2')).toBe('border: 2px solid;');
|
|
158
|
-
expect(generateRuleForClass('border-0')).toBe('border: 0px solid;');
|
|
205
|
+
expect(generateRuleForClass('border')).toBe('border-width: 1px; border-style: solid;');
|
|
206
|
+
expect(generateRuleForClass('border-2')).toBe('border-width: 2px; border-style: solid;');
|
|
207
|
+
expect(generateRuleForClass('border-0')).toBe('border-width: 0px; border-style: solid;');
|
|
159
208
|
});
|
|
160
209
|
|
|
161
210
|
test('bare / numeric per-side widths split into longhands, no color (a border-color class wins)', () => {
|
|
@@ -164,8 +213,37 @@ describe('cssGeneration', () => {
|
|
|
164
213
|
});
|
|
165
214
|
|
|
166
215
|
test('logical-axis border widths use border-inline / border-block', () => {
|
|
167
|
-
expect(generateRuleForClass('border-x')).toBe('border-inline: 1px solid;');
|
|
168
|
-
expect(generateRuleForClass('border-y-2')).toBe('border-block: 2px solid;');
|
|
216
|
+
expect(generateRuleForClass('border-x')).toBe('border-inline-width: 1px; border-inline-style: solid;');
|
|
217
|
+
expect(generateRuleForClass('border-y-2')).toBe('border-block-width: 2px; border-block-style: solid;');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('an ARBITRARY width-only border renders one visible edge, like its scale form', () => {
|
|
221
|
+
// Regression: these used to emit the `border-left: 3px` SHORTHAND, which resets the style to
|
|
222
|
+
// the initial `none` — an invisible border. Authors then reached for a blanket
|
|
223
|
+
// `[border-style:solid]`, which painted all four edges at the default `medium` width.
|
|
224
|
+
expect(generateRuleForClass('border-l-[3px]')).toBe('border-left-width: 3px; border-left-style: solid;');
|
|
225
|
+
expect(generateRuleForClass('border-t-[1px]')).toBe('border-top-width: 1px; border-top-style: solid;');
|
|
226
|
+
expect(generateRuleForClass('border-[3px]')).toBe('border-width: 3px; border-style: solid;');
|
|
227
|
+
expect(generateRuleForClass('border-x-[2px]')).toBe('border-inline-width: 2px; border-inline-style: solid;');
|
|
228
|
+
expect(generateRuleForClass('border-l-[0.5rem]')).toBe('border-left-width: 0.5rem; border-left-style: solid;');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('an arbitrary lone style keyword stays a style, not a width', () => {
|
|
232
|
+
expect(generateRuleForClass('border-l-[dashed]')).toBe('border-left-style: dashed;');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a math-function width is not shredded at its inner spaces', () => {
|
|
236
|
+
// Splitting naively on whitespace produced `border-bottom-width: calc(1px; ... style: +;`.
|
|
237
|
+
expect(generateRuleForClass('border-b-[calc(1px_+_2px)]')).toBe(
|
|
238
|
+
'border-bottom-width: calc(1px + 2px); border-bottom-style: solid;',
|
|
239
|
+
);
|
|
240
|
+
expect(generateRuleForClass('border-b-[calc(1px_+_2px)_solid_red]')).toBe(
|
|
241
|
+
'border-bottom-width: calc(1px + 2px); border-bottom-style: solid; border-bottom-color: red;',
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('an ambiguous var() value stays a shorthand (it may hold a whole border)', () => {
|
|
246
|
+
expect(generateRuleForClass('border-l-(--w)')).toBe('border-left: var(--w);');
|
|
169
247
|
});
|
|
170
248
|
|
|
171
249
|
test('border style keyword on its own', () => {
|
|
@@ -197,15 +275,16 @@ describe('cssGeneration', () => {
|
|
|
197
275
|
expect(generateRuleForClass('border-[rgb(0_0_0_/_50%)]')).toBe('border-color: rgb(0 0 0 / 50%);');
|
|
198
276
|
});
|
|
199
277
|
|
|
200
|
-
test('transparent border-color sorts AFTER the border
|
|
278
|
+
test('transparent border-color sorts AFTER the border width/style so it always wins', () => {
|
|
201
279
|
// The two classes target distinct properties (border vs border-color), so the longhand
|
|
202
280
|
// must follow the shorthand in source order regardless of which was collected first.
|
|
281
|
+
// The width/style pair no longer resets the color at all, so this is belt-and-braces.
|
|
203
282
|
const css = generateUtilityCSS(
|
|
204
283
|
new Set(['border-[rgba(0,_0,_0,_0)]', 'border-[1px_solid]']),
|
|
205
284
|
DEFAULT_BREAKPOINTS,
|
|
206
285
|
{ enabled: false } as ResponsiveScales,
|
|
207
286
|
);
|
|
208
|
-
const posShorthand = css.indexOf('border: 1px solid;');
|
|
287
|
+
const posShorthand = css.indexOf('border-width: 1px; border-style: solid;');
|
|
209
288
|
const posColor = css.indexOf('border-color: rgba(0, 0, 0, 0);');
|
|
210
289
|
expect(posShorthand).toBeGreaterThanOrEqual(0);
|
|
211
290
|
expect(posColor).toBeGreaterThan(posShorthand);
|
|
@@ -776,4 +855,66 @@ describe('styleObjectToCSS', () => {
|
|
|
776
855
|
test('empty object yields an empty string', () => {
|
|
777
856
|
expect(styleObjectToCSS({})).toBe('');
|
|
778
857
|
});
|
|
858
|
+
|
|
859
|
+
test('logical px/py-derived box props emit REGULAR physical padding, never padding-inline/block', () => {
|
|
860
|
+
// Both axes → one compact shorthand `padding: <block> <inline>` (CSS's vertical horizontal).
|
|
861
|
+
expect(styleObjectToCSS({ paddingBlock: '100px', paddingInline: '0px' })).toBe('padding: 100px 0px');
|
|
862
|
+
// A lone axis → its two physical side longhands.
|
|
863
|
+
expect(styleObjectToCSS({ paddingInline: '100px' })).toBe('padding-left: 100px; padding-right: 100px');
|
|
864
|
+
expect(styleObjectToCSS({ marginBlock: '8px' })).toBe('margin-top: 8px; margin-bottom: 8px');
|
|
865
|
+
// An explicit side wins over the axis (no clobber); the other side is still filled from the axis.
|
|
866
|
+
expect(styleObjectToCSS({ paddingInline: '100px', paddingLeft: '50px' })).toBe(
|
|
867
|
+
'padding-left: 50px; padding-right: 100px',
|
|
868
|
+
);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test('does not mutate the caller object', () => {
|
|
872
|
+
const input = { paddingInline: '100px', paddingBlock: '0px' };
|
|
873
|
+
styleObjectToCSS(input);
|
|
874
|
+
expect(input).toEqual({ paddingInline: '100px', paddingBlock: '0px' });
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('physicalizeBoxSpacing', () => {
|
|
879
|
+
// Cast inputs to a broad record: the generic return type carries the input's literal keys, which
|
|
880
|
+
// would make `toEqual` on the reshaped result a type error even though the runtime is correct.
|
|
881
|
+
const boxed = (o: Record<string, unknown>) => physicalizeBoxSpacing(o);
|
|
882
|
+
|
|
883
|
+
test('both axes with no competing key collapse to a shorthand', () => {
|
|
884
|
+
expect(boxed({ paddingInline: '0px', paddingBlock: '100px' })).toEqual({ padding: '100px 0px' });
|
|
885
|
+
expect(boxed({ marginInline: '0px', marginBlock: '24px' })).toEqual({ margin: '24px 0px' });
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('a lone axis expands to its two physical side longhands', () => {
|
|
889
|
+
expect(boxed({ paddingInline: '100px' })).toEqual({ paddingLeft: '100px', paddingRight: '100px' });
|
|
890
|
+
expect(boxed({ paddingBlock: '100px' })).toEqual({ paddingTop: '100px', paddingBottom: '100px' });
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test('a competing physical/shorthand key blocks the shorthand and never clobbers explicit sides', () => {
|
|
894
|
+
// `padding` shorthand already present → axes expand to sides layered over it.
|
|
895
|
+
expect(boxed({ padding: '4px', paddingInline: '100px' })).toEqual({
|
|
896
|
+
padding: '4px',
|
|
897
|
+
paddingLeft: '100px',
|
|
898
|
+
paddingRight: '100px',
|
|
899
|
+
});
|
|
900
|
+
// An explicit side is preserved; the axis fills only the missing side.
|
|
901
|
+
expect(boxed({ paddingInline: '100px', paddingLeft: '50px' })).toEqual({
|
|
902
|
+
paddingLeft: '50px',
|
|
903
|
+
paddingRight: '100px',
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test('a bound (_mapping) axis is kept as a side longhand rather than string-composed', () => {
|
|
908
|
+
const mapping = { _mapping: { prop: 'x' } };
|
|
909
|
+
expect(boxed({ paddingInline: mapping, paddingBlock: '10px' })).toEqual({
|
|
910
|
+
paddingLeft: mapping,
|
|
911
|
+
paddingRight: mapping,
|
|
912
|
+
paddingTop: '10px',
|
|
913
|
+
paddingBottom: '10px',
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('no logical box props → untouched', () => {
|
|
918
|
+
expect(boxed({ padding: '10px', color: 'red' })).toEqual({ padding: '10px', color: 'red' });
|
|
919
|
+
});
|
|
779
920
|
});
|