meno-core 1.1.6 → 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-GHNLTFCN.js → chunk-G6OXV2IV.js} +3 -3
- package/dist/chunks/{chunk-GHNLTFCN.js.map → chunk-G6OXV2IV.js.map} +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 +64 -0
- package/lib/server/ssr/ssrRenderer.ts +8 -1
- package/lib/shared/attributeNodeUtils.test.ts +25 -0
- package/lib/shared/attributeNodeUtils.ts +9 -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
|
@@ -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.
|
|
@@ -1850,6 +1881,39 @@ describe('ssrRenderer', () => {
|
|
|
1850
1881
|
expect(html).toBe('');
|
|
1851
1882
|
});
|
|
1852
1883
|
|
|
1884
|
+
// A list re-renders the SAME child node object once per item. The link/embed/locale-list
|
|
1885
|
+
// builders delete `class` off the attributes they extract, which used to be the node's live
|
|
1886
|
+
// object — so item 0 consumed the class-string styling and items 1..n rendered bare.
|
|
1887
|
+
// (extractAttributesFromNode now returns a copy.)
|
|
1888
|
+
test('class-string styling on a LINK inside a list applies to EVERY item, not just the first', async () => {
|
|
1889
|
+
const node = {
|
|
1890
|
+
type: 'list',
|
|
1891
|
+
sourceType: 'prop',
|
|
1892
|
+
source: ['a', 'b', 'c'],
|
|
1893
|
+
children: [{ type: 'link', href: '/x', attributes: { class: 'p-[24px]' }, children: '{{item}}' }],
|
|
1894
|
+
};
|
|
1895
|
+
const html = await render(node);
|
|
1896
|
+
expect(html.match(/p-\[24px\]/g) ?? []).toHaveLength(3);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
test('class-string styling on an EMBED inside a list applies to every item', async () => {
|
|
1900
|
+
const node = {
|
|
1901
|
+
type: 'list',
|
|
1902
|
+
sourceType: 'prop',
|
|
1903
|
+
source: ['a', 'b', 'c'],
|
|
1904
|
+
children: [{ type: 'embed', html: '<i></i>', attributes: { class: 'p-[24px]' } }],
|
|
1905
|
+
};
|
|
1906
|
+
const html = await render(node);
|
|
1907
|
+
expect(html.match(/p-\[24px\]/g) ?? []).toHaveLength(3);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test('a list never consumes its child node’s attributes (model is not mutated by render)', async () => {
|
|
1911
|
+
const child = { type: 'link', href: '/x', attributes: { class: 'p-[24px]' }, children: '{{item}}' };
|
|
1912
|
+
const node = { type: 'list', sourceType: 'prop', source: ['a', 'b'], children: [child] };
|
|
1913
|
+
await render(node);
|
|
1914
|
+
expect(child.attributes).toEqual({ class: 'p-[24px]' });
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1853
1917
|
test('renders list with collection source but no CMS service', async () => {
|
|
1854
1918
|
const node = {
|
|
1855
1919
|
type: 'list',
|
|
@@ -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 || {};
|
|
@@ -59,6 +59,31 @@ describe('attributeNodeUtils', () => {
|
|
|
59
59
|
expect(extractAttributesFromNode('string' as any)).toEqual({});
|
|
60
60
|
expect(extractAttributesFromNode(123 as any)).toEqual({});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
// Callers treat the result as a scratch bag: the link/embed/locale-list builders delete
|
|
64
|
+
// `class` off it once folded into the element's class list. Returning the live reference made
|
|
65
|
+
// that delete consume the MODEL's class, so a list (which re-renders the same child node once
|
|
66
|
+
// per item) styled only its first item.
|
|
67
|
+
test('returns a COPY — mutating the result never touches the node', () => {
|
|
68
|
+
const node: ComponentNode = {
|
|
69
|
+
type: 'node' as const,
|
|
70
|
+
tag: 'a',
|
|
71
|
+
attributes: { class: 'p-[24px]', id: 'test' },
|
|
72
|
+
};
|
|
73
|
+
const extracted = extractAttributesFromNode(node);
|
|
74
|
+
delete (extracted as Record<string, unknown>).class;
|
|
75
|
+
|
|
76
|
+
expect(extracted).toEqual({ id: 'test' });
|
|
77
|
+
expect(node.attributes).toEqual({ class: 'p-[24px]', id: 'test' });
|
|
78
|
+
// A second extraction still sees the class — the per-list-item render path.
|
|
79
|
+
expect(extractAttributesFromNode(node)).toEqual({ class: 'p-[24px]', id: 'test' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('successive extractions are independent objects', () => {
|
|
83
|
+
const node: ComponentNode = { type: 'node' as const, tag: 'div', attributes: { id: 'a' } };
|
|
84
|
+
expect(extractAttributesFromNode(node)).not.toBe(extractAttributesFromNode(node));
|
|
85
|
+
expect(extractAttributesFromNode(node)).not.toBe(node.attributes);
|
|
86
|
+
});
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
describe('applyAttributesToNode', () => {
|
|
@@ -10,6 +10,14 @@ import { isComponentNode, isHtmlNode } from './nodeUtils';
|
|
|
10
10
|
* Extract attributes from a ComponentNode
|
|
11
11
|
* Attributes are stored at the top level of the node (like styles)
|
|
12
12
|
* Returns a record of attributes ready for React props
|
|
13
|
+
*
|
|
14
|
+
* Always a fresh COPY, never the node's live `attributes` object. Callers treat the result as a
|
|
15
|
+
* scratch bag they may mutate — the link/embed/locale-list builders `delete` `class`/`className`
|
|
16
|
+
* off it once those tokens have been folded into the element's class list. Handing out the
|
|
17
|
+
* reference made that delete mutate the MODEL, and because a list renders the same child node
|
|
18
|
+
* objects once per item, item 0 consumed the class and items 1..n rendered without it (the
|
|
19
|
+
* "class-string styles only apply to the first list item" bug). Node.style was never affected —
|
|
20
|
+
* it is read fresh per render — so this only ever bit `attributes.class` styling.
|
|
13
21
|
*/
|
|
14
22
|
export function extractAttributesFromNode(node: ComponentNode): Record<string, string | number | boolean> {
|
|
15
23
|
if (!node || typeof node !== 'object') {
|
|
@@ -18,7 +26,7 @@ export function extractAttributesFromNode(node: ComponentNode): Record<string, s
|
|
|
18
26
|
|
|
19
27
|
// Attributes are stored at top level for both HTML nodes and component instances
|
|
20
28
|
if ('attributes' in node && node.attributes) {
|
|
21
|
-
return node.attributes as Record<string, string | number | boolean
|
|
29
|
+
return { ...(node.attributes as Record<string, string | number | boolean>) };
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
return {};
|
|
@@ -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
|
});
|