smol.js 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.
package/src/ssr.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { SmolElement } from './types';
2
+ import { renderToString } from './html';
3
+
4
+ /**
5
+ * Render a component to an HTML string with Declarative Shadow DOM
6
+ * This is used for server-side rendering (SSR)
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const html = renderComponentToString(MyCounter, { initialCount: 0 });
11
+ * // Returns: <my-counter><template shadowrootmode="open">...</template></my-counter>
12
+ * ```
13
+ */
14
+ export function renderComponentToString(
15
+ ComponentClass: typeof HTMLElement,
16
+ attributes: Record<string, string> = {}
17
+ ): string {
18
+ // Create a temporary instance (without actually connecting to DOM)
19
+ const instance = new ComponentClass() as SmolElement;
20
+
21
+ // Set attributes if provided
22
+ Object.entries(attributes).forEach(([key, value]) => {
23
+ instance.setAttribute(key, value);
24
+ });
25
+
26
+ // Get the tag name from the constructor
27
+ const tagName = (ComponentClass as any)._smolTag || 'unknown-element';
28
+
29
+ // Get the template and styles
30
+ const config = (ComponentClass as any)._smolConfig;
31
+ const styles = config?.styles || '';
32
+
33
+ // Manually call the lifecycle to initialize state
34
+ if (config?.connected) {
35
+ config.connected.call(instance);
36
+ }
37
+
38
+ // Render the template
39
+ let templateHTML = '';
40
+ if (config?.template) {
41
+ const ctx = {
42
+ emit: instance.emit.bind(instance),
43
+ render: () => { },
44
+ element: instance
45
+ };
46
+ const result = config.template.call(instance, ctx);
47
+
48
+ if (typeof result === 'string') {
49
+ templateHTML = result;
50
+ } else if (result && result._isTemplateResult) {
51
+ templateHTML = renderToString(result);
52
+ }
53
+ }
54
+
55
+ // Build the Declarative Shadow DOM
56
+ if (globalThis.document && templateHTML) {
57
+ // Parse the template HTML
58
+ const template = document.createElement('template');
59
+ template.innerHTML = templateHTML;
60
+
61
+ // Recursively find and render nested components
62
+ expandNestedComponents(template.content);
63
+
64
+ templateHTML = template.innerHTML;
65
+ }
66
+
67
+ const shadowContent = `
68
+ ${styles ? `<style>${styles}</style>` : ''}
69
+ ${templateHTML}
70
+ `.trim();
71
+
72
+ // Build attribute string
73
+ const attrString = Object.entries(attributes)
74
+ .map(([key, value]) => `${key}="${escapeAttr(value)}"`)
75
+ .join(' ');
76
+
77
+ // Return the complete HTML with Declarative Shadow DOM
78
+ return `
79
+ <${tagName}${attrString ? ' ' + attrString : ''}>
80
+ <template shadowrootmode="open">
81
+ ${shadowContent}
82
+ </template>
83
+ </${tagName}>
84
+ `.trim();
85
+ }
86
+
87
+ /**
88
+ * Escape attribute values
89
+ */
90
+ function escapeAttr(value: string): string {
91
+ return value
92
+ .replace(/&/g, '&amp;')
93
+ .replace(/"/g, '&quot;')
94
+ .replace(/</g, '&lt;')
95
+ .replace(/>/g, '&gt;');
96
+ }
97
+
98
+ /**
99
+ * Server-side render utility
100
+ * Renders multiple components to HTML
101
+ */
102
+ export function ssr(components: Array<{
103
+ component: typeof HTMLElement;
104
+ attributes?: Record<string, string>;
105
+ }>): string {
106
+ return components
107
+ .map(({ component, attributes }) => renderComponentToString(component, attributes))
108
+ .join('\n');
109
+ }
110
+
111
+ /**
112
+ * Recursively expand nested components
113
+ */
114
+ function expandNestedComponents(node: Node | DocumentFragment): void {
115
+ const children = Array.from(node.childNodes);
116
+
117
+ children.forEach(child => {
118
+ if (child.nodeType === 1) { // ELEMENT_NODE
119
+ const element = child as HTMLElement;
120
+ const tagName = element.tagName.toLowerCase();
121
+
122
+ // Check if it's a registered custom element
123
+ // We use globalThis to access the polyfilled registry in SSR
124
+ if (globalThis.customElements && tagName.includes('-')) {
125
+ const ComponentClass = customElements.get(tagName);
126
+
127
+ if (ComponentClass) {
128
+ // Get attributes
129
+ const attributes: Record<string, string> = {};
130
+ Array.from(element.attributes).forEach(attr => {
131
+ attributes[attr.name] = attr.value;
132
+ });
133
+
134
+ // Render the nested component
135
+ const rendered = renderComponentToString(ComponentClass as typeof HTMLElement, attributes);
136
+
137
+ // Replace the element with the rendered HTML (which includes the tag)
138
+ // Since we can't easily replace outerHTML in a fragment with a string,
139
+ // we create a temporary container
140
+ const temp = document.createElement('div');
141
+ temp.innerHTML = rendered;
142
+
143
+ // Replace the child with the new rendered node
144
+ const newChild = temp.firstElementChild;
145
+ if (newChild) {
146
+ element.replaceWith(newChild);
147
+ // No need to recurse into newChild because renderComponentToString
148
+ // already handled its recursion internally
149
+ return;
150
+ }
151
+ }
152
+ }
153
+
154
+ // Recurse for normal elements
155
+ expandNestedComponents(element);
156
+ }
157
+ });
158
+ }
package/src/state.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { State } from './types';
2
+
3
+ /**
4
+ * Create a reactive state object using Proxy
5
+ *
6
+ * Unlike signals, state objects are reactive objects that track changes to any property.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const state = smolState({
11
+ * count: 0,
12
+ * name: 'John'
13
+ * });
14
+ *
15
+ * state.subscribe(() => {
16
+ * console.log('State changed:', state.data);
17
+ * });
18
+ *
19
+ * state.data.count++; // triggers subscribers
20
+ * ```
21
+ */
22
+ export function smolState<T extends object>(initialValue: T): State<T> {
23
+ const _subscribers = new Set<() => void>();
24
+
25
+ const notify = () => {
26
+ _subscribers.forEach(fn => fn());
27
+ };
28
+
29
+ const data = new Proxy(initialValue, {
30
+ set(target, property, value) {
31
+ const oldValue = (target as any)[property];
32
+
33
+ if (oldValue !== value) {
34
+ (target as any)[property] = value;
35
+ notify();
36
+ }
37
+
38
+ return true;
39
+ },
40
+
41
+ deleteProperty(target, property) {
42
+ if (property in target) {
43
+ delete (target as any)[property];
44
+ notify();
45
+ }
46
+
47
+ return true;
48
+ }
49
+ });
50
+
51
+ const state: State<T> = {
52
+ data,
53
+
54
+ subscribe(fn: () => void): () => void {
55
+ _subscribers.add(fn);
56
+
57
+ // Return unsubscribe function
58
+ return () => {
59
+ _subscribers.delete(fn);
60
+ };
61
+ },
62
+
63
+ _subscribers
64
+ };
65
+
66
+ return state;
67
+ }
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ // Core TypeScript type definitions for smol.js
2
+
3
+ export interface SmolComponentConfig {
4
+ /** The custom element tag name (must contain a hyphen) */
5
+ tag: string;
6
+
7
+ /** Shadow DOM mode */
8
+ mode?: 'open' | 'closed';
9
+
10
+ /** List of attributes to observe for changes */
11
+ observedAttributes?: string[];
12
+
13
+ /** Component styles (use css`` tagged template) */
14
+ styles?: string;
15
+
16
+ /** Template function that returns HTML */
17
+ template?: (this: SmolElement, ctx: SmolContext) => string | TemplateResult;
18
+
19
+ /** Lifecycle: element connected to DOM */
20
+ connected?: (this: SmolElement) => void;
21
+
22
+ /** Lifecycle: element disconnected from DOM */
23
+ disconnected?: (this: SmolElement) => void;
24
+
25
+ /** Lifecycle: observed attribute changed */
26
+ attributeChanged?: (this: SmolElement, name: string, oldValue: string | null, newValue: string | null) => void;
27
+ }
28
+
29
+ export interface SmolContext {
30
+ /** Emit a custom event */
31
+ emit: (name: string, detail?: any) => void;
32
+
33
+ /** Trigger a re-render */
34
+ render: () => void;
35
+
36
+ /** Access to element properties */
37
+ [key: string]: any;
38
+ }
39
+
40
+ export interface SmolElement extends HTMLElement {
41
+ /** Trigger a re-render of the component */
42
+ render(): void;
43
+
44
+ /** Emit a custom event from the component */
45
+ emit(name: string, detail?: any): void;
46
+
47
+ /** Access to the shadow root */
48
+ readonly shadowRoot: ShadowRoot;
49
+ }
50
+
51
+ export interface TemplateResult {
52
+ strings: TemplateStringsArray;
53
+ values: any[];
54
+ _isTemplateResult: true;
55
+ }
56
+
57
+ export interface Signal<T> {
58
+ /** Get the current value */
59
+ get value(): T;
60
+
61
+ /** Set a new value */
62
+ set value(newValue: T);
63
+
64
+ /** Subscribe to value changes */
65
+ subscribe(fn: (value: T) => void): () => void;
66
+
67
+ /** Internal subscribers */
68
+ _subscribers: Set<(value: T) => void>;
69
+ }
70
+
71
+ export interface State<T extends object> {
72
+ /** The proxied state object */
73
+ data: T;
74
+
75
+ /** Subscribe to any state change */
76
+ subscribe(fn: () => void): () => void;
77
+
78
+ /** Internal subscribers */
79
+ _subscribers: Set<() => void>;
80
+ }
81
+
82
+ export interface SmolServiceConfig<T> {
83
+ /** Unique service name */
84
+ name: string;
85
+
86
+ /** Factory function to create the service */
87
+ factory: () => T;
88
+
89
+ /** Whether this is a singleton (default: true) */
90
+ singleton?: boolean;
91
+ }
@@ -0,0 +1,85 @@
1
+ import type { Plugin } from 'vite';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+
5
+ /**
6
+ * Vite plugin for importing HTML templates as smol.js template functions
7
+ *
8
+ * This plugin allows you to write your component templates in separate .html files
9
+ * and import them into your components.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import template from './my-component.html?smol';
14
+ *
15
+ * smolComponent({
16
+ * tag: 'my-component',
17
+ * template(ctx) {
18
+ * return template(html);
19
+ * }
20
+ * });
21
+ * ```
22
+ */
23
+ export function smolTemplatePlugin(): Plugin {
24
+ return {
25
+ name: 'vite-plugin-smol-templates',
26
+ enforce: 'pre',
27
+
28
+ async resolveId(id: string, importer?: string) {
29
+ // Only handle .html files with ?smol query
30
+ if (id.includes('.html?smol')) {
31
+ const cleanId = id.replace('?smol', '');
32
+ const resolved = await this.resolve(cleanId, importer);
33
+
34
+ if (resolved) {
35
+ return resolved.id + '?smol';
36
+ }
37
+ }
38
+ return null;
39
+ },
40
+
41
+ load(id: string) {
42
+ // Check if this is an HTML template with ?smol query
43
+ if (!id.includes('.html?smol')) {
44
+ return null;
45
+ }
46
+
47
+ // Remove query parameter to get the actual file path
48
+ const filePath = id.replace(/\?smol$/, '');
49
+
50
+ try {
51
+ // Read the HTML file content
52
+ const htmlContent = readFileSync(filePath, 'utf-8');
53
+
54
+ // Transform the HTML into a JavaScript module
55
+ // The template will be a function that takes the html tagged template function
56
+ // and returns a TemplateResult
57
+
58
+ // We need to preserve ${} interpolations as actual template literal placeholders
59
+ // while escaping backticks in the HTML content
60
+ const escapedContent = htmlContent
61
+ .replace(/\\/g, '\\\\') // Escape backslashes
62
+ .replace(/`/g, '\\`'); // Escape backticks
63
+
64
+ // Create a function that uses 'with' to execute the template literal
65
+ // We use new Function to avoid strict mode limitations on 'with'
66
+ // and to allow dynamic variable resolution from the context
67
+ const templateBody = `with(context) { return html\`${escapedContent}\`; }`;
68
+
69
+ const code = `
70
+ export default function(html, context = {}) {
71
+ return new Function('html', 'context', ${JSON.stringify(templateBody)})(html, context);
72
+ }
73
+ `;
74
+
75
+ return {
76
+ code,
77
+ map: null
78
+ };
79
+ } catch (error) {
80
+ this.error(`Failed to load HTML template: ${filePath}\n${error}`);
81
+ return null;
82
+ }
83
+ }
84
+ };
85
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Export the Vite plugin for external HTML templates
2
+ // This is in a separate entry point to avoid bundling Node.js dependencies
3
+ // into the browser-facing library
4
+ export { smolTemplatePlugin } from './vite-plugin-smol-templates.js';