create-nextblock 0.14.2 → 0.14.4
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/package.json +1 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +375 -117
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
- package/templates/nextblock-template/app/layout.tsx +49 -0
- package/templates/nextblock-template/app/providers.tsx +16 -4
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
- package/templates/nextblock-template/components/theme-icon.tsx +78 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
- package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +92 -87
- package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
TEXT_COLOR_CLASSES,
|
|
4
|
+
TEXT_COLOR_TOKENS,
|
|
5
|
+
TEXT_COLOR_TOKEN_OPTIONS,
|
|
6
|
+
isCustomCssColor,
|
|
7
|
+
isTextColorToken,
|
|
8
|
+
resolveTextAlign,
|
|
9
|
+
resolveTextColor,
|
|
10
|
+
} from './blockColors';
|
|
11
|
+
import { HeadingBlockSchema } from './blockRegistry';
|
|
12
|
+
|
|
13
|
+
describe('resolveTextColor', () => {
|
|
14
|
+
it('maps theme tokens to a static class and no inline style', () => {
|
|
15
|
+
expect(resolveTextColor('primary')).toEqual({ className: 'text-primary' });
|
|
16
|
+
expect(resolveTextColor('foreground')).toEqual({ className: 'text-foreground' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('maps "muted" to the readable foreground pair, not the surface colour', () => {
|
|
20
|
+
// Regression: the renderer used to emit `text-muted` (hsl(var(--muted)),
|
|
21
|
+
// a near-white surface) while both CMS previews used muted-foreground.
|
|
22
|
+
expect(resolveTextColor('muted')).toEqual({ className: 'text-muted-foreground' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders custom colours as an inline style', () => {
|
|
26
|
+
expect(resolveTextColor('#FF8800')).toEqual({ style: { color: '#FF8800' } });
|
|
27
|
+
expect(resolveTextColor('rgba(255, 136, 0, 0.8)')).toEqual({ style: { color: 'rgba(255, 136, 0, 0.8)' } });
|
|
28
|
+
expect(resolveTextColor('hsl(28, 100%, 50%)')).toEqual({ style: { color: 'hsl(28, 100%, 50%)' } });
|
|
29
|
+
expect(resolveTextColor('#f80')).toEqual({ style: { color: '#f80' } });
|
|
30
|
+
expect(resolveTextColor('#FF880080')).toEqual({ style: { color: '#FF880080' } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('trims incidental whitespace', () => {
|
|
34
|
+
expect(resolveTextColor(' primary ')).toEqual({ className: 'text-primary' });
|
|
35
|
+
expect(resolveTextColor(' #FF8800 ')).toEqual({ style: { color: '#FF8800' } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('degrades to inherited colour rather than emitting a bogus class', () => {
|
|
39
|
+
expect(resolveTextColor(undefined)).toEqual({});
|
|
40
|
+
expect(resolveTextColor(null)).toEqual({});
|
|
41
|
+
expect(resolveTextColor('')).toEqual({});
|
|
42
|
+
expect(resolveTextColor('chartreuse')).toEqual({});
|
|
43
|
+
expect(resolveTextColor('javascript:alert(1)')).toEqual({});
|
|
44
|
+
expect(resolveTextColor('url(https://evil.test/x.png)')).toEqual({});
|
|
45
|
+
expect(resolveTextColor('red; background: url(x)')).toEqual({});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('never emits an interpolated class name', () => {
|
|
49
|
+
for (const token of TEXT_COLOR_TOKENS) {
|
|
50
|
+
const { className } = resolveTextColor(token);
|
|
51
|
+
expect(className).toBe(TEXT_COLOR_CLASSES[token]);
|
|
52
|
+
expect(className?.startsWith('text-')).toBe(true);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('resolveTextAlign', () => {
|
|
58
|
+
it('covers every alignment the schema allows', () => {
|
|
59
|
+
expect(resolveTextAlign('left')).toBe('text-left');
|
|
60
|
+
expect(resolveTextAlign('center')).toBe('text-center');
|
|
61
|
+
expect(resolveTextAlign('right')).toBe('text-right');
|
|
62
|
+
expect(resolveTextAlign('justify')).toBe('text-justify');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores unknown values', () => {
|
|
66
|
+
expect(resolveTextAlign(undefined)).toBeUndefined();
|
|
67
|
+
expect(resolveTextAlign('sideways')).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('guards', () => {
|
|
72
|
+
it('distinguishes tokens from custom colours', () => {
|
|
73
|
+
expect(isTextColorToken('accent')).toBe(true);
|
|
74
|
+
expect(isTextColorToken('#FFF')).toBe(false);
|
|
75
|
+
expect(isCustomCssColor('#FFF')).toBe(true);
|
|
76
|
+
expect(isCustomCssColor('accent')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('token options', () => {
|
|
81
|
+
it('exposes a label and swatch for every token', () => {
|
|
82
|
+
expect(TEXT_COLOR_TOKEN_OPTIONS).toHaveLength(TEXT_COLOR_TOKENS.length);
|
|
83
|
+
for (const option of TEXT_COLOR_TOKEN_OPTIONS) {
|
|
84
|
+
expect(option.label).toBeTruthy();
|
|
85
|
+
expect(option.cssColor).toMatch(/^hsl\(var\(--[a-z-]+\)\)$/);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('HeadingBlockSchema.textColor', () => {
|
|
91
|
+
const base = { level: 2 as const, text_content: 'Hello' };
|
|
92
|
+
|
|
93
|
+
it('still accepts every previously stored token', () => {
|
|
94
|
+
for (const token of ['primary', 'secondary', 'accent', 'muted', 'destructive', 'background']) {
|
|
95
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: token }).success).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts custom colours from the picker', () => {
|
|
100
|
+
for (const color of ['#FF8800', '#f80', '#FF880080', 'rgb(1,2,3)', 'rgba(1,2,3,0.5)', 'hsl(28, 100%, 50%)']) {
|
|
101
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: color }).success).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects values that are neither a token nor a colour', () => {
|
|
106
|
+
for (const bad of ['nonsense', 'url(x)', 'red', '']) {
|
|
107
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: bad }).success).toBe(false);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('stays optional', () => {
|
|
112
|
+
expect(HeadingBlockSchema.safeParse(base).success).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text-colour model shared by the block renderers and the CMS previews.
|
|
5
|
+
*
|
|
6
|
+
* A colour is stored as EITHER a theme token keyword (`"primary"`) — which keeps
|
|
7
|
+
* following the active theme and dark mode — OR a literal CSS colour emitted by
|
|
8
|
+
* the colour picker (`"#FF8800"`, `"rgba(...)"`, `"hsl(...)"`). Tokens render as
|
|
9
|
+
* a static Tailwind class; literals render as an inline style.
|
|
10
|
+
*
|
|
11
|
+
* Keep this the single source of truth: HeadingBlockRenderer, EditableBlock and
|
|
12
|
+
* ColumnEditor previously each carried their own map and had drifted apart
|
|
13
|
+
* (`muted` rendered as the near-invisible `text-muted` on the live site while
|
|
14
|
+
* both previews used `text-muted-foreground`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const TEXT_COLOR_TOKENS = [
|
|
18
|
+
'foreground',
|
|
19
|
+
'primary',
|
|
20
|
+
'secondary',
|
|
21
|
+
'accent',
|
|
22
|
+
'muted',
|
|
23
|
+
'destructive',
|
|
24
|
+
'background',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type TextColorToken = (typeof TEXT_COLOR_TOKENS)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Token -> Tailwind class.
|
|
31
|
+
*
|
|
32
|
+
* These must be written as complete literals, never interpolated. Tailwind v4
|
|
33
|
+
* ignores the `safelist` key in the legacy JS config (libs/ui/tailwind.config.js
|
|
34
|
+
* still carries one, but v4 never reads it) — a class exists only if the scanner
|
|
35
|
+
* finds the whole string in a source file. Listing them here is what guarantees
|
|
36
|
+
* they are emitted.
|
|
37
|
+
*/
|
|
38
|
+
export const TEXT_COLOR_CLASSES: Record<TextColorToken, string> = {
|
|
39
|
+
foreground: 'text-foreground',
|
|
40
|
+
primary: 'text-primary',
|
|
41
|
+
secondary: 'text-secondary',
|
|
42
|
+
accent: 'text-accent',
|
|
43
|
+
// `--muted` is a near-white surface colour; the readable pair is its foreground.
|
|
44
|
+
muted: 'text-muted-foreground',
|
|
45
|
+
destructive: 'text-destructive',
|
|
46
|
+
background: 'text-background',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Token -> CSS colour, for painting swatches in the editor. */
|
|
50
|
+
export const TEXT_COLOR_SWATCHES: Record<TextColorToken, string> = {
|
|
51
|
+
foreground: 'hsl(var(--foreground))',
|
|
52
|
+
primary: 'hsl(var(--primary))',
|
|
53
|
+
secondary: 'hsl(var(--secondary))',
|
|
54
|
+
accent: 'hsl(var(--accent))',
|
|
55
|
+
muted: 'hsl(var(--muted-foreground))',
|
|
56
|
+
destructive: 'hsl(var(--destructive))',
|
|
57
|
+
background: 'hsl(var(--background))',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const TEXT_COLOR_LABELS: Record<TextColorToken, string> = {
|
|
61
|
+
foreground: 'Default text',
|
|
62
|
+
primary: 'Primary',
|
|
63
|
+
secondary: 'Secondary',
|
|
64
|
+
accent: 'Accent',
|
|
65
|
+
muted: 'Muted',
|
|
66
|
+
destructive: 'Destructive',
|
|
67
|
+
background: 'Background',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Token options in the shape `ColorField` expects. */
|
|
71
|
+
export const TEXT_COLOR_TOKEN_OPTIONS = TEXT_COLOR_TOKENS.map((token) => ({
|
|
72
|
+
value: token,
|
|
73
|
+
label: TEXT_COLOR_LABELS[token],
|
|
74
|
+
cssColor: TEXT_COLOR_SWATCHES[token],
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Accepts what the colour picker can emit. Deliberately permissive on the inner
|
|
79
|
+
* grammar — the picker only produces well-formed values, and being strict here
|
|
80
|
+
* would reject legitimate colours authored by hand or by Cortex AI.
|
|
81
|
+
*/
|
|
82
|
+
export const CSS_COLOR_PATTERN =
|
|
83
|
+
/^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
|
|
84
|
+
|
|
85
|
+
export function isTextColorToken(value: unknown): value is TextColorToken {
|
|
86
|
+
return typeof value === 'string' && (TEXT_COLOR_TOKENS as readonly string[]).includes(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isCustomCssColor(value: unknown): value is string {
|
|
90
|
+
return typeof value === 'string' && CSS_COLOR_PATTERN.test(value.trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ResolvedTextColor {
|
|
94
|
+
className?: string;
|
|
95
|
+
style?: CSSProperties;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turn a stored colour into render props. Unknown values resolve to `{}` so a
|
|
100
|
+
* stale or hand-edited value degrades to the inherited colour rather than
|
|
101
|
+
* emitting a bogus Tailwind class.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveTextColor(value: string | null | undefined): ResolvedTextColor {
|
|
104
|
+
if (!value) return {};
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (isTextColorToken(trimmed)) {
|
|
107
|
+
return { className: TEXT_COLOR_CLASSES[trimmed] };
|
|
108
|
+
}
|
|
109
|
+
if (isCustomCssColor(trimmed)) {
|
|
110
|
+
return { style: { color: trimmed } };
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Text alignment, also as literals. `text-justify` currently only survives
|
|
117
|
+
* because the string happens to appear in a regex in libs/editor/src/lib/kit.ts;
|
|
118
|
+
* pinning it here removes that accidental dependency.
|
|
119
|
+
*/
|
|
120
|
+
export const TEXT_ALIGN_CLASSES = {
|
|
121
|
+
left: 'text-left',
|
|
122
|
+
center: 'text-center',
|
|
123
|
+
right: 'text-right',
|
|
124
|
+
justify: 'text-justify',
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
export type TextAlign = keyof typeof TEXT_ALIGN_CLASSES;
|
|
128
|
+
|
|
129
|
+
export function resolveTextAlign(value: string | null | undefined): string | undefined {
|
|
130
|
+
if (!value) return undefined;
|
|
131
|
+
return TEXT_ALIGN_CLASSES[value as TextAlign];
|
|
132
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from '../zod-config';
|
|
|
2
2
|
import { TestimonialBlockConfig, TestimonialBlockContent } from '../../components/blocks/TestimonialBlock';
|
|
3
3
|
import { ProductGridBlockSchema, ProductGridBlockContent, FeaturedProductBlockSchema, FeaturedProductBlockContent, CartBlockSchema, CartBlockContent, CheckoutBlockSchema, CheckoutBlockContent, ProductDetailsBlockSchema, ProductDetailsBlockContent } from './ecommerce-block-schemas';
|
|
4
4
|
import { availableBlockTypes, type BlockType } from './blockTypes';
|
|
5
|
+
import { CSS_COLOR_PATTERN, TEXT_COLOR_TOKENS } from './blockColors';
|
|
5
6
|
export { availableBlockTypes, type BlockType } from './blockTypes';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -19,11 +20,26 @@ export const TextBlockSchema = z.object({
|
|
|
19
20
|
});
|
|
20
21
|
export type TextBlockContent = z.infer<typeof TextBlockSchema>;
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A text colour is either a theme token (keeps following the theme and dark
|
|
25
|
+
* mode) or a literal CSS colour from the colour picker. Existing rows only ever
|
|
26
|
+
* hold tokens, so widening the union is backward compatible.
|
|
27
|
+
* See `./blockColors` for the token -> class mapping used at render time.
|
|
28
|
+
*/
|
|
29
|
+
export const TextColorSchema = z
|
|
30
|
+
.union([
|
|
31
|
+
z.enum(TEXT_COLOR_TOKENS),
|
|
32
|
+
z.string().regex(CSS_COLOR_PATTERN, 'Must be a hex, rgb(a) or hsl(a) colour'),
|
|
33
|
+
])
|
|
34
|
+
.describe(
|
|
35
|
+
`A theme token (${TEXT_COLOR_TOKENS.join(', ')}) or a custom CSS colour such as "#FF8800", "rgba(255,136,0,0.8)" or "hsl(28, 100%, 50%)"`,
|
|
36
|
+
);
|
|
37
|
+
|
|
22
38
|
export const HeadingBlockSchema = z.object({
|
|
23
39
|
level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)]).describe('Heading level (1-6)'),
|
|
24
40
|
text_content: z.string().describe('The text content of the heading'),
|
|
25
41
|
textAlign: z.enum(['left', 'center', 'right', 'justify']).optional().describe('Text alignment'),
|
|
26
|
-
textColor:
|
|
42
|
+
textColor: TextColorSchema.optional().describe('Color of the heading text'),
|
|
27
43
|
});
|
|
28
44
|
export type HeadingBlockContent = z.infer<typeof HeadingBlockSchema>;
|
|
29
45
|
|