@shoppexio/builder-runtime 0.1.0

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.
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance, createEmptyBuilderSettings, type BuilderSettings, type ThemeManifest } from '@shoppex/builder-contracts';
3
+ import {
4
+ builderBlock,
5
+ builderContent,
6
+ builderSlot,
7
+ canAddBlock,
8
+ createBuilderCss,
9
+ getBuilderContentList,
10
+ getBuilderContentString,
11
+ getPageBlocks,
12
+ resolveBlockSettings,
13
+ resolveStyleSlotValue,
14
+ } from './index.js';
15
+
16
+ function createSettings(): BuilderSettings {
17
+ return {
18
+ ...createEmptyBuilderSettings(1),
19
+ theme: {
20
+ content: {
21
+ 'hero.title': 'Launch sale',
22
+ faq: {
23
+ items: [{ question: 'Can I edit sections?', answer: 'Yes' }],
24
+ },
25
+ },
26
+ layout: {
27
+ home: {
28
+ blocks: [
29
+ createBlockInstance({
30
+ id: 'hero-1',
31
+ type: 'hero',
32
+ settings: { title: 'Hero block' },
33
+ style_overrides: {
34
+ 'button.radius': { base: 14 },
35
+ },
36
+ }),
37
+ ],
38
+ },
39
+ },
40
+ style_slots: {
41
+ 'button.radius': { base: 8, md: 12 },
42
+ 'color.primary': '#ff5500',
43
+ },
44
+ pages: [],
45
+ terms: {},
46
+ },
47
+ };
48
+ }
49
+
50
+ const manifest: ThemeManifest = {
51
+ id: 'default',
52
+ name: 'Default',
53
+ version: '2.0.0',
54
+ pages: {
55
+ home: {
56
+ label: 'Home',
57
+ allowedBlocks: ['hero'],
58
+ defaultBlocks: [],
59
+ },
60
+ },
61
+ blocks: {
62
+ hero: {
63
+ label: 'Hero',
64
+ variants: [],
65
+ settings: {
66
+ title: { type: 'text', label: 'Headline', defaultValue: 'Default hero' },
67
+ },
68
+ exposedStyleSlots: ['button.radius'],
69
+ presets: [],
70
+ },
71
+ },
72
+ styleSlots: {},
73
+ presets: {},
74
+ };
75
+
76
+ describe('@shoppex/builder-runtime', () => {
77
+ test('reads direct and nested content values', () => {
78
+ const settings = createSettings();
79
+
80
+ expect(getBuilderContentString(settings, 'hero.title')).toBe('Launch sale');
81
+ expect(getBuilderContentList(settings, 'faq.items')).toEqual([{ question: 'Can I edit sections?', answer: 'Yes' }]);
82
+ });
83
+
84
+ test('preserves intentionally empty content strings', () => {
85
+ const settings = createSettings();
86
+ settings.theme.content['hero.subtitle'] = '';
87
+
88
+ expect(getBuilderContentString(settings, 'hero.subtitle', 'Default subtitle')).toBe('');
89
+ });
90
+
91
+ test('resolves page blocks', () => {
92
+ const settings = createSettings();
93
+
94
+ expect(getPageBlocks(settings, 'home')).toHaveLength(1);
95
+ expect(getPageBlocks(settings, 'missing')).toEqual([]);
96
+ });
97
+
98
+ test('resolves style slots with breakpoint fallback and block override', () => {
99
+ const settings = createSettings();
100
+ const block = settings.theme.layout.home.blocks[0];
101
+
102
+ expect(resolveStyleSlotValue(settings, 'button.radius', { breakpoint: 'lg' })).toBe(12);
103
+ expect(resolveStyleSlotValue(settings, 'button.radius', { block, breakpoint: 'md' })).toBe(14);
104
+ });
105
+
106
+ test('emits CSS variables with responsive media blocks', () => {
107
+ const css = createBuilderCss(createSettings());
108
+
109
+ expect(css).toContain('--builder-button-radius: 8px;');
110
+ expect(css).toContain('--builder-color-primary: #ff5500;');
111
+ expect(css).toContain('@media (min-width: 768px)');
112
+ expect(css).toContain('--builder-button-radius: 12px;');
113
+ });
114
+
115
+ test('checks block limits against the manifest', () => {
116
+ const settings = createSettings();
117
+
118
+ expect(canAddBlock(settings, manifest, 'home', 'hero')).toBe(true);
119
+ expect(canAddBlock(settings, manifest, 'home', 'faq')).toBe(false);
120
+ });
121
+
122
+ test('merges block defaults from the manifest', () => {
123
+ const block = createBlockInstance({
124
+ id: 'hero-2',
125
+ type: 'hero',
126
+ settings: {},
127
+ });
128
+
129
+ expect(resolveBlockSettings(block, manifest)).toEqual({ title: 'Default hero' });
130
+ });
131
+
132
+ test('creates the three supported builder attributes', () => {
133
+ expect(builderContent('hero.title')).toEqual({ 'data-builder-content': 'hero.title' });
134
+ expect(builderSlot('button.radius', { blockId: 'hero-1' })).toEqual({
135
+ 'data-builder-slot': 'button.radius',
136
+ 'data-builder-block': 'hero-1',
137
+ });
138
+ expect(builderBlock('hero-1', 'hero')).toEqual({
139
+ 'data-builder-block': 'hero-1',
140
+ 'data-builder-block-type': 'hero',
141
+ });
142
+ });
143
+ });
package/src/content.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { BuilderSettings } from '@shoppex/builder-contracts';
2
+
3
+ export type JsonRecord = Record<string, unknown>;
4
+
5
+ export function getBuilderContentValue(settings: BuilderSettings, path: string): unknown {
6
+ const content = settings.theme.content;
7
+
8
+ if (Object.prototype.hasOwnProperty.call(content, path)) {
9
+ return content[path];
10
+ }
11
+
12
+ return getByDottedPath(content, path);
13
+ }
14
+
15
+ export function getBuilderContentString(settings: BuilderSettings, path: string, fallback?: string): string | undefined {
16
+ const value = getBuilderContentValue(settings, path);
17
+ return typeof value === 'string' ? value : fallback;
18
+ }
19
+
20
+ export function getBuilderContentList<T = unknown>(settings: BuilderSettings, path: string, fallback: T[] = []): T[] {
21
+ const value = getBuilderContentValue(settings, path);
22
+ return Array.isArray(value) ? (value as T[]) : fallback;
23
+ }
24
+
25
+ export function getBuilderContentRecord(settings: BuilderSettings): JsonRecord {
26
+ return settings.theme.content;
27
+ }
28
+
29
+ export function getBlockSettingValue<T = unknown>(
30
+ settings: BuilderSettings,
31
+ input: { pageId: string; blockId: string; path: string; fallback: T },
32
+ ): T {
33
+ const block = settings.theme.layout[input.pageId]?.blocks.find((candidate) => candidate.id === input.blockId);
34
+ if (!block) {
35
+ return input.fallback;
36
+ }
37
+
38
+ if (Object.prototype.hasOwnProperty.call(block.settings, input.path)) {
39
+ return block.settings[input.path] as T;
40
+ }
41
+
42
+ const nested = getByDottedPath(block.settings, input.path);
43
+ return nested === undefined ? input.fallback : (nested as T);
44
+ }
45
+
46
+ function getByDottedPath(record: JsonRecord, path: string): unknown {
47
+ let current: unknown = record;
48
+
49
+ for (const segment of path.split('.')) {
50
+ if (!current || typeof current !== 'object') {
51
+ return undefined;
52
+ }
53
+
54
+ current = (current as JsonRecord)[segment];
55
+ }
56
+
57
+ return current;
58
+ }
@@ -0,0 +1,124 @@
1
+ import type { BuilderSettings, Breakpoint, StyleSlotId, StyleSlots } from '@shoppex/builder-contracts';
2
+ import { CORE_STYLE_SLOT_IDS } from '@shoppex/builder-contracts';
3
+ import { isResponsiveRecord } from './style-slots.js';
4
+
5
+ const BREAKPOINT_MEDIA: Record<Exclude<Breakpoint, 'base'>, string> = {
6
+ sm: '(min-width: 640px)',
7
+ md: '(min-width: 768px)',
8
+ lg: '(min-width: 1024px)',
9
+ xl: '(min-width: 1280px)',
10
+ };
11
+
12
+ const STYLE_SLOT_CSS_VARIABLES: Record<StyleSlotId, { name: string; unit?: string }> = {
13
+ 'button.radius': { name: '--builder-button-radius', unit: 'px' },
14
+ 'button.background': { name: '--builder-button-background' },
15
+ 'button.foreground': { name: '--builder-button-foreground' },
16
+ 'button.border': { name: '--builder-button-border' },
17
+ 'button.font.weight': { name: '--builder-button-font-weight' },
18
+ 'input.radius': { name: '--builder-input-radius', unit: 'px' },
19
+ 'input.height': { name: '--builder-input-height', unit: 'px' },
20
+ 'input.border': { name: '--builder-input-border' },
21
+ 'input.background': { name: '--builder-input-background' },
22
+ 'input.foreground': { name: '--builder-input-foreground' },
23
+ 'card.radius': { name: '--builder-card-radius', unit: 'px' },
24
+ 'card.background': { name: '--builder-card-background' },
25
+ 'card.border': { name: '--builder-card-border' },
26
+ 'section.padding.y': { name: '--builder-section-padding-y', unit: 'px' },
27
+ 'section.padding.x': { name: '--builder-section-padding-x', unit: 'px' },
28
+ 'container.width': { name: '--builder-container-width', unit: 'px' },
29
+ 'color.primary': { name: '--builder-color-primary' },
30
+ 'color.accent': { name: '--builder-color-accent' },
31
+ 'color.background': { name: '--builder-color-background' },
32
+ 'color.foreground': { name: '--builder-color-foreground' },
33
+ 'color.muted': { name: '--builder-color-muted' },
34
+ 'link.color': { name: '--builder-link-color' },
35
+ 'typography.heading.weight': { name: '--builder-typography-heading-weight' },
36
+ 'typography.body.size': { name: '--builder-typography-body-size', unit: 'px' },
37
+ };
38
+
39
+ export function getStyleSlotCssVariable(slotId: StyleSlotId): string {
40
+ return STYLE_SLOT_CSS_VARIABLES[slotId].name;
41
+ }
42
+
43
+ export function createStyleSlotCssVariables(slots: StyleSlots): Record<string, string> {
44
+ const variables: Record<string, string> = {};
45
+
46
+ for (const slotId of CORE_STYLE_SLOT_IDS) {
47
+ const value = slots[slotId];
48
+ if (value === undefined || isResponsiveRecord(value)) {
49
+ continue;
50
+ }
51
+
52
+ variables[STYLE_SLOT_CSS_VARIABLES[slotId].name] = formatStyleSlotValue(slotId, value);
53
+ }
54
+
55
+ return variables;
56
+ }
57
+
58
+ export function createBuilderCss(settings: BuilderSettings, selector = ':root'): string {
59
+ return createStyleSlotsCss(settings.theme.style_slots, selector);
60
+ }
61
+
62
+ export function createStyleSlotsCss(slots: StyleSlots, selector = ':root'): string {
63
+ const baseDeclarations: string[] = [];
64
+ const responsiveDeclarations: Record<Exclude<Breakpoint, 'base'>, string[]> = {
65
+ sm: [],
66
+ md: [],
67
+ lg: [],
68
+ xl: [],
69
+ };
70
+
71
+ for (const slotId of CORE_STYLE_SLOT_IDS) {
72
+ const value = slots[slotId];
73
+ if (value === undefined) {
74
+ continue;
75
+ }
76
+
77
+ const cssVariable = STYLE_SLOT_CSS_VARIABLES[slotId].name;
78
+
79
+ if (!isResponsiveRecord(value)) {
80
+ baseDeclarations.push(`${cssVariable}: ${formatStyleSlotValue(slotId, value)};`);
81
+ continue;
82
+ }
83
+
84
+ if (value.base !== undefined) {
85
+ baseDeclarations.push(`${cssVariable}: ${formatStyleSlotValue(slotId, value.base)};`);
86
+ }
87
+
88
+ for (const breakpoint of ['sm', 'md', 'lg', 'xl'] as const) {
89
+ if (value[breakpoint] !== undefined) {
90
+ responsiveDeclarations[breakpoint].push(`${cssVariable}: ${formatStyleSlotValue(slotId, value[breakpoint])};`);
91
+ }
92
+ }
93
+ }
94
+
95
+ const chunks: string[] = [];
96
+
97
+ if (baseDeclarations.length > 0) {
98
+ chunks.push(`${selector} {\n ${baseDeclarations.join('\n ')}\n}`);
99
+ }
100
+
101
+ for (const breakpoint of ['sm', 'md', 'lg', 'xl'] as const) {
102
+ if (responsiveDeclarations[breakpoint].length === 0) {
103
+ continue;
104
+ }
105
+
106
+ chunks.push(
107
+ `@media ${BREAKPOINT_MEDIA[breakpoint]} {\n ${selector} {\n ${responsiveDeclarations[breakpoint].join(
108
+ '\n ',
109
+ )}\n }\n}`,
110
+ );
111
+ }
112
+
113
+ return chunks.join('\n\n');
114
+ }
115
+
116
+ function formatStyleSlotValue(slotId: StyleSlotId, value: unknown): string {
117
+ const unit = STYLE_SLOT_CSS_VARIABLES[slotId].unit;
118
+
119
+ if (typeof value === 'number') {
120
+ return unit ? `${value}${unit}` : `${value}`;
121
+ }
122
+
123
+ return String(value);
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './attributes.js';
2
+ export * from './content.js';
3
+ export * from './css-vars.js';
4
+ export * from './layout.js';
5
+ export * from './react.js';
6
+ export * from './style-slots.js';
package/src/jsdom.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare module 'jsdom' {
2
+ export class JSDOM {
3
+ window: Window & typeof globalThis;
4
+ constructor(html?: string, options?: { url?: string; referrer?: string });
5
+ }
6
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { BlockInstance, BuilderSettings, PageLayout, ThemeManifest } from '@shoppex/builder-contracts';
2
+
3
+ export function getPageLayout(settings: BuilderSettings, pageId: string): PageLayout {
4
+ return settings.theme.layout[pageId] ?? { blocks: [] };
5
+ }
6
+
7
+ export function getPageBlocks(settings: BuilderSettings, pageId: string): BlockInstance[] {
8
+ return getPageLayout(settings, pageId).blocks;
9
+ }
10
+
11
+ export function getVisiblePageBlocks(settings: BuilderSettings, pageId: string): BlockInstance[] {
12
+ return getPageBlocks(settings, pageId).filter((block) => block.visible);
13
+ }
14
+
15
+ export function getBlockById(settings: BuilderSettings, pageId: string, blockId: string): BlockInstance | null {
16
+ return getPageBlocks(settings, pageId).find((block) => block.id === blockId) ?? null;
17
+ }
18
+
19
+ export function getAllowedBlockTypes(manifest: ThemeManifest, pageId: string): string[] {
20
+ return manifest.pages[pageId]?.allowedBlocks ?? [];
21
+ }
22
+
23
+ export function canAddBlock(settings: BuilderSettings, manifest: ThemeManifest, pageId: string, blockType: string): boolean {
24
+ const page = manifest.pages[pageId];
25
+ const blockDefinition = manifest.blocks[blockType];
26
+
27
+ if (!page || !blockDefinition || !page.allowedBlocks.includes(blockType)) {
28
+ return false;
29
+ }
30
+
31
+ if (!blockDefinition.maxInstances) {
32
+ return true;
33
+ }
34
+
35
+ const currentCount = getPageBlocks(settings, pageId).filter((block) => block.type === blockType).length;
36
+ return currentCount < blockDefinition.maxInstances;
37
+ }
38
+
39
+ export function resolveBlockSettings(block: BlockInstance, manifest: ThemeManifest): Record<string, unknown> {
40
+ const blockDefinition = manifest.blocks[block.type];
41
+ if (!blockDefinition) {
42
+ return block.settings;
43
+ }
44
+
45
+ const defaults = Object.fromEntries(
46
+ Object.entries(blockDefinition.settings)
47
+ .filter(([, field]) => Object.prototype.hasOwnProperty.call(field, 'defaultValue'))
48
+ .map(([key, field]) => [key, field.defaultValue]),
49
+ );
50
+
51
+ return {
52
+ ...defaults,
53
+ ...block.settings,
54
+ };
55
+ }