@paganaye/stylets 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.
Files changed (70) hide show
  1. package/README.md +13 -0
  2. package/dist/BaseStyles.d.ts +44 -0
  3. package/dist/BaseStyles.js +166 -0
  4. package/dist/Button.d.ts +69 -0
  5. package/dist/Button.js +56 -0
  6. package/dist/CssColor.d.ts +44 -0
  7. package/dist/CssColor.js +196 -0
  8. package/dist/CssColor.test.d.ts +1 -0
  9. package/dist/CssColor.test.js +68 -0
  10. package/dist/CssExpr.d.ts +9 -0
  11. package/dist/CssExpr.js +35 -0
  12. package/dist/CssFilter.d.ts +18 -0
  13. package/dist/CssFilter.js +40 -0
  14. package/dist/CssNum.d.ts +30 -0
  15. package/dist/CssNum.js +106 -0
  16. package/dist/CssNum.test.d.ts +1 -0
  17. package/dist/CssNum.test.js +30 -0
  18. package/dist/CssReset.d.ts +1 -0
  19. package/dist/CssReset.js +15 -0
  20. package/dist/CssShadow.d.ts +19 -0
  21. package/dist/CssShadow.js +42 -0
  22. package/dist/DefaultTheme.d.ts +14 -0
  23. package/dist/DefaultTheme.js +6 -0
  24. package/dist/EmptyTheme.d.ts +14 -0
  25. package/dist/EmptyTheme.js +2 -0
  26. package/dist/HTML.d.ts +9 -0
  27. package/dist/HTML.jsx +7 -0
  28. package/dist/ScopeStyles.d.ts +24 -0
  29. package/dist/ScopeStyles.js +67 -0
  30. package/dist/State.d.ts +36 -0
  31. package/dist/State.js +107 -0
  32. package/dist/State.test.d.ts +1 -0
  33. package/dist/State.test.js +41 -0
  34. package/dist/StyleWriter.d.ts +25 -0
  35. package/dist/StyleWriter.js +156 -0
  36. package/dist/Theme.d.ts +56 -0
  37. package/dist/Theme.js +29 -0
  38. package/dist/Tone.d.ts +1 -0
  39. package/dist/Tone.js +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/dist/index.test.d.ts +1 -0
  43. package/dist/index.test.js +9 -0
  44. package/dist/props.d.ts +7 -0
  45. package/dist/props.js +104 -0
  46. package/dist/types.d.ts +239 -0
  47. package/dist/types.js +1 -0
  48. package/package.json +36 -0
  49. package/src/BaseStyles.ts +192 -0
  50. package/src/Button.ts +68 -0
  51. package/src/CssColor.test.ts +82 -0
  52. package/src/CssColor.ts +175 -0
  53. package/src/CssExpr.ts +25 -0
  54. package/src/CssFilter.ts +44 -0
  55. package/src/CssNum.test.ts +37 -0
  56. package/src/CssNum.ts +93 -0
  57. package/src/CssReset.ts +17 -0
  58. package/src/CssShadow.ts +46 -0
  59. package/src/DefaultTheme.ts +8 -0
  60. package/src/HTML.tsx +17 -0
  61. package/src/ScopeStyles.ts +100 -0
  62. package/src/State.test.ts +47 -0
  63. package/src/State.ts +164 -0
  64. package/src/StyleWriter.ts +163 -0
  65. package/src/Theme.ts +95 -0
  66. package/src/Tone.ts +1 -0
  67. package/src/index.test.ts +10 -0
  68. package/src/index.ts +1 -0
  69. package/src/props.ts +118 -0
  70. package/src/types.ts +311 -0
package/src/State.ts ADDED
@@ -0,0 +1,164 @@
1
+ export type EqualityFunction<T> = (a: T, b: T) => boolean;
2
+
3
+ export abstract class BaseState<T = any> {
4
+ protected _listeners: Array<(data: T) => void> = [];
5
+ protected _value: T;
6
+ protected valueEquals: EqualityFunction<T>;
7
+
8
+ constructor(initialValue: T, equalityFunction?: EqualityFunction<T>) {
9
+ this._value = initialValue;
10
+ this.valueEquals = equalityFunction ?? ((a, b) => a === b);
11
+ }
12
+
13
+ get value(): T {
14
+ return this._value;
15
+ }
16
+
17
+ protected internalSetValue(newValue: T): void {
18
+ let previousValue = this._value;
19
+ if (this.valueEquals(newValue, previousValue as T)) return;
20
+ this._value = newValue;
21
+ this.emit(newValue);
22
+ }
23
+
24
+ public subscribe(listener: (data: T) => void): void {
25
+ this._listeners.push(listener);
26
+ let currentValue = this._value;
27
+ listener(currentValue);
28
+ }
29
+
30
+ public unsubscribe(listener: (data: T) => void): void {
31
+ this._listeners = this._listeners.filter(l => l !== listener);
32
+ }
33
+
34
+ protected emit(data: T): void {
35
+ for (const listener of this._listeners) {
36
+ listener(data);
37
+ }
38
+ }
39
+
40
+ public dispose(): void {
41
+ this._listeners = [];
42
+ }
43
+ }
44
+
45
+
46
+ export class State<T = any> extends BaseState<T> {
47
+ constructor(initialValue: T, equals?: EqualityFunction<T>) {
48
+ super(initialValue, equals);
49
+ }
50
+
51
+ public setValue(newValue: T): void {
52
+ super.internalSetValue(newValue);
53
+ }
54
+ }
55
+
56
+ export function createState<T>(value: T, equals?: EqualityFunction<T>): State<T>;
57
+ export function createState<T>(value?: T, equals?: EqualityFunction<T | undefined>): State<T | undefined>;
58
+ export function createState<T>(value?: T, equals?: EqualityFunction<any>): State<any> {
59
+ return new State<any>(value, equals);
60
+ }
61
+
62
+ export class Effect {
63
+ readonly dependencies: Array<BaseState>;
64
+ readonly recomputeBound = this.recomputeValue.bind(this);
65
+
66
+ constructor(
67
+ watch: BaseState | Array<BaseState>,
68
+ private compute: () => void
69
+ ) {
70
+ this.dependencies = Array.isArray(watch) ? watch : [watch];
71
+
72
+ for (let state of this.dependencies) {
73
+ state.subscribe(this.recomputeBound);
74
+ }
75
+ }
76
+
77
+ protected async recomputeValue() {
78
+ try {
79
+ this.compute();
80
+ } catch (error) {
81
+ console.error("Error during effect computation:", error);
82
+ }
83
+ }
84
+
85
+ public dispose(): void {
86
+ for (let state of this.dependencies) {
87
+ state.unsubscribe(this.recomputeBound);
88
+ }
89
+ }
90
+ }
91
+
92
+ export function effect<T>(
93
+ watch: BaseState | Array<BaseState>,
94
+ compute: () => T | undefined,
95
+ ) {
96
+ return new Effect(watch, compute);
97
+ }
98
+
99
+ export class Computed<T = any> extends BaseState<T> {
100
+ private readonly effect: Effect;
101
+
102
+ constructor(
103
+ watch: BaseState | Array<BaseState>,
104
+ private compute: () => T,
105
+ initialValue: T,
106
+ isValidValue: boolean,
107
+ equals?: EqualityFunction<T>
108
+ ) {
109
+ super(initialValue, equals);
110
+
111
+ let recompute = () => {
112
+ const newValue = this.compute();
113
+ super.internalSetValue(newValue as unknown as T);
114
+ }
115
+ this.effect = new Effect(watch, recompute);
116
+ if (!isValidValue) recompute();
117
+ }
118
+
119
+ public dispose(): void {
120
+ this.effect.dispose();
121
+ }
122
+ }
123
+
124
+ export function computed<T>(
125
+ watch: BaseState | Array<BaseState>,
126
+ compute: () => T,
127
+ initialValue: T,
128
+ equals?: EqualityFunction<T>
129
+ ): Computed<T>;
130
+
131
+ export function computed<T>(
132
+ watch: BaseState | Array<BaseState>,
133
+ compute: () => T | undefined,
134
+ equals?: EqualityFunction<T | undefined>
135
+ ): Computed<T | undefined>;
136
+
137
+ export function computed<T>(
138
+ watch: BaseState | Array<BaseState>,
139
+ compute: () => T | undefined,
140
+ initialOrEquals?: T | EqualityFunction<T | undefined>,
141
+ maybeEquals?: EqualityFunction<T>
142
+ ) {
143
+ if (typeof initialOrEquals === 'function' || initialOrEquals === undefined) {
144
+ const equals = initialOrEquals as EqualityFunction<T | undefined> | undefined;
145
+ return new Computed<T | undefined>(
146
+ watch,
147
+ compute as () => T | undefined,
148
+ /*initialValue*/ undefined,
149
+ /*isValidValue*/ false,
150
+ equals
151
+ );
152
+ } else {
153
+ const initialValue = initialOrEquals as T;
154
+ const equals = maybeEquals as EqualityFunction<T> | undefined;
155
+ return new Computed<T>(
156
+ watch,
157
+ compute as () => T,
158
+ /*initialValue*/ initialValue,
159
+ /*isValidValue*/ true,
160
+ equals
161
+ );
162
+ }
163
+ }
164
+
@@ -0,0 +1,163 @@
1
+ import { CssExpr } from "./CssExpr";
2
+ import { CssProperties, ITheme } from "./types";
3
+
4
+ export function toKebabCase(str: string): string {
5
+ if (str.startsWith('--')) return str;
6
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
7
+ }
8
+
9
+ export class StyleWriter {
10
+ readonly element: HTMLStyleElement;
11
+ readonly lines: string[] = [];
12
+ static readonly builders = new Map<string, StyleWriter>;
13
+ private indentString: string = '';
14
+ private _indentLevel: number = 0;
15
+
16
+ public get indentLevel() {
17
+ return this._indentLevel;
18
+ }
19
+
20
+ public set indentLevel(value: number) {
21
+ this._indentLevel = value;
22
+ this.indentString = ' '.repeat(this._indentLevel);
23
+ }
24
+
25
+ private constructor(public readonly name: string) {
26
+ this.element = document.createElement('style');
27
+ this.element.setAttribute("data-section", name);
28
+ document.head.appendChild(this.element);
29
+ }
30
+
31
+ public static byName(name: string): StyleWriter {
32
+ let existing = StyleWriter.builders.get(name);
33
+ if (existing) return existing;
34
+ let newBuilder = new StyleWriter(name);
35
+ StyleWriter.builders.set(name, newBuilder);
36
+ return newBuilder;
37
+ }
38
+
39
+ render() {
40
+ this.element.innerHTML = this.lines.join("\n");
41
+ this.clear();
42
+ }
43
+
44
+ clear() {
45
+ this.lines.length = 0;
46
+ }
47
+
48
+ addLines(lines: string[]) {
49
+ lines.forEach(s => this.addLine(s))
50
+ }
51
+
52
+ addLine(line: string | undefined) {
53
+ if (line === undefined) return;
54
+ this.lines.push(this.indentString + line);
55
+ }
56
+
57
+ addRule(selector: string, styles: CssProperties | string) {
58
+ this.addLine(`${selector} {`)
59
+ this.indentLevel += 1;
60
+ if (typeof styles === 'string') {
61
+ this.addLine(styles)
62
+ } else if (Array.isArray(styles)) {
63
+ this.addLines(styles);
64
+ } else {
65
+ this.addCssProperties(styles);
66
+ }
67
+ this.indentLevel -= 1;
68
+ this.addLine(`}`);
69
+ }
70
+
71
+ protected addCssProperties(properties: CssProperties) {
72
+ if (properties == null) return;
73
+ let entries = Object.entries(properties);
74
+ if (entries.length === 0) return;
75
+
76
+ let stringifyValue = (key: string, v: any): string | undefined => {
77
+ if (v === null || v === undefined) return undefined;
78
+ if (typeof v === 'string' || typeof v === 'number') {
79
+ return StyleWriter.stringifyStyle(key, v as any);
80
+ } else if (v instanceof CssExpr) {
81
+ let s = v.toString();
82
+ return s;
83
+ } else if (Array.isArray(v)) {
84
+ let s = v.map(i => stringifyValue(key, i)).join(', ');
85
+ return s;
86
+ } else {
87
+ throw Error("Unexpected type in style value");
88
+ }
89
+ }
90
+ for (let [k, v] of entries) {
91
+ if (!k.startsWith('--')) {
92
+ k = toKebabCase(k);
93
+ }
94
+ v = stringifyValue(k, v);
95
+ if (v === undefined || v === null) continue;
96
+ this.addLine(`${k}: ${v};`);
97
+ }
98
+
99
+ }
100
+
101
+ static stringifyStyle(key: string, value: CssProperties, theme?: ITheme): string | undefined {
102
+ if (value === null || value === undefined) return undefined;
103
+ if (key === 'content' && typeof value === 'string') {
104
+ return `content: ${value};`;
105
+ }
106
+ let result: string;
107
+ switch (typeof value) {
108
+ case "number":
109
+ result = StyleWriter.convertNumberToCssValue(key, value as number);
110
+ break;
111
+ case "function":
112
+ result = (value as any)(theme);
113
+ break;
114
+ default:
115
+ result = String(value);
116
+ break;
117
+ }
118
+ return result;
119
+ }
120
+
121
+ static spacingProps = new Set<string>([
122
+ 'width', 'height', 'max-width', 'min-width', 'max-height', 'min-height',
123
+ 'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
124
+ 'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
125
+ 'gap', 'row-gap', 'column-gap',
126
+ 'top', 'right', 'bottom', 'left',
127
+ 'inset', 'inset-top', 'inset-right', 'inset-bottom', 'inset-left',
128
+ 'border', 'border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'
129
+ ]);
130
+
131
+ // Properties that are unitless in CSS
132
+ static unitlessProps = new Set<string>([
133
+ 'line-height', 'z-index', 'opacity', 'font-weight', 'order', 'flex', 'flex-grow', 'flex-shrink', 'flex-basis'
134
+ ]);
135
+
136
+ static convertNumberToCssValue(prop: string, value: number): string {
137
+ // normalize prop name to kebab-case as used in spacingPropsSet
138
+ const p = prop.toLowerCase();
139
+ if (StyleWriter.unitlessProps.has(p)) return String(value);
140
+
141
+ if (StyleWriter.spacingProps && StyleWriter.spacingProps.has(p)) {
142
+ // default conversion: treat number as rem with one decimal (value * 0.25 -> 0.25 increments?)
143
+ // original behaviour used Math.round(value * 2.5) / 10 to produce tenths
144
+ return `${Math.round(value * 2.5) / 10}rem`;
145
+ }
146
+
147
+ // fallback: return numeric as-is
148
+ return String(value);
149
+ }
150
+ }
151
+
152
+ export function addStyles(section: string, callback: (builder: StyleWriter) => void, thisArg?: any) {
153
+
154
+ let builder = StyleWriter.byName(section);
155
+ try {
156
+ callback.call(thisArg, builder);
157
+ }
158
+ finally {
159
+ if (typeof document === 'undefined') return;
160
+ builder.render()
161
+ }
162
+ }
163
+
package/src/Theme.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { ITheme, IThemeParts, IScopeStylesParts, MergeThemeArgs, ThemeArgs, ExtractVariants } from "./types";
2
+ import { ScopeStylesBuilder, ScopeStylesPublic } from "./ScopeStyles";
3
+ import { BaseStyles } from "./BaseStyles";
4
+ export type ThemeCSSClass = keyof ITheme['classes'];
5
+ export type ThemeClassList = Partial<Record<ThemeCSSClass, boolean | undefined>>;
6
+
7
+ export function createTheme<
8
+ Colors extends string = never,
9
+ Sizes extends string = never,
10
+ Paddings extends string = never,
11
+ Margins extends string = never,
12
+ Borders extends string = never,
13
+ Layouts extends string = never,
14
+ Backgrounds extends string = never,
15
+ Typography extends string = never,
16
+ Shadows extends string = never,
17
+ Vars extends string = never,
18
+ Classes extends string = never,
19
+ Variants extends Record<string, Record<string, any>> = Record<string, never>
20
+ >(themeParts: IThemeParts<{
21
+ colors: Colors;
22
+ sizes: Sizes;
23
+ paddings: Paddings;
24
+ margins: Margins;
25
+ borders: Borders;
26
+ backgrounds: Backgrounds;
27
+ layouts: Layouts;
28
+ typography: Typography;
29
+ shadows: Shadows;
30
+ vars: Vars;
31
+ classes: Classes;
32
+ variants: Variants
33
+ }>) {
34
+ type A = {
35
+ colors: Colors;
36
+ sizes: Sizes; paddings:
37
+ Paddings; margins:
38
+ Margins; borders: Borders;
39
+ backgrounds: Backgrounds;
40
+ layouts: Layouts;
41
+ typography: Typography;
42
+ shadows: Shadows;
43
+ vars: Vars;
44
+ classes: Classes;
45
+ variants: Variants
46
+ };
47
+ let theme = new ThemeBuilder<A>(themeParts as any);
48
+ return theme as ThemeBuilder<A>;
49
+ }
50
+
51
+
52
+ export class ThemeBuilder<A extends ThemeArgs> extends BaseStyles<A> {
53
+
54
+ constructor(themeParts: IThemeParts<A>) {
55
+ super({ ...themeParts } as any);
56
+ }
57
+
58
+ declareScope<P extends IScopeStylesParts<any> & { class: string, scopeStart?: string, scopeEnd?: string }>(parts: P): ScopeStylesBuilder<MergeThemeArgs<A, P>, MergeThemeArgs<A, P>['variants']>;
59
+ // overload that infers Added from a callback returning parts
60
+ declareScope<P extends (theme: ThemeBuilder<A>) => IScopeStylesParts<any> & { scopeStart: string, scopeEnd?: string }>(parts: P): ScopeStylesBuilder<MergeThemeArgs<A, ReturnType<P>>, MergeThemeArgs<A, ReturnType<P>>['variants']>;
61
+ declareScope(partsOrLambda: any) {
62
+ const resolved = (typeof partsOrLambda === 'function') ? partsOrLambda(this as any) : partsOrLambda;
63
+ return new ScopeStylesBuilder(
64
+ this as any,
65
+ { ...resolved } as any
66
+ );
67
+ }
68
+ // overload that infers Added from a concrete parts object
69
+ add<P extends IThemeParts<any>>(parts: P): ThemeBuilder<MergeThemeArgs<A, P>>;
70
+ // overload that infers Added from a callback returning parts
71
+ add<P extends (theme: ThemeBuilder<A>) => IThemeParts<any>>(parts: P): ThemeBuilder<MergeThemeArgs<A, ReturnType<P>>>;
72
+ add(parts: any): any {
73
+
74
+ if (typeof parts === 'function') parts = parts(this as any);
75
+ super.add(parts as any);
76
+ return this as any;
77
+ }
78
+
79
+ public render() {
80
+ super.render();
81
+ let result = (this as unknown) as ThemePublic<A>;
82
+ return result;
83
+ }
84
+ }
85
+
86
+ export type ThemePublic<A extends ThemeArgs> = {
87
+ declareScope: ThemeBuilder<A>['declareScope'];
88
+ colors: A['colors'] extends string ? Record<A['colors'], string> : Record<string, string>;
89
+ vars: A['vars'] extends string ? Record<A['vars'], string> : Record<string, string>;
90
+ classes: A['classes'] extends string ? Record<A['classes'], string> : Record<string, string>;
91
+ }
92
+
93
+ export class Theme<A extends ThemeArgs> extends BaseStyles<A> {
94
+ declareScope!: ThemeBuilder<A>['declareScope'];
95
+ }
package/src/Tone.ts ADDED
@@ -0,0 +1 @@
1
+ export type Tone = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTheme } from './Theme';
3
+
4
+ describe('createTheme', () => {
5
+ it('returns a theme object', () => {
6
+ const theme = createTheme({}).render();
7
+ expect(theme).toBeDefined();
8
+ expect(theme.classes).toStrictEqual({});
9
+ });
10
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./Theme";
package/src/props.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { ClassList, CssProperties } from "./types";
2
+ import { IDivProps } from "./HTML";
3
+
4
+ function classListToRecord<T extends string>(classList?: ClassList<T>): Record<string, boolean> {
5
+ const result: Record<string, boolean> = {};
6
+ if (!classList) return result;
7
+
8
+ if (Array.isArray(classList)) {
9
+ for (const item of classList) {
10
+ if (!item) continue;
11
+ if (typeof item === 'string') {
12
+ result[item] = true;
13
+ } else if (typeof item === 'object') {
14
+ Object.assign(result, item);
15
+ }
16
+ }
17
+ } else if (typeof classList === 'string') {
18
+ result[classList] = true;
19
+ } else {
20
+ Object.assign(result, classList);
21
+ }
22
+ return result;
23
+ }
24
+
25
+ // Normalizes classes and variants. User code can intercept between prepareProps/completeProps to add classes.
26
+ export function prepareProps<T extends IDivProps = IDivProps>(
27
+ propsIn: T,
28
+ styles?: { variants?: Record<string, any>, class?: string }): Record<string, any> {
29
+
30
+ const classRecord = classListToRecord(propsIn.class);
31
+ const dataAttrs: Record<string, string> = {};
32
+
33
+ if (styles?.class) {
34
+ classRecord[styles.class] = true;
35
+ }
36
+
37
+ // Apply variants as attributes, use 'default' value if not provided
38
+ if (styles?.variants) {
39
+ for (const variantKey of Object.keys(styles.variants)) {
40
+ let variantValue = (propsIn as any)[variantKey];
41
+
42
+ if (variantValue === undefined) {
43
+ const variantGroup = styles.variants[variantKey];
44
+ if (variantGroup && 'default' in variantGroup) {
45
+ variantValue = 'default';
46
+ }
47
+ }
48
+
49
+ if (variantValue !== undefined) {
50
+ dataAttrs[variantKey] = variantValue;
51
+ }
52
+ }
53
+ }
54
+
55
+ return {
56
+ __prepared: true,
57
+ style: styleToString(propsIn.style),
58
+ class: classRecord,
59
+ ...dataAttrs,
60
+ };
61
+ }
62
+
63
+ // Converts classRecord to space-separated string
64
+ export function completeProps(propsIn: Record<string, any>): Record<string, any> {
65
+ if (!('__prepared' in propsIn)) {
66
+ propsIn = prepareProps(propsIn as any);
67
+ }
68
+
69
+ delete propsIn.__prepared;
70
+
71
+ if (typeof propsIn.class === 'object') {
72
+ propsIn.class = Object.entries(propsIn.class)
73
+ .filter(([_, value]) => value)
74
+ .map(([key]) => key)
75
+ .join(' ');
76
+ }
77
+ return propsIn;
78
+ }
79
+
80
+ function styleToString(styles?: CssProperties): string | undefined {
81
+ if (!styles) return undefined;
82
+
83
+ let result = '';
84
+ for (const [key, value] of Object.entries(styles)) {
85
+ if (value !== undefined) {
86
+ const cssKey = key.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
87
+ result += `${cssKey}: ${value}; `;
88
+ }
89
+ }
90
+ return result || undefined;
91
+ }
92
+
93
+ export function Elt(tag: string, props: Record<string, any>, children?: any[]): string {
94
+ const finalProps = completeProps(props) as Record<string, any>;
95
+
96
+ const escapeAttr = (s: string): string =>
97
+ s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
98
+
99
+ const attrs = Object.entries(finalProps)
100
+ .map(([k, v]) => {
101
+ if (v === undefined || v === null) return '';
102
+ if (k === 'children') return '';
103
+ if (typeof v === 'boolean') return v ? `${k}` : '';
104
+ return `${k}="${escapeAttr(String(v))}"`;
105
+ })
106
+ .filter(Boolean)
107
+ .join(' ');
108
+
109
+ const renderChildren = (c: any): string => {
110
+ if (c === undefined || c === null) return '';
111
+ if (Array.isArray(c)) return c.map(renderChildren).join('');
112
+ return String(c);
113
+ };
114
+
115
+ const childrenStr = renderChildren(children);
116
+
117
+ return `<${tag}${attrs ? ' ' + attrs : ''}>${childrenStr}</${tag}>`;
118
+ }