@lessonkit/themes 1.2.0 → 1.3.1

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/index.cjs CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  lightTheme: () => lightTheme,
37
37
  mergeThemes: () => mergeThemes,
38
38
  radiusVarName: () => radiusVarName,
39
+ sanitizeCssCustomPropertyValue: () => sanitizeCssCustomPropertyValue,
39
40
  shadowVarName: () => shadowVarName,
40
41
  spacingVarName: () => spacingVarName,
41
42
  themeToCssDeclarationBlock: () => themeToCssDeclarationBlock,
@@ -68,9 +69,20 @@ var TYPOGRAPHY_KEYS = [
68
69
  ];
69
70
  var RADIUS_KEYS = ["sm", "md", "lg"];
70
71
  var SHADOW_KEYS = ["sm", "md", "lg"];
72
+ var EXTRA_COLOR_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
71
73
  function isNonEmptyString(v) {
72
74
  return typeof v === "string" && v.trim().length > 0;
73
75
  }
76
+ function isSafeCssCustomPropertyValue(value) {
77
+ return !/[;}\r\n\\<>]/.test(value) && !value.includes("/*");
78
+ }
79
+ function validateCssTokenValue(path, value, issues) {
80
+ if (!isSafeCssCustomPropertyValue(value)) {
81
+ issues.push({ path, message: "must not contain CSS-breaking characters (;, }, newlines, or comments)" });
82
+ return false;
83
+ }
84
+ return true;
85
+ }
74
86
  function validateRequiredGroup(group, value, keys, issues) {
75
87
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
76
88
  issues.push({ path: group, message: "must be an object" });
@@ -82,7 +94,7 @@ function validateRequiredGroup(group, value, keys, issues) {
82
94
  const v = obj[key];
83
95
  if (!isNonEmptyString(v)) {
84
96
  issues.push({ path: `${group}.${key}`, message: "required non-empty string" });
85
- } else {
97
+ } else if (validateCssTokenValue(`${group}.${key}`, v, issues)) {
86
98
  out[key] = v;
87
99
  }
88
100
  }
@@ -96,9 +108,16 @@ function validateColorsExtra(value, issues) {
96
108
  }
97
109
  const extra = {};
98
110
  for (const [k, v] of Object.entries(value)) {
111
+ if (!EXTRA_COLOR_KEY_PATTERN.test(k)) {
112
+ issues.push({
113
+ path: `colors.extra.${k}`,
114
+ message: "key must match [a-zA-Z][a-zA-Z0-9_-]*"
115
+ });
116
+ continue;
117
+ }
99
118
  if (!isNonEmptyString(v)) {
100
119
  issues.push({ path: `colors.extra.${k}`, message: "must be a non-empty string" });
101
- } else {
120
+ } else if (validateCssTokenValue(`colors.extra.${k}`, v, issues)) {
102
121
  extra[k] = v;
103
122
  }
104
123
  }
@@ -204,6 +223,21 @@ function mergeThemes(base, ...overrides) {
204
223
  }
205
224
 
206
225
  // src/cssVariables.ts
226
+ function sanitizeCssCustomPropertyValue(value) {
227
+ if (/[;}\r\n\\<>]/.test(value) || value.includes("/*")) return null;
228
+ return value;
229
+ }
230
+ function isSafeCssSelector(selector) {
231
+ if (selector === ":root") return true;
232
+ if (/^\.[a-zA-Z_][\w-]*$/.test(selector)) return true;
233
+ if (/^#[a-zA-Z_][\w-]*$/.test(selector)) return true;
234
+ if (/^\[data-lk-theme=(["'])[^"']+\1\]$/.test(selector)) return true;
235
+ return false;
236
+ }
237
+ function assignCssVar(vars, key, value) {
238
+ const safe = sanitizeCssCustomPropertyValue(value);
239
+ if (safe !== null) vars[key] = safe;
240
+ }
207
241
  function tokenKeyToKebab(key) {
208
242
  return key.replace(/([A-Z])/g, "-$1").toLowerCase();
209
243
  }
@@ -230,23 +264,23 @@ function themeToCssVariables(theme) {
230
264
  for (const [key, value] of Object.entries(theme.colors)) {
231
265
  if (key === "extra" && value && typeof value === "object") {
232
266
  for (const [ek, ev] of Object.entries(value)) {
233
- vars[colorExtraVarName(ek)] = ev;
267
+ assignCssVar(vars, colorExtraVarName(ek), ev);
234
268
  }
235
269
  } else if (key !== "extra") {
236
- vars[colorVarName(key)] = value;
270
+ assignCssVar(vars, colorVarName(key), value);
237
271
  }
238
272
  }
239
273
  for (const [key, value] of Object.entries(theme.spacing)) {
240
- vars[spacingVarName(key)] = value;
274
+ assignCssVar(vars, spacingVarName(key), value);
241
275
  }
242
276
  for (const [key, value] of Object.entries(theme.typography)) {
243
- vars[typographyVarName(key)] = value;
277
+ assignCssVar(vars, typographyVarName(key), value);
244
278
  }
245
279
  for (const [key, value] of Object.entries(theme.radius)) {
246
- vars[radiusVarName(key)] = value;
280
+ assignCssVar(vars, radiusVarName(key), value);
247
281
  }
248
282
  for (const [key, value] of Object.entries(theme.shadows)) {
249
- vars[shadowVarName(key)] = value;
283
+ assignCssVar(vars, shadowVarName(key), value);
250
284
  }
251
285
  const sorted = {};
252
286
  for (const key of Object.keys(vars).sort()) {
@@ -256,6 +290,9 @@ function themeToCssVariables(theme) {
256
290
  }
257
291
  function themeToCssDeclarationBlock(theme, opts) {
258
292
  const selector = opts?.selector ?? ":root";
293
+ if (!isSafeCssSelector(selector)) {
294
+ throw new Error(`[lessonkit] unsafe CSS selector for theme block: ${selector}`);
295
+ }
259
296
  const vars = themeToCssVariables(theme);
260
297
  const body = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
261
298
  return `${selector} {
@@ -373,8 +410,11 @@ var PRESETS = {
373
410
  dark: darkTheme,
374
411
  brand: brandTheme
375
412
  };
413
+ function cloneTheme(theme) {
414
+ return JSON.parse(JSON.stringify(theme));
415
+ }
376
416
  function getPresetTheme(preset) {
377
- return PRESETS[preset];
417
+ return cloneTheme(PRESETS[preset]);
378
418
  }
379
419
 
380
420
  // src/catalog.ts
@@ -492,6 +532,7 @@ function buildThemeCatalog() {
492
532
  lightTheme,
493
533
  mergeThemes,
494
534
  radiusVarName,
535
+ sanitizeCssCustomPropertyValue,
495
536
  shadowVarName,
496
537
  spacingVarName,
497
538
  themeToCssDeclarationBlock,
package/dist/index.d.cts CHANGED
@@ -53,6 +53,8 @@ declare const REQUIRED_SHADOW_KEYS: ThemeShadowKey[];
53
53
  */
54
54
  declare function mergeThemes(base: LessonkitThemeV1, ...overrides: (PartialLessonkitThemeV1 | undefined)[]): LessonkitThemeV1;
55
55
 
56
+ /** Reject values that could break out of a CSS declaration block. */
57
+ declare function sanitizeCssCustomPropertyValue(value: string): string | null;
56
58
  /** Map a token path segment to kebab-case for CSS custom property names. */
57
59
  declare function tokenKeyToKebab(key: string): string;
58
60
  /** CSS custom property name for a color token (required keys). */
@@ -93,4 +95,4 @@ type ThemeCatalogEntry = {
93
95
  /** Enumerable catalog of themeable tokens (v1). */
94
96
  declare function buildThemeCatalog(): ThemeCatalogEntry[];
95
97
 
96
- export { type LessonkitTheme, type LessonkitThemeColors, type LessonkitThemeRadius, type LessonkitThemeShadows, type LessonkitThemeSpacing, type LessonkitThemeTypography, type LessonkitThemeV1, type PartialLessonkitThemeV1, REQUIRED_COLOR_KEYS, REQUIRED_RADIUS_KEYS, REQUIRED_SHADOW_KEYS, REQUIRED_SPACING_KEYS, REQUIRED_TYPOGRAPHY_KEYS, type ThemeCatalogEntry, type ThemeColorKey, type ThemePresetName, type ThemeRadiusKey, type ThemeShadowKey, type ThemeSpacingKey, type ThemeTypographyKey, type ThemeValidationIssue, type ThemeValidationResult, brandTheme, brandThemeOverrides, buildThemeCatalog, colorExtraVarName, colorVarName, darkTheme, defaultTheme, getPresetTheme, lightTheme, mergeThemes, radiusVarName, shadowVarName, spacingVarName, themeToCssDeclarationBlock, themeToCssVariables, tokenKeyToKebab, typographyVarName, validateTheme };
98
+ export { type LessonkitTheme, type LessonkitThemeColors, type LessonkitThemeRadius, type LessonkitThemeShadows, type LessonkitThemeSpacing, type LessonkitThemeTypography, type LessonkitThemeV1, type PartialLessonkitThemeV1, REQUIRED_COLOR_KEYS, REQUIRED_RADIUS_KEYS, REQUIRED_SHADOW_KEYS, REQUIRED_SPACING_KEYS, REQUIRED_TYPOGRAPHY_KEYS, type ThemeCatalogEntry, type ThemeColorKey, type ThemePresetName, type ThemeRadiusKey, type ThemeShadowKey, type ThemeSpacingKey, type ThemeTypographyKey, type ThemeValidationIssue, type ThemeValidationResult, brandTheme, brandThemeOverrides, buildThemeCatalog, colorExtraVarName, colorVarName, darkTheme, defaultTheme, getPresetTheme, lightTheme, mergeThemes, radiusVarName, sanitizeCssCustomPropertyValue, shadowVarName, spacingVarName, themeToCssDeclarationBlock, themeToCssVariables, tokenKeyToKebab, typographyVarName, validateTheme };
package/dist/index.d.ts CHANGED
@@ -53,6 +53,8 @@ declare const REQUIRED_SHADOW_KEYS: ThemeShadowKey[];
53
53
  */
54
54
  declare function mergeThemes(base: LessonkitThemeV1, ...overrides: (PartialLessonkitThemeV1 | undefined)[]): LessonkitThemeV1;
55
55
 
56
+ /** Reject values that could break out of a CSS declaration block. */
57
+ declare function sanitizeCssCustomPropertyValue(value: string): string | null;
56
58
  /** Map a token path segment to kebab-case for CSS custom property names. */
57
59
  declare function tokenKeyToKebab(key: string): string;
58
60
  /** CSS custom property name for a color token (required keys). */
@@ -93,4 +95,4 @@ type ThemeCatalogEntry = {
93
95
  /** Enumerable catalog of themeable tokens (v1). */
94
96
  declare function buildThemeCatalog(): ThemeCatalogEntry[];
95
97
 
96
- export { type LessonkitTheme, type LessonkitThemeColors, type LessonkitThemeRadius, type LessonkitThemeShadows, type LessonkitThemeSpacing, type LessonkitThemeTypography, type LessonkitThemeV1, type PartialLessonkitThemeV1, REQUIRED_COLOR_KEYS, REQUIRED_RADIUS_KEYS, REQUIRED_SHADOW_KEYS, REQUIRED_SPACING_KEYS, REQUIRED_TYPOGRAPHY_KEYS, type ThemeCatalogEntry, type ThemeColorKey, type ThemePresetName, type ThemeRadiusKey, type ThemeShadowKey, type ThemeSpacingKey, type ThemeTypographyKey, type ThemeValidationIssue, type ThemeValidationResult, brandTheme, brandThemeOverrides, buildThemeCatalog, colorExtraVarName, colorVarName, darkTheme, defaultTheme, getPresetTheme, lightTheme, mergeThemes, radiusVarName, shadowVarName, spacingVarName, themeToCssDeclarationBlock, themeToCssVariables, tokenKeyToKebab, typographyVarName, validateTheme };
98
+ export { type LessonkitTheme, type LessonkitThemeColors, type LessonkitThemeRadius, type LessonkitThemeShadows, type LessonkitThemeSpacing, type LessonkitThemeTypography, type LessonkitThemeV1, type PartialLessonkitThemeV1, REQUIRED_COLOR_KEYS, REQUIRED_RADIUS_KEYS, REQUIRED_SHADOW_KEYS, REQUIRED_SPACING_KEYS, REQUIRED_TYPOGRAPHY_KEYS, type ThemeCatalogEntry, type ThemeColorKey, type ThemePresetName, type ThemeRadiusKey, type ThemeShadowKey, type ThemeSpacingKey, type ThemeTypographyKey, type ThemeValidationIssue, type ThemeValidationResult, brandTheme, brandThemeOverrides, buildThemeCatalog, colorExtraVarName, colorVarName, darkTheme, defaultTheme, getPresetTheme, lightTheme, mergeThemes, radiusVarName, sanitizeCssCustomPropertyValue, shadowVarName, spacingVarName, themeToCssDeclarationBlock, themeToCssVariables, tokenKeyToKebab, typographyVarName, validateTheme };
package/dist/index.js CHANGED
@@ -20,9 +20,20 @@ var TYPOGRAPHY_KEYS = [
20
20
  ];
21
21
  var RADIUS_KEYS = ["sm", "md", "lg"];
22
22
  var SHADOW_KEYS = ["sm", "md", "lg"];
23
+ var EXTRA_COLOR_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
23
24
  function isNonEmptyString(v) {
24
25
  return typeof v === "string" && v.trim().length > 0;
25
26
  }
27
+ function isSafeCssCustomPropertyValue(value) {
28
+ return !/[;}\r\n\\<>]/.test(value) && !value.includes("/*");
29
+ }
30
+ function validateCssTokenValue(path, value, issues) {
31
+ if (!isSafeCssCustomPropertyValue(value)) {
32
+ issues.push({ path, message: "must not contain CSS-breaking characters (;, }, newlines, or comments)" });
33
+ return false;
34
+ }
35
+ return true;
36
+ }
26
37
  function validateRequiredGroup(group, value, keys, issues) {
27
38
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
28
39
  issues.push({ path: group, message: "must be an object" });
@@ -34,7 +45,7 @@ function validateRequiredGroup(group, value, keys, issues) {
34
45
  const v = obj[key];
35
46
  if (!isNonEmptyString(v)) {
36
47
  issues.push({ path: `${group}.${key}`, message: "required non-empty string" });
37
- } else {
48
+ } else if (validateCssTokenValue(`${group}.${key}`, v, issues)) {
38
49
  out[key] = v;
39
50
  }
40
51
  }
@@ -48,9 +59,16 @@ function validateColorsExtra(value, issues) {
48
59
  }
49
60
  const extra = {};
50
61
  for (const [k, v] of Object.entries(value)) {
62
+ if (!EXTRA_COLOR_KEY_PATTERN.test(k)) {
63
+ issues.push({
64
+ path: `colors.extra.${k}`,
65
+ message: "key must match [a-zA-Z][a-zA-Z0-9_-]*"
66
+ });
67
+ continue;
68
+ }
51
69
  if (!isNonEmptyString(v)) {
52
70
  issues.push({ path: `colors.extra.${k}`, message: "must be a non-empty string" });
53
- } else {
71
+ } else if (validateCssTokenValue(`colors.extra.${k}`, v, issues)) {
54
72
  extra[k] = v;
55
73
  }
56
74
  }
@@ -156,6 +174,21 @@ function mergeThemes(base, ...overrides) {
156
174
  }
157
175
 
158
176
  // src/cssVariables.ts
177
+ function sanitizeCssCustomPropertyValue(value) {
178
+ if (/[;}\r\n\\<>]/.test(value) || value.includes("/*")) return null;
179
+ return value;
180
+ }
181
+ function isSafeCssSelector(selector) {
182
+ if (selector === ":root") return true;
183
+ if (/^\.[a-zA-Z_][\w-]*$/.test(selector)) return true;
184
+ if (/^#[a-zA-Z_][\w-]*$/.test(selector)) return true;
185
+ if (/^\[data-lk-theme=(["'])[^"']+\1\]$/.test(selector)) return true;
186
+ return false;
187
+ }
188
+ function assignCssVar(vars, key, value) {
189
+ const safe = sanitizeCssCustomPropertyValue(value);
190
+ if (safe !== null) vars[key] = safe;
191
+ }
159
192
  function tokenKeyToKebab(key) {
160
193
  return key.replace(/([A-Z])/g, "-$1").toLowerCase();
161
194
  }
@@ -182,23 +215,23 @@ function themeToCssVariables(theme) {
182
215
  for (const [key, value] of Object.entries(theme.colors)) {
183
216
  if (key === "extra" && value && typeof value === "object") {
184
217
  for (const [ek, ev] of Object.entries(value)) {
185
- vars[colorExtraVarName(ek)] = ev;
218
+ assignCssVar(vars, colorExtraVarName(ek), ev);
186
219
  }
187
220
  } else if (key !== "extra") {
188
- vars[colorVarName(key)] = value;
221
+ assignCssVar(vars, colorVarName(key), value);
189
222
  }
190
223
  }
191
224
  for (const [key, value] of Object.entries(theme.spacing)) {
192
- vars[spacingVarName(key)] = value;
225
+ assignCssVar(vars, spacingVarName(key), value);
193
226
  }
194
227
  for (const [key, value] of Object.entries(theme.typography)) {
195
- vars[typographyVarName(key)] = value;
228
+ assignCssVar(vars, typographyVarName(key), value);
196
229
  }
197
230
  for (const [key, value] of Object.entries(theme.radius)) {
198
- vars[radiusVarName(key)] = value;
231
+ assignCssVar(vars, radiusVarName(key), value);
199
232
  }
200
233
  for (const [key, value] of Object.entries(theme.shadows)) {
201
- vars[shadowVarName(key)] = value;
234
+ assignCssVar(vars, shadowVarName(key), value);
202
235
  }
203
236
  const sorted = {};
204
237
  for (const key of Object.keys(vars).sort()) {
@@ -208,6 +241,9 @@ function themeToCssVariables(theme) {
208
241
  }
209
242
  function themeToCssDeclarationBlock(theme, opts) {
210
243
  const selector = opts?.selector ?? ":root";
244
+ if (!isSafeCssSelector(selector)) {
245
+ throw new Error(`[lessonkit] unsafe CSS selector for theme block: ${selector}`);
246
+ }
211
247
  const vars = themeToCssVariables(theme);
212
248
  const body = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
213
249
  return `${selector} {
@@ -325,8 +361,11 @@ var PRESETS = {
325
361
  dark: darkTheme,
326
362
  brand: brandTheme
327
363
  };
364
+ function cloneTheme(theme) {
365
+ return JSON.parse(JSON.stringify(theme));
366
+ }
328
367
  function getPresetTheme(preset) {
329
- return PRESETS[preset];
368
+ return cloneTheme(PRESETS[preset]);
330
369
  }
331
370
 
332
371
  // src/catalog.ts
@@ -443,6 +482,7 @@ export {
443
482
  lightTheme,
444
483
  mergeThemes,
445
484
  radiusVarName,
485
+ sanitizeCssCustomPropertyValue,
446
486
  shadowVarName,
447
487
  spacingVarName,
448
488
  themeToCssDeclarationBlock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/themes",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "description": "Theme primitives and tokens for LessonKit.",
6
6
  "license": "Apache-2.0",