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/README.md +205 -0
- package/dist/index.d.ts +285 -0
- package/dist/smol.js +432 -0
- package/dist/smol.umd.js +1 -0
- package/dist/vite-plugin-smol-templates.d.ts +20 -0
- package/dist/vite-plugin-smol-templates.js +73 -0
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +4 -0
- package/package.json +48 -0
- package/src/component.ts +154 -0
- package/src/css.ts +25 -0
- package/src/html-module.d.ts +16 -0
- package/src/html.ts +178 -0
- package/src/hydrate-client.ts +17 -0
- package/src/hydrate.ts +102 -0
- package/src/index.ts +33 -0
- package/src/service.ts +67 -0
- package/src/signal.ts +89 -0
- package/src/ssr.ts +158 -0
- package/src/state.ts +67 -0
- package/src/types.ts +91 -0
- package/src/vite-plugin-smol-templates.ts +85 -0
- package/src/vite.ts +4 -0
package/src/component.ts
ADDED
|
@@ -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, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|