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/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, '&')
|
|
93
|
+
.replace(/"/g, '"')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>');
|
|
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