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.
@@ -0,0 +1,154 @@
1
+ import type { SmolComponentConfig, SmolElement, SmolContext } from './types';
2
+ import { render, renderToString, isTemplateResult } from './html';
3
+
4
+ /**
5
+ * Create a custom Web Component
6
+ *
7
+ * This function creates a custom element class and automatically registers it.
8
+ * It handles Shadow DOM, lifecycle callbacks, and template rendering.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * // my-button.html
13
+ * // <button><slot>Button</slot></button>
14
+ *
15
+ * // my-button.css
16
+ * // button { padding: 0.5rem 1rem; }
17
+ *
18
+ * import { smolComponent, html } from 'smol.js';
19
+ * import styles from './my-button.css?inline';
20
+ * import template from './my-button.html?smol';
21
+ *
22
+ * smolComponent({
23
+ * tag: 'my-button',
24
+ * observedAttributes: ['variant'],
25
+ *
26
+ * styles,
27
+ *
28
+ * template(ctx) {
29
+ * return template(html);
30
+ * }
31
+ * });
32
+ * ```
33
+ */
34
+ export function smolComponent(config: SmolComponentConfig): typeof HTMLElement {
35
+ const {
36
+ tag,
37
+ mode = 'open',
38
+ observedAttributes = [],
39
+ styles = '',
40
+ template,
41
+ connected,
42
+ disconnected,
43
+ attributeChanged
44
+ } = config;
45
+
46
+ // Validate tag name
47
+ if (!tag.includes('-')) {
48
+ throw new Error(`Custom element tag names must contain a hyphen: "${tag}"`);
49
+ }
50
+
51
+ class SmolCustomElement extends HTMLElement implements SmolElement {
52
+ declare shadowRoot: ShadowRoot;
53
+
54
+ static get observedAttributes() {
55
+ return observedAttributes;
56
+ }
57
+
58
+ constructor() {
59
+ super();
60
+
61
+ // Check if Shadow DOM already exists (from SSR Declarative Shadow DOM)
62
+ if (!this.shadowRoot) {
63
+ this.attachShadow({ mode });
64
+ }
65
+
66
+ // Inject styles if provided
67
+ if (styles && this.shadowRoot) {
68
+ const styleElement = document.createElement('style');
69
+ styleElement.textContent = styles;
70
+ this.shadowRoot.appendChild(styleElement);
71
+ }
72
+ }
73
+
74
+ connectedCallback() {
75
+ // Call user-defined connected lifecycle FIRST
76
+ // This allows setup of signals and reactive state before first render
77
+ if (connected) {
78
+ connected.call(this);
79
+ }
80
+
81
+ // Then render the component
82
+ this.render();
83
+ }
84
+
85
+ disconnectedCallback() {
86
+ // Call user-defined disconnected lifecycle
87
+ if (disconnected) {
88
+ disconnected.call(this);
89
+ }
90
+ }
91
+
92
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
93
+ // Re-render on attribute change
94
+ if (this.isConnected) {
95
+ this.render();
96
+ }
97
+
98
+ // Call user-defined attribute changed lifecycle
99
+ if (attributeChanged) {
100
+ attributeChanged.call(this, name, oldValue, newValue);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Render the component template
106
+ */
107
+ render(): void {
108
+ if (!template || !this.shadowRoot) {
109
+ return;
110
+ }
111
+
112
+ const ctx: SmolContext = {
113
+ emit: this.emit.bind(this),
114
+ render: this.render.bind(this),
115
+ // Add element as context (for accessing attributes, etc.)
116
+ element: this
117
+ };
118
+
119
+ const result = template.call(this, ctx);
120
+
121
+ if (!result) {
122
+ return;
123
+ }
124
+
125
+ if (typeof result === 'string') {
126
+ this.shadowRoot.innerHTML = result;
127
+ } else if (isTemplateResult(result)) {
128
+ render(result, this.shadowRoot);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Emit a custom event
134
+ */
135
+ emit(name: string, detail?: any): void {
136
+ this.dispatchEvent(new CustomEvent(name, {
137
+ detail,
138
+ bubbles: true,
139
+ composed: true
140
+ }));
141
+ }
142
+ }
143
+
144
+ // Store config and tag name on the class for SSR
145
+ (SmolCustomElement as any)._smolConfig = config;
146
+ (SmolCustomElement as any)._smolTag = tag;
147
+
148
+ // Auto-register the custom element
149
+ if (!customElements.get(tag)) {
150
+ customElements.define(tag, SmolCustomElement);
151
+ }
152
+
153
+ return SmolCustomElement;
154
+ }
package/src/css.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Tagged template literal for CSS styles
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * const styles = css`
7
+ * :host {
8
+ * display: block;
9
+ * }
10
+ * `;
11
+ * ```
12
+ */
13
+ export function css(strings: TemplateStringsArray, ...values: any[]): string {
14
+ let result = '';
15
+
16
+ for (let i = 0; i < strings.length; i++) {
17
+ result += strings[i];
18
+
19
+ if (i < values.length) {
20
+ result += String(values[i]);
21
+ }
22
+ }
23
+
24
+ return result.trim();
25
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Type declarations for importing HTML templates with ?smol query parameter
3
+ *
4
+ * This allows TypeScript to recognize .html?smol imports
5
+ */
6
+
7
+ declare module '*.html?smol' {
8
+ import type { TemplateResult } from './types';
9
+
10
+ /**
11
+ * Imported HTML template function
12
+ * Takes the html tagged template function and returns a TemplateResult
13
+ */
14
+ const template: (html: (strings: TemplateStringsArray, ...values: any[]) => TemplateResult) => TemplateResult;
15
+ export default template;
16
+ }
package/src/html.ts ADDED
@@ -0,0 +1,178 @@
1
+ import type { TemplateResult } from './types';
2
+
3
+ /**
4
+ * Tagged template literal for HTML templates
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * const template = html`<div>${value}</div>`;
9
+ * ```
10
+ */
11
+ export function html(strings: TemplateStringsArray, ...values: any[]): TemplateResult {
12
+ return {
13
+ strings,
14
+ values,
15
+ _isTemplateResult: true
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Check if a value is a TemplateResult
21
+ */
22
+ export function isTemplateResult(value: any): value is TemplateResult {
23
+ return value && value._isTemplateResult === true;
24
+ }
25
+
26
+ /**
27
+ * Render a template result to an HTML string
28
+ * Used for SSR or initial rendering
29
+ */
30
+ export function renderToString(template: TemplateResult): string {
31
+ let result = '';
32
+
33
+ for (let i = 0; i < template.strings.length; i++) {
34
+ result += template.strings[i];
35
+
36
+ if (i < template.values.length) {
37
+ const value = template.values[i];
38
+ result += stringifyValue(value);
39
+ }
40
+ }
41
+
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Convert a value to a string for HTML output
47
+ */
48
+ function stringifyValue(value: any): string {
49
+ if (value == null) {
50
+ return '';
51
+ }
52
+
53
+ if (isTemplateResult(value)) {
54
+ return renderToString(value);
55
+ }
56
+
57
+ if (Array.isArray(value)) {
58
+ return value.map(stringifyValue).join('');
59
+ }
60
+
61
+ if (typeof value === 'boolean') {
62
+ return value ? '' : '';
63
+ }
64
+
65
+ if (typeof value === 'function') {
66
+ // Skip functions (likely event handlers)
67
+ return '';
68
+ }
69
+
70
+ return escapeHtml(String(value));
71
+ }
72
+
73
+ /**
74
+ * Escape HTML special characters
75
+ */
76
+ function escapeHtml(unsafe: string): string {
77
+ return unsafe
78
+ .replace(/&/g, '&amp;')
79
+ .replace(/</g, '&lt;')
80
+ .replace(/>/g, '&gt;')
81
+ .replace(/"/g, '&quot;')
82
+ .replace(/'/g, '&#039;');
83
+ }
84
+
85
+ /**
86
+ * Render a template to a DOM node
87
+ * This handles event listeners, attributes, and text content
88
+ * IMPORTANT: Preserves existing <style> elements in shadow DOM
89
+ */
90
+ export function render(template: TemplateResult, container: HTMLElement | ShadowRoot): void {
91
+ // CRITICAL FIX: Preserve existing style elements before wiping innerHTML
92
+ const existingStyles = Array.from(container.querySelectorAll('style'));
93
+
94
+ // Build HTML first, replacing expressions with markers
95
+ let html = '';
96
+ const markers: Array<{ index: number; value: any; type: string }> = [];
97
+ let markerIndex = 0;
98
+
99
+ for (let i = 0; i < template.strings.length; i++) {
100
+ const str = template.strings[i];
101
+ html += str;
102
+
103
+ if (i < template.values.length) {
104
+ const value = template.values[i];
105
+
106
+ // Detect what kind of binding this is based on the preceding text
107
+ const lastPart = str.trim();
108
+
109
+ // Event handler: @event=${handler}
110
+ if (lastPart.endsWith('@click=') || lastPart.endsWith('@change=') || lastPart.includes('@')) {
111
+ markers.push({ index: markerIndex, value, type: 'event' });
112
+ html += `"__smol_event_${markerIndex}__"`;
113
+ markerIndex++;
114
+ }
115
+ // Boolean attribute: ?disabled=${bool}
116
+ else if (lastPart.endsWith('?disabled=') || lastPart.endsWith('?checked=') || lastPart.includes('?')) {
117
+ markers.push({ index: markerIndex, value, type: 'boolean' });
118
+ html += `"__smol_bool_${markerIndex}__"`;
119
+ markerIndex++;
120
+ }
121
+ // Regular interpolation
122
+ else {
123
+ html += stringifyValue(value);
124
+ }
125
+ }
126
+ }
127
+
128
+ // Set innerHTML (this wipes everything including styles)
129
+ container.innerHTML = html;
130
+
131
+ // Re-add the preserved style elements at the beginning
132
+ existingStyles.forEach(style => {
133
+ container.insertBefore(style, container.firstChild);
134
+ });
135
+
136
+ // Attach event listeners and handle boolean attributes
137
+ markers.forEach(marker => {
138
+ if (marker.type === 'event') {
139
+ // Find elements with event markers
140
+ const markerAttr = `__smol_event_${marker.index}__`;
141
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
142
+
143
+ let node;
144
+ while ((node = walker.nextNode())) {
145
+ const element = node as Element;
146
+ for (const attr of Array.from(element.attributes)) {
147
+ if (attr.value === markerAttr) {
148
+ const eventName = attr.name.replace('@', '');
149
+ element.removeAttribute(attr.name);
150
+
151
+ if (typeof marker.value === 'function') {
152
+ element.addEventListener(eventName, marker.value as EventListener);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ } else if (marker.type === 'boolean') {
158
+ // Handle boolean attributes
159
+ const markerAttr = `__smol_bool_${marker.index}__`;
160
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
161
+
162
+ let node;
163
+ while ((node = walker.nextNode())) {
164
+ const element = node as Element;
165
+ for (const attr of Array.from(element.attributes)) {
166
+ if (attr.value === markerAttr) {
167
+ const attrName = attr.name.replace('?', '');
168
+ element.removeAttribute(attr.name);
169
+
170
+ if (marker.value) {
171
+ element.setAttribute(attrName, '');
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ });
178
+ }
@@ -0,0 +1,17 @@
1
+ import { hydrateAll } from './hydrate';
2
+
3
+ /**
4
+ * Client-only auto-hydration setup
5
+ * This file should only be imported in client-side code, not SSR
6
+ */
7
+
8
+ if (typeof window !== 'undefined') {
9
+ if (document.readyState === 'loading') {
10
+ document.addEventListener('DOMContentLoaded', () => {
11
+ hydrateAll();
12
+ });
13
+ } else {
14
+ // DOM already loaded
15
+ hydrateAll();
16
+ }
17
+ }
package/src/hydrate.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { SmolElement } from './types';
2
+
3
+ /**
4
+ * Hydration utilities for SSR-rendered components
5
+ *
6
+ * Hydration attaches JavaScript behavior to server-rendered HTML
7
+ * without destroying and recreating the DOM.
8
+ */
9
+
10
+ /**
11
+ * Check if a component was server-rendered (has Declarative Shadow DOM)
12
+ */
13
+ export function isServerRendered(element: HTMLElement): boolean {
14
+ return element.shadowRoot !== null && !element.shadowRoot.mode;
15
+ }
16
+
17
+ /**
18
+ * Hydrate a server-rendered component
19
+ *
20
+ * This attaches event listeners and sets up reactivity
21
+ * without re-rendering the component.
22
+ */
23
+ export function hydrateComponent(element: SmolElement): void {
24
+ // If not server-rendered, skip hydration
25
+ if (!element.shadowRoot) {
26
+ return;
27
+ }
28
+
29
+ // Mark as hydrated to prevent full re-render
30
+ (element as any)._hydrated = true;
31
+
32
+ // The component's connected callback will handle:
33
+ // 1. Setting up reactive state
34
+ // 2. Subscribing to state changes
35
+ // 3. Calling render() - but render() should check _hydrated flag
36
+
37
+ // After hydration, attach event listeners
38
+ attachEventListeners(element);
39
+ }
40
+
41
+ /**
42
+ * Attach event listeners to a hydrated component
43
+ *
44
+ * This walks the shadow DOM and finds elements with data-smol-event attributes
45
+ * that were set during SSR.
46
+ */
47
+ function attachEventListeners(element: SmolElement): void {
48
+ if (!element.shadowRoot) return;
49
+
50
+ // Find all elements with event listener markers
51
+ const walker = document.createTreeWalker(
52
+ element.shadowRoot,
53
+ NodeFilter.SHOW_ELEMENT
54
+ );
55
+
56
+ const elementsWithEvents: Array<{ element: Element; events: Map<string, string> }> = [];
57
+
58
+ let node;
59
+ while ((node = walker.nextNode())) {
60
+ const el = node as Element;
61
+ const events = new Map<string, string>();
62
+
63
+ // Check for data-smol-* attributes that mark event listeners
64
+ for (const attr of Array.from(el.attributes)) {
65
+ if (attr.name.startsWith('data-smol-event-')) {
66
+ const eventName = attr.name.replace('data-smol-event-', '');
67
+ events.set(eventName, attr.value);
68
+ }
69
+ }
70
+
71
+ if (events.size > 0) {
72
+ elementsWithEvents.push({ element: el, events });
73
+ }
74
+ }
75
+
76
+ // Attach event listeners
77
+ // Note: The actual handler functions need to be retrieved from the component's config
78
+ // This is a simplified version - in production, you'd serialize handler references
79
+ elementsWithEvents.forEach(({ element: el, events }) => {
80
+ events.forEach((handlerId, eventName) => {
81
+ // In a full implementation, you'd look up the handler by handlerId
82
+ // For now, this is a placeholder
83
+ console.warn(`Hydration: Found event listener ${eventName} but handler lookup not implemented`);
84
+ });
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Hydrate all smol components on the page
90
+ *
91
+ * Call this after the page loads to hydrate all server-rendered components.
92
+ */
93
+ export function hydrateAll(): void {
94
+ const allElements = document.querySelectorAll('*');
95
+
96
+ allElements.forEach(element => {
97
+ // Check if this is a custom element (has a hyphen in the tag name)
98
+ if (element.tagName.includes('-') && element.shadowRoot) {
99
+ hydrateComponent(element as SmolElement);
100
+ }
101
+ });
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // smol.js - Minimal Web Component Library
2
+ // Zero dependencies, tree-shakable, standards-based
3
+
4
+ // Core component creation
5
+ export { smolComponent } from './component';
6
+
7
+ // Services and dependency injection
8
+ export { smolService, inject, clearServices } from './service';
9
+
10
+ // Reactivity primitives
11
+ export { smolSignal, computed, effect } from './signal';
12
+ export { smolState } from './state';
13
+
14
+ // Template helpers
15
+ export { html, render, renderToString, isTemplateResult } from './html';
16
+ export { css } from './css';
17
+
18
+ // SSR utilities
19
+ export { renderComponentToString, ssr } from './ssr';
20
+
21
+ // Hydration utilities
22
+ export { hydrateComponent, hydrateAll } from './hydrate';
23
+
24
+ // TypeScript types
25
+ export type {
26
+ SmolComponentConfig,
27
+ SmolContext,
28
+ SmolElement,
29
+ TemplateResult,
30
+ Signal,
31
+ State,
32
+ SmolServiceConfig
33
+ } from './types';
package/src/service.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { SmolServiceConfig } from './types';
2
+
3
+ // Global service registry
4
+ const serviceRegistry = new Map<string, any>();
5
+
6
+ /**
7
+ * Create a service (singleton by default)
8
+ *
9
+ * Services provide shared functionality across components.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const ApiService = smolService({
14
+ * name: 'ApiService',
15
+ * factory: () => ({
16
+ * async fetchData(url: string) {
17
+ * const res = await fetch(url);
18
+ * return res.json();
19
+ * }
20
+ * })
21
+ * });
22
+ *
23
+ * // Use in component:
24
+ * const api = inject('ApiService');
25
+ * ```
26
+ */
27
+ export function smolService<T>(config: SmolServiceConfig<T>): T {
28
+ const { name, factory, singleton = true } = config;
29
+
30
+ if (singleton) {
31
+ // Return existing instance if available
32
+ if (serviceRegistry.has(name)) {
33
+ return serviceRegistry.get(name);
34
+ }
35
+
36
+ // Create and store new instance
37
+ const instance = factory();
38
+ serviceRegistry.set(name, instance);
39
+ return instance;
40
+ }
41
+
42
+ // Always create new instance for non-singletons
43
+ return factory();
44
+ }
45
+
46
+ /**
47
+ * Inject a service by name
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const api = inject<ApiService>('ApiService');
52
+ * ```
53
+ */
54
+ export function inject<T = any>(name: string): T {
55
+ if (!serviceRegistry.has(name)) {
56
+ throw new Error(`Service "${name}" not found. Did you forget to create it with smolService()?`);
57
+ }
58
+
59
+ return serviceRegistry.get(name);
60
+ }
61
+
62
+ /**
63
+ * Clear all services (useful for testing)
64
+ */
65
+ export function clearServices(): void {
66
+ serviceRegistry.clear();
67
+ }
package/src/signal.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { Signal } from './types';
2
+
3
+ /**
4
+ * Create a reactive signal
5
+ *
6
+ * Signals are lightweight reactive primitives that notify subscribers when their value changes.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const count = smolSignal(0);
11
+ *
12
+ * // Subscribe to changes
13
+ * count.subscribe((value) => console.log('Count:', value));
14
+ *
15
+ * // Update value
16
+ * count.value = 1; // logs "Count: 1"
17
+ * ```
18
+ */
19
+ export function smolSignal<T>(initialValue: T): Signal<T> {
20
+ let _value = initialValue;
21
+ const _subscribers = new Set<(value: T) => void>();
22
+
23
+ const signal: Signal<T> = {
24
+ get value() {
25
+ return _value;
26
+ },
27
+
28
+ set value(newValue: T) {
29
+ if (_value !== newValue) {
30
+ _value = newValue;
31
+ _subscribers.forEach(fn => fn(_value));
32
+ }
33
+ },
34
+
35
+ subscribe(fn: (value: T) => void): () => void {
36
+ _subscribers.add(fn);
37
+
38
+ // Return unsubscribe function
39
+ return () => {
40
+ _subscribers.delete(fn);
41
+ };
42
+ },
43
+
44
+ _subscribers
45
+ };
46
+
47
+ return signal;
48
+ }
49
+
50
+ /**
51
+ * Create a computed signal that derives its value from other signals
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const count = smolSignal(0);
56
+ * const doubled = computed(() => count.value * 2);
57
+ * ```
58
+ */
59
+ export function computed<T>(fn: () => T): Signal<T> {
60
+ const signal = smolSignal<T>(fn());
61
+
62
+ // Note: This is a simplified version
63
+ // A production version would track dependencies automatically
64
+
65
+ return signal;
66
+ }
67
+
68
+ /**
69
+ * Create an effect that runs when signals change
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * const count = smolSignal(0);
74
+ *
75
+ * effect(() => {
76
+ * console.log('Count is:', count.value);
77
+ * });
78
+ * ```
79
+ */
80
+ export function effect(fn: () => void): () => void {
81
+ // Run immediately
82
+ fn();
83
+
84
+ // Note: This is a simplified version
85
+ // A production version would track signal dependencies and re-run when they change
86
+
87
+ // Return cleanup function
88
+ return () => { };
89
+ }