@devera_se/bedrockjs 0.1.1

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,357 @@
1
+ /**
2
+ * Base Component class for creating web components
3
+ */
4
+
5
+ import { render } from './render.js';
6
+ import { watch } from './reactive.js';
7
+
8
+ // Registry of defined components
9
+ const componentRegistry = new Map();
10
+
11
+ /**
12
+ * Base class for creating reactive web components
13
+ */
14
+ export class Component extends HTMLElement {
15
+ // Override in subclass to set custom tag name
16
+ static tag = null;
17
+
18
+ // Set to true to use Shadow DOM
19
+ static shadow = false;
20
+
21
+ // Define reactive properties
22
+ static properties = {};
23
+
24
+ // Set to false to disable auto-registration
25
+ static autoRegister = true;
26
+
27
+ // Store for property values
28
+ #props = {};
29
+
30
+ // Watcher cleanup function
31
+ #stopWatch = null;
32
+
33
+ // Render root (shadow or light DOM)
34
+ #renderRoot = null;
35
+
36
+ // Whether component is connected
37
+ #connected = false;
38
+
39
+ // Pending render flag
40
+ #pendingRender = false;
41
+
42
+ // Route data from router
43
+ #routeData = null;
44
+
45
+ constructor() {
46
+ super();
47
+
48
+ // Initialize shadow DOM if enabled
49
+ if (this.constructor.shadow) {
50
+ this.#renderRoot = this.attachShadow({ mode: 'open' });
51
+ } else {
52
+ this.#renderRoot = this;
53
+ }
54
+
55
+ // Initialize reactive properties
56
+ this.#initializeProperties();
57
+ }
58
+
59
+ /**
60
+ * Initialize reactive properties from static definition
61
+ */
62
+ #initializeProperties() {
63
+ const properties = this.constructor.properties;
64
+
65
+ for (const [name, config] of Object.entries(properties)) {
66
+ const normalizedConfig = typeof config === 'function'
67
+ ? { type: config }
68
+ : config;
69
+
70
+ // Set default value
71
+ if (normalizedConfig.default !== undefined) {
72
+ this.#props[name] = typeof normalizedConfig.default === 'function'
73
+ ? normalizedConfig.default()
74
+ : normalizedConfig.default;
75
+ } else {
76
+ this.#props[name] = undefined;
77
+ }
78
+
79
+ // Define getter/setter
80
+ Object.defineProperty(this, name, {
81
+ get: () => this.#props[name],
82
+ set: (value) => {
83
+ const oldValue = this.#props[name];
84
+ const coerced = this.#coerceValue(value, normalizedConfig.type);
85
+
86
+ if (oldValue !== coerced) {
87
+ this.#props[name] = coerced;
88
+ this.#scheduleRender();
89
+ }
90
+ },
91
+ enumerable: true,
92
+ configurable: true
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Coerce value to the specified type
99
+ */
100
+ #coerceValue(value, type) {
101
+ if (value === null || value === undefined) {
102
+ return value;
103
+ }
104
+
105
+ if (!type) return value;
106
+
107
+ switch (type) {
108
+ case String:
109
+ return String(value);
110
+ case Number:
111
+ return Number(value);
112
+ case Boolean:
113
+ return Boolean(value);
114
+ case Array:
115
+ return Array.isArray(value) ? value : [value];
116
+ case Object:
117
+ return typeof value === 'object' ? value : { value };
118
+ default:
119
+ return value;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get the render root (shadow DOM or this element)
125
+ */
126
+ get renderRoot() {
127
+ return this.#renderRoot;
128
+ }
129
+
130
+ /**
131
+ * Get route data
132
+ */
133
+ get routeData() {
134
+ return this.#routeData;
135
+ }
136
+
137
+ /**
138
+ * Set route data (called by router)
139
+ */
140
+ set routeData(data) {
141
+ this.#routeData = data;
142
+ this.#scheduleRender();
143
+ }
144
+
145
+ /**
146
+ * Called when element is connected to DOM
147
+ */
148
+ connectedCallback() {
149
+ this.#connected = true;
150
+
151
+ // Read attributes into properties
152
+ this.#readAttributes();
153
+
154
+ // Initial render
155
+ this.#doRender();
156
+
157
+ // Set up reactive watching
158
+ this.#stopWatch = watch(() => {
159
+ this.#doRender();
160
+ }, { immediate: false });
161
+ }
162
+
163
+ /**
164
+ * Called when element is disconnected from DOM
165
+ */
166
+ disconnectedCallback() {
167
+ this.#connected = false;
168
+
169
+ if (this.#stopWatch) {
170
+ this.#stopWatch();
171
+ this.#stopWatch = null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read attributes into properties
177
+ */
178
+ #readAttributes() {
179
+ const properties = this.constructor.properties;
180
+
181
+ for (const [name, config] of Object.entries(properties)) {
182
+ const normalizedConfig = typeof config === 'function'
183
+ ? { type: config }
184
+ : config;
185
+
186
+ // Convert property name to attribute name (camelCase -> kebab-case)
187
+ const attrName = name.replace(/([A-Z])/g, '-$1').toLowerCase();
188
+
189
+ if (this.hasAttribute(attrName)) {
190
+ const attrValue = this.getAttribute(attrName);
191
+ this[name] = this.#parseAttributeValue(attrValue, normalizedConfig.type);
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Parse attribute string value based on type
198
+ */
199
+ #parseAttributeValue(value, type) {
200
+ if (!type) return value;
201
+
202
+ switch (type) {
203
+ case Boolean:
204
+ return value !== null && value !== 'false';
205
+ case Number:
206
+ return Number(value);
207
+ case Array:
208
+ case Object:
209
+ try {
210
+ return JSON.parse(value);
211
+ } catch {
212
+ return value;
213
+ }
214
+ default:
215
+ return value;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Observe attribute changes
221
+ */
222
+ static get observedAttributes() {
223
+ const properties = this.properties || {};
224
+ return Object.keys(properties).map(name =>
225
+ name.replace(/([A-Z])/g, '-$1').toLowerCase()
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Called when an observed attribute changes
231
+ */
232
+ attributeChangedCallback(name, oldValue, newValue) {
233
+ if (oldValue === newValue) return;
234
+
235
+ // Convert attribute name to property name (kebab-case -> camelCase)
236
+ const propName = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
237
+
238
+ if (propName in this.constructor.properties) {
239
+ const config = this.constructor.properties[propName];
240
+ const normalizedConfig = typeof config === 'function'
241
+ ? { type: config }
242
+ : config;
243
+
244
+ this[propName] = this.#parseAttributeValue(newValue, normalizedConfig.type);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Schedule a render on next microtask
250
+ */
251
+ #scheduleRender() {
252
+ if (!this.#connected || this.#pendingRender) return;
253
+
254
+ this.#pendingRender = true;
255
+ queueMicrotask(() => {
256
+ this.#pendingRender = false;
257
+ if (this.#connected) {
258
+ this.#doRender();
259
+ }
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Perform the actual render
265
+ */
266
+ #doRender() {
267
+ const result = this.render();
268
+ if (result) {
269
+ render(result, this.#renderRoot);
270
+ }
271
+ this.updated();
272
+ }
273
+
274
+ /**
275
+ * Override to return template result
276
+ * @returns {TemplateResult|null}
277
+ */
278
+ render() {
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * Called after each render
284
+ */
285
+ updated() {
286
+ // Override in subclass
287
+ }
288
+
289
+ /**
290
+ * Manually trigger a re-render
291
+ */
292
+ requestUpdate() {
293
+ this.#scheduleRender();
294
+ }
295
+
296
+ /**
297
+ * Register this component with the custom elements registry
298
+ * @param {string} [tagName] - Optional tag name override
299
+ */
300
+ static register(tagName) {
301
+ const tag = tagName || this.tag;
302
+
303
+ if (!tag) {
304
+ throw new Error('Component must have a tag name');
305
+ }
306
+
307
+ if (!customElements.get(tag)) {
308
+ customElements.define(tag, this);
309
+ componentRegistry.set(tag, this);
310
+ }
311
+
312
+ return this;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Decorator/helper to define a component
318
+ * @param {string} tag - Tag name
319
+ * @param {Object} options - Component options
320
+ */
321
+ export function defineComponent(tag, options = {}) {
322
+ return (ComponentClass) => {
323
+ ComponentClass.tag = tag;
324
+
325
+ if (options.shadow !== undefined) {
326
+ ComponentClass.shadow = options.shadow;
327
+ }
328
+
329
+ if (options.properties) {
330
+ ComponentClass.properties = {
331
+ ...ComponentClass.properties,
332
+ ...options.properties
333
+ };
334
+ }
335
+
336
+ if (options.autoRegister !== false) {
337
+ ComponentClass.register();
338
+ }
339
+
340
+ return ComponentClass;
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Auto-register components when the class is defined
346
+ * Call this after defining your component class
347
+ */
348
+ export function autoRegister(ComponentClass) {
349
+ if (ComponentClass.autoRegister && ComponentClass.tag) {
350
+ ComponentClass.register();
351
+ }
352
+ return ComponentClass;
353
+ }
354
+
355
+ // Hook into class definition to auto-register
356
+ // This runs when the module is imported
357
+ const originalDefine = Object.defineProperty;
package/src/html.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export class TemplateResult {
2
+ strings: TemplateStringsArray;
3
+ values: any[];
4
+
5
+ constructor(strings: TemplateStringsArray, values: any[]);
6
+
7
+ getTemplate(): any;
8
+ }
9
+
10
+ export function html(strings: TemplateStringsArray, ...values: any[]): TemplateResult;
11
+
12
+ export function isTemplateResult(value: any): value is TemplateResult;
package/src/html.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Template literal parser for creating efficient DOM templates
3
+ */
4
+
5
+ // Unique marker for template parts
6
+ const MARKER = `bedrock-${Math.random().toString(36).slice(2)}`;
7
+ const COMMENT_MARKER = `<!--${MARKER}-`;
8
+ const ATTR_MARKER = `${MARKER}-`;
9
+
10
+ // Template cache for reusing parsed templates
11
+ const templateCache = new WeakMap();
12
+
13
+ /**
14
+ * Represents a parsed template with static parts and dynamic values
15
+ */
16
+ export class TemplateResult {
17
+ constructor(strings, values) {
18
+ this.strings = strings;
19
+ this.values = values;
20
+ this._type = 'template-result';
21
+ }
22
+
23
+ /**
24
+ * Get cached template or create new one
25
+ */
26
+ getTemplate() {
27
+ let template = templateCache.get(this.strings);
28
+ if (!template) {
29
+ template = parseTemplate(this.strings);
30
+ templateCache.set(this.strings, template);
31
+ }
32
+ return template;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Tagged template literal function for creating templates
38
+ * @param {TemplateStringsArray} strings - Static parts
39
+ * @param {...any} values - Dynamic values
40
+ * @returns {TemplateResult}
41
+ */
42
+ export function html(strings, ...values) {
43
+ return new TemplateResult(strings, values);
44
+ }
45
+
46
+ /**
47
+ * Check if we're inside an HTML tag (for attribute vs node detection)
48
+ */
49
+ function isInsideTag(str) {
50
+ let inTag = false;
51
+ for (let i = str.length - 1; i >= 0; i--) {
52
+ if (str[i] === '>') return false;
53
+ if (str[i] === '<') return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Parse template strings into a reusable template structure
60
+ */
61
+ function parseTemplate(strings) {
62
+ const parts = [];
63
+ let htmlStr = '';
64
+
65
+ for (let i = 0; i < strings.length; i++) {
66
+ htmlStr += strings[i];
67
+
68
+ if (i < strings.length - 1) {
69
+ // Check if this expression is inside a tag (attribute) or outside (node)
70
+ if (isInsideTag(htmlStr)) {
71
+ // Attribute position - use attribute marker
72
+ htmlStr += `${ATTR_MARKER}${i}`;
73
+ parts.push({ type: 'attr-pending', index: i });
74
+ } else {
75
+ // Node position - use comment marker
76
+ htmlStr += `${COMMENT_MARKER}${i}-->`;
77
+ parts.push({ type: 'node', index: i });
78
+ }
79
+ }
80
+ }
81
+
82
+ // Parse into template element
83
+ const template = document.createElement('template');
84
+ template.innerHTML = htmlStr;
85
+
86
+ // Walk the template to resolve part locations
87
+ const resolvedParts = new Array(strings.length - 1).fill(null);
88
+ walkTemplate(template.content, resolvedParts, []);
89
+
90
+ return { element: template, parts: resolvedParts };
91
+ }
92
+
93
+ /**
94
+ * Walk the template DOM to find marker positions
95
+ */
96
+ function walkTemplate(node, parts, path) {
97
+ if (node.nodeType === Node.ELEMENT_NODE) {
98
+ // Check attributes for markers
99
+ const attrsToRemove = [];
100
+ for (const attr of node.attributes) {
101
+ if (attr.value.includes(ATTR_MARKER) || attr.name.includes(ATTR_MARKER)) {
102
+ const match = (attr.value + attr.name).match(new RegExp(`${ATTR_MARKER}(\\d+)`));
103
+ if (match) {
104
+ const index = parseInt(match[1], 10);
105
+ const name = attr.name.replace(new RegExp(`${ATTR_MARKER}\\d+`), '');
106
+ const isEvent = name.startsWith('on-');
107
+ const isProperty = name.startsWith('.');
108
+
109
+ parts[index] = {
110
+ type: isEvent ? 'event' : isProperty ? 'property' : 'attribute',
111
+ path: [...path],
112
+ name: isEvent ? name.slice(3) : isProperty ? name.slice(1) : name,
113
+ };
114
+ attrsToRemove.push(attr.name);
115
+ }
116
+ }
117
+ }
118
+ // Remove marker attributes
119
+ for (const name of attrsToRemove) {
120
+ node.removeAttribute(name);
121
+ }
122
+ }
123
+
124
+ // Check comment nodes for markers
125
+ if (node.nodeType === Node.COMMENT_NODE) {
126
+ const text = node.textContent;
127
+ if (text.startsWith(MARKER + '-')) {
128
+ const index = parseInt(text.slice(MARKER.length + 1), 10);
129
+ parts[index] = {
130
+ type: 'node',
131
+ path: [...path]
132
+ };
133
+ }
134
+ }
135
+
136
+ // Recurse into children
137
+ const children = Array.from(node.childNodes);
138
+ for (let i = 0; i < children.length; i++) {
139
+ walkTemplate(children[i], parts, [...path, i]);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Check if a value is a TemplateResult
145
+ */
146
+ export function isTemplateResult(value) {
147
+ return value && value._type === 'template-result';
148
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { html, TemplateResult, isTemplateResult } from './html.js';
2
+ export { render, keyed } from './render.js';
3
+ export { Component, defineComponent, autoRegister } from './component.js';
4
+ export { reactive, watch, computed, signal, batch } from './reactive.js';
5
+ export {
6
+ Router,
7
+ RouterOutlet,
8
+ RouterLink,
9
+ createRouter,
10
+ navigate,
11
+ getParams
12
+ } from './router.js';
package/src/index.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * BedrockJS - A lightweight web framework built on web components
3
+ *
4
+ * @example
5
+ * import { html, Component, reactive, Router } from 'bedrockjs';
6
+ *
7
+ * class MyCounter extends Component {
8
+ * static tag = 'my-counter';
9
+ * static properties = {
10
+ * count: { type: Number, default: 0 }
11
+ * };
12
+ *
13
+ * render() {
14
+ * return html`
15
+ * <button on-click=${() => this.count++}>
16
+ * Count: ${this.count}
17
+ * </button>
18
+ * `;
19
+ * }
20
+ * }
21
+ * MyCounter.register();
22
+ */
23
+
24
+ // Template system
25
+ export { html, TemplateResult, isTemplateResult } from './html.js';
26
+
27
+ // Rendering
28
+ export { render, keyed } from './render.js';
29
+
30
+ // Component system
31
+ export { Component, defineComponent, autoRegister } from './component.js';
32
+
33
+ // Reactive state
34
+ export {
35
+ reactive,
36
+ watch,
37
+ computed,
38
+ signal,
39
+ batch
40
+ } from './reactive.js';
41
+
42
+ // Router
43
+ export {
44
+ Router,
45
+ RouterOutlet,
46
+ RouterLink,
47
+ createRouter,
48
+ navigate,
49
+ getParams
50
+ } from './router.js';
@@ -0,0 +1,20 @@
1
+ export function reactive<T extends object>(target: T): T;
2
+
3
+ export interface WatchOptions {
4
+ immediate?: boolean;
5
+ }
6
+
7
+ export function watch(fn: () => void, options?: WatchOptions): () => void;
8
+
9
+ export interface Computed<T> {
10
+ readonly value: T;
11
+ stop(): void;
12
+ }
13
+
14
+ export function computed<T>(getter: () => T): Computed<T>;
15
+
16
+ export type Signal<T> = [() => T, (value: T) => void];
17
+
18
+ export function signal<T>(initialValue: T): Signal<T>;
19
+
20
+ export function batch(fn: () => void): void;