humanjs-core 1.0.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,194 @@
1
+ const SECTION_NAMES = ['imports', 'state', 'derived', 'actions', 'template', 'hooks'];
2
+ const EVENT_NAMES = ['click', 'submit', 'input', 'change', 'keydown', 'keyup', 'focus', 'blur'];
3
+
4
+ function escapeTemplateLiteral(value) {
5
+ return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
6
+ }
7
+
8
+ function stringifyCode(code) {
9
+ return JSON.stringify(code);
10
+ }
11
+
12
+ function isActionCall(value) {
13
+ const callMatch = value.trim().match(/^([A-Za-z_$][\w$]*)\s*\((.*)\)$/s);
14
+ return callMatch ? { name: callMatch[1], args: callMatch[2] } : null;
15
+ }
16
+
17
+ function isSimpleActionName(value) {
18
+ return /^[A-Za-z_$][\w$]*$/.test(value.trim());
19
+ }
20
+
21
+ function compileActionAttribute(value, eventName, generatedActions) {
22
+ const trimmed = value.trim();
23
+ const actionCall = isActionCall(trimmed);
24
+
25
+ if (actionCall) {
26
+ return `data-${eventName}="${actionCall.name}" data-${eventName}-args='[${actionCall.args.trim()}]'`;
27
+ }
28
+
29
+ if (isSimpleActionName(trimmed)) {
30
+ return `data-${eventName}="${trimmed}"`;
31
+ }
32
+
33
+ const generatedName = `__inline_${generatedActions.length + 1}`;
34
+ generatedActions.push({
35
+ name: generatedName,
36
+ code: trimmed
37
+ });
38
+
39
+ return `data-${eventName}="${generatedName}"`;
40
+ }
41
+
42
+ export function parseHumanFile(source) {
43
+ const normalized = source.replace(/\r\n/g, '\n');
44
+ const sections = Object.fromEntries(SECTION_NAMES.map((name) => [name, '']));
45
+ const lines = normalized.split('\n');
46
+ let currentSection = null;
47
+ let foundSection = false;
48
+
49
+ lines.forEach((line) => {
50
+ const headerMatch = line.match(/^---([a-z]+)$/);
51
+
52
+ if (headerMatch) {
53
+ const [, name] = headerMatch;
54
+ if (!SECTION_NAMES.includes(name)) {
55
+ throw new Error(`[HumanJS] Unknown .human section: ${name}`);
56
+ }
57
+
58
+ currentSection = name;
59
+ foundSection = true;
60
+ return;
61
+ }
62
+
63
+ if (!currentSection) return;
64
+ sections[currentSection] += `${line}\n`;
65
+ });
66
+
67
+ if (!foundSection) {
68
+ throw new Error('[HumanJS] Invalid .human file. Use sections like ---state and ---template.');
69
+ }
70
+
71
+ Object.keys(sections).forEach((name) => {
72
+ sections[name] = sections[name].trim();
73
+ });
74
+
75
+ if (!sections.template) {
76
+ throw new Error('[HumanJS] .human files require a ---template section.');
77
+ }
78
+
79
+ return sections;
80
+ }
81
+
82
+ export function compileTemplate(template) {
83
+ const generatedActions = [];
84
+ let compiled = escapeTemplateLiteral(template.trim());
85
+
86
+ compiled = compiled.replace(
87
+ /\{([^{}]+)\}/g,
88
+ (_, expression) => `\${__humanExpr(state, actions, ${stringifyCode(expression.trim())})}`
89
+ );
90
+
91
+ EVENT_NAMES.forEach((eventName) => {
92
+ const pattern = new RegExp(`@${eventName}="([^"]+)"`, 'g');
93
+ compiled = compiled.replace(pattern, (_, value) => compileActionAttribute(value, eventName, generatedActions));
94
+ });
95
+
96
+ return { template: compiled, generatedActions };
97
+ }
98
+
99
+ export function humanExpr(state, actions, code) {
100
+ return new Function('state', 'actions', `with (state) { return (${code}); }`)(state, actions);
101
+ }
102
+
103
+ export function humanRun(state, actions, event, el, args, code) {
104
+ return new Function(
105
+ 'state',
106
+ 'actions',
107
+ 'event',
108
+ 'el',
109
+ 'args',
110
+ `with (state) { ${code}; }`
111
+ )(state, actions, event, el, args);
112
+ }
113
+
114
+ export function createRuntimeTemplate(template) {
115
+ const { template: compiledTemplate, generatedActions } = compileTemplate(template);
116
+
117
+ return {
118
+ render({ html, state, actions }) {
119
+ return new Function(
120
+ 'html',
121
+ 'state',
122
+ 'actions',
123
+ '__humanExpr',
124
+ `return html\`${compiledTemplate}\`;`
125
+ )(html, state, actions, humanExpr);
126
+ },
127
+ actions: Object.fromEntries(
128
+ generatedActions.map(({ name, code }) => [
129
+ name,
130
+ ({ state, actions, event, el, args }) => humanRun(state, actions, event, el, args, code)
131
+ ])
132
+ )
133
+ };
134
+ }
135
+
136
+ export function compileHuman(source, options = {}) {
137
+ const sections = parseHumanFile(source);
138
+ const appImport = options.appImportPath || '../../src/index.js';
139
+ const imports = sections.imports ? `${sections.imports}\n\n` : '';
140
+ const state = sections.state || '{}';
141
+ const derived = sections.derived || '{}';
142
+ const rawActions = sections.actions.trim();
143
+ const actions = rawActions || '{}';
144
+ const hooks = sections.hooks || '';
145
+ const { template, generatedActions } = compileTemplate(sections.template);
146
+ const hasCustomActions = rawActions && rawActions !== '{}';
147
+ const mergedActions = generatedActions.length
148
+ ? `{
149
+ ${generatedActions.map(({ name, code }) => `
150
+ ${name}({ state, actions, event, el, args }) {
151
+ return __humanRun(state, actions, event, el, args, ${stringifyCode(code)});
152
+ }`).join(',\n')}${hasCustomActions ? ',\n' : ''}
153
+ ${hasCustomActions ? rawActions.slice(1, -1).trim() : ''}
154
+ }`
155
+ : actions;
156
+
157
+ return `${imports}import { app, html } from '${appImport}';
158
+
159
+ const __humanExpr = ${humanExpr.toString()};
160
+
161
+ const __humanRun = ${humanRun.toString()};
162
+
163
+ app.human({
164
+ state: ${state},
165
+ derived: ${derived},
166
+ actions: ${mergedActions},
167
+ render: ({ state, actions }) => html\`
168
+ ${template}
169
+ \`${hooks ? `,\n${hooks}` : ''}
170
+ });
171
+ `;
172
+ }
173
+
174
+ export async function loadHuman(url, options = {}) {
175
+ const response = await fetch(url);
176
+
177
+ if (!response.ok) {
178
+ throw new Error(`[HumanJS] Failed to load ${url}: ${response.status}`);
179
+ }
180
+
181
+ const source = await response.text();
182
+ const compiled = compileHuman(source, {
183
+ appImportPath: options.appImportPath || new URL('../index.js', import.meta.url).href
184
+ });
185
+
186
+ const blob = new Blob([compiled], { type: 'text/javascript' });
187
+ const blobUrl = URL.createObjectURL(blob);
188
+
189
+ try {
190
+ return await import(blobUrl);
191
+ } finally {
192
+ URL.revokeObjectURL(blobUrl);
193
+ }
194
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * COMPONENT SYSTEM
3
+ *
4
+ * Create reactive components with state, lifecycle hooks, and auto re-rendering.
5
+ */
6
+
7
+ import { createState } from './state.js';
8
+ import { html } from './render.js';
9
+ import { attachEvents } from './events.js';
10
+ import { createRuntimeTemplate } from '../compiler/human.js';
11
+
12
+ const ACTION_EVENTS = [
13
+ 'click',
14
+ 'submit',
15
+ 'input',
16
+ 'change',
17
+ 'keydown',
18
+ 'keyup',
19
+ 'focus',
20
+ 'blur'
21
+ ];
22
+
23
+ /**
24
+ * Create a reactive component
25
+ * @param {HTMLElement} rootElement - Where to mount the component
26
+ * @param {Function} renderFn - Function that returns { element, events }
27
+ * @param {Object} initialState - Initial state
28
+ * @param {Object} lifecycle - { onMount, onUpdate, onDestroy }
29
+ */
30
+ export function createComponent(rootElement, renderFn, initialState = {}, initialLifecycle = {}) {
31
+ let currentElement = null;
32
+ let cleanupEvents = null;
33
+ let cleanupRender = null;
34
+ let isDestroyed = false;
35
+
36
+ // Create reactive state FIRST
37
+ const componentState = createState(initialState);
38
+
39
+ // Render function
40
+ function render() {
41
+ if (isDestroyed) return;
42
+ const previousCleanupRender = cleanupRender;
43
+
44
+ // Call render function with state
45
+ const result = renderFn(componentState);
46
+
47
+ // Extract element and events
48
+ let newElement, events;
49
+ if (result && typeof result === 'object' && result.element) {
50
+ newElement = result.element;
51
+ events = result.events || {};
52
+ cleanupRender = typeof result.cleanup === 'function' ? result.cleanup : null;
53
+ } else {
54
+ newElement = result;
55
+ events = {};
56
+ cleanupRender = null;
57
+ }
58
+
59
+ // First render - mount
60
+ if (!currentElement) {
61
+ currentElement = newElement;
62
+ rootElement.appendChild(currentElement);
63
+
64
+ // Attach events
65
+ cleanupEvents = attachEvents(currentElement, events);
66
+
67
+ // Call onMount
68
+ if (initialLifecycle.onMount) {
69
+ initialLifecycle.onMount(componentState);
70
+ }
71
+ }
72
+ // Subsequent renders - replace
73
+ else {
74
+ // Cleanup old events
75
+ if (cleanupEvents) cleanupEvents();
76
+ if (previousCleanupRender) previousCleanupRender();
77
+
78
+ // Replace element
79
+ rootElement.replaceChild(newElement, currentElement);
80
+ currentElement = newElement;
81
+
82
+ // Attach new events
83
+ cleanupEvents = attachEvents(currentElement, events);
84
+
85
+ // Call onUpdate
86
+ if (initialLifecycle.onUpdate) {
87
+ initialLifecycle.onUpdate(componentState);
88
+ }
89
+ }
90
+ }
91
+
92
+ // Watch for state changes and re-render
93
+ componentState.$watch = new Proxy(componentState.$watch || (() => {}), {
94
+ apply(target, thisArg, args) {
95
+ const result = target.apply(thisArg, args);
96
+ return result;
97
+ }
98
+ });
99
+
100
+ // Override state setter to trigger re-render
101
+ const originalState = { ...componentState.$raw() };
102
+ Object.keys(originalState).forEach(key => {
103
+ let value = componentState[key];
104
+ Object.defineProperty(componentState, key, {
105
+ get() {
106
+ return value;
107
+ },
108
+ set(newValue) {
109
+ if (value !== newValue) {
110
+ value = newValue;
111
+ render();
112
+ }
113
+ },
114
+ enumerable: true,
115
+ configurable: true
116
+ });
117
+ });
118
+
119
+ // Destroy function
120
+ function destroy() {
121
+ if (isDestroyed) return;
122
+
123
+ isDestroyed = true;
124
+
125
+ // Cleanup events
126
+ if (cleanupEvents) cleanupEvents();
127
+ if (cleanupRender) cleanupRender();
128
+
129
+ // Call onDestroy
130
+ if (initialLifecycle.onDestroy) {
131
+ initialLifecycle.onDestroy(componentState);
132
+ }
133
+
134
+ // Remove from DOM
135
+ if (currentElement && currentElement.parentNode) {
136
+ currentElement.parentNode.removeChild(currentElement);
137
+ }
138
+
139
+ currentElement = null;
140
+ }
141
+
142
+ // Initial render
143
+ render();
144
+
145
+ // Return component API
146
+ return {
147
+ state: componentState,
148
+ destroy,
149
+ render,
150
+ get element() {
151
+ return currentElement;
152
+ }
153
+ };
154
+ }
155
+
156
+ function parseActionArgs(rawValue) {
157
+ if (!rawValue) return [];
158
+
159
+ try {
160
+ const parsed = JSON.parse(rawValue);
161
+ return Array.isArray(parsed) ? parsed : [parsed];
162
+ } catch {
163
+ return [rawValue];
164
+ }
165
+ }
166
+
167
+ function createActionBindings(rootElement, actions, getContext) {
168
+ const cleanups = [];
169
+
170
+ ACTION_EVENTS.forEach((eventName) => {
171
+ const attrName = `data-${eventName}`;
172
+ const listener = (event) => {
173
+ const target = event.target.closest(`[${attrName}]`);
174
+ if (!target || !rootElement.contains(target)) return;
175
+
176
+ const actionName = target.getAttribute(attrName);
177
+ const action = actions[actionName];
178
+
179
+ if (typeof action !== 'function') {
180
+ console.warn(`[HumanJS] Unknown action: ${actionName}`);
181
+ return;
182
+ }
183
+
184
+ const args = parseActionArgs(target.getAttribute(`${attrName}-args`));
185
+ const context = getContext(target, event);
186
+
187
+ action({ ...context, args });
188
+ };
189
+
190
+ rootElement.addEventListener(eventName, listener);
191
+ cleanups.push(() => rootElement.removeEventListener(eventName, listener));
192
+ });
193
+
194
+ return () => cleanups.forEach((cleanup) => cleanup());
195
+ }
196
+
197
+ export function createApp(options = {}) {
198
+ const {
199
+ root = document.getElementById('app'),
200
+ state: initialState = {},
201
+ derived = {},
202
+ actions: actionDefinitions = {},
203
+ render,
204
+ onMount,
205
+ onUpdate,
206
+ onDestroy
207
+ } = options;
208
+
209
+ if (!render) {
210
+ throw new Error('[HumanJS] render function is required');
211
+ }
212
+
213
+ if (!root) {
214
+ throw new Error('[HumanJS] root element is required');
215
+ }
216
+
217
+ let instance = null;
218
+ let derivedReady = false;
219
+ const boundActions = Object.fromEntries(
220
+ Object.entries(actionDefinitions).map(([name, action]) => [
221
+ name,
222
+ (...args) => action({
223
+ state: instance.state,
224
+ actions: boundActions,
225
+ root,
226
+ render: () => instance.render(),
227
+ args
228
+ })
229
+ ])
230
+ );
231
+
232
+ const lifecycle = {
233
+ onMount(state) {
234
+ if (onMount) {
235
+ onMount({ state, actions: boundActions, root });
236
+ }
237
+ },
238
+ onUpdate(state) {
239
+ if (onUpdate) {
240
+ onUpdate({ state, actions: boundActions, root });
241
+ }
242
+ },
243
+ onDestroy(state) {
244
+ if (onDestroy) {
245
+ onDestroy({ state, actions: boundActions, root });
246
+ }
247
+ }
248
+ };
249
+
250
+ instance = createComponent(
251
+ root,
252
+ (state) => {
253
+ if (!derivedReady) {
254
+ Object.entries(derived).forEach(([key, compute]) => {
255
+ if (typeof compute === 'function') {
256
+ state.$computed(key, () => compute(state, boundActions));
257
+ }
258
+ });
259
+ derivedReady = true;
260
+ }
261
+
262
+ const viewContext = { state, actions: boundActions, root };
263
+ const result = render(viewContext);
264
+
265
+ let element;
266
+ let events = {};
267
+
268
+ if (result && typeof result === 'object' && result.element) {
269
+ element = result.element;
270
+ events = result.events || {};
271
+ } else {
272
+ element = result;
273
+ }
274
+
275
+ const cleanupActions = createActionBindings(element, boundActions, (target, event) => ({
276
+ state,
277
+ actions: boundActions,
278
+ root,
279
+ event,
280
+ el: target,
281
+ render: () => instance.render()
282
+ }));
283
+
284
+ const cleanupEvents = attachEvents(element, events);
285
+
286
+ return {
287
+ element,
288
+ events: {},
289
+ cleanup() {
290
+ cleanupActions();
291
+ cleanupEvents();
292
+ }
293
+ };
294
+ },
295
+ initialState,
296
+ lifecycle
297
+ );
298
+
299
+ return {
300
+ ...instance,
301
+ actions: boundActions
302
+ };
303
+ }
304
+
305
+ export function createSimpleApp(options = {}) {
306
+ const {
307
+ template,
308
+ actions = {},
309
+ ...rest
310
+ } = options;
311
+
312
+ if (!template) {
313
+ throw new Error('[HumanJS] template is required');
314
+ }
315
+
316
+ const runtime = createRuntimeTemplate(template);
317
+
318
+ return createApp({
319
+ ...rest,
320
+ actions: {
321
+ ...runtime.actions,
322
+ ...actions
323
+ },
324
+ render: ({ state, actions, root }) => runtime.render({ html, state, actions, root })
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Higher-level app API for cleaner component creation
330
+ */
331
+ export const app = {
332
+ /**
333
+ * Create a component with options object
334
+ */
335
+ create(options = {}) {
336
+ const {
337
+ root = document.getElementById('app'),
338
+ state: initialState = {},
339
+ render: renderFn,
340
+ onMount,
341
+ onUpdate,
342
+ onDestroy
343
+ } = options;
344
+
345
+ if (!renderFn) {
346
+ throw new Error('[HumanJS] render function is required');
347
+ }
348
+
349
+ return createComponent(
350
+ root,
351
+ renderFn,
352
+ initialState,
353
+ { onMount, onUpdate, onDestroy }
354
+ );
355
+ },
356
+
357
+ human(options = {}) {
358
+ return createApp(options);
359
+ },
360
+
361
+ simple(options = {}) {
362
+ return createSimpleApp(options);
363
+ },
364
+
365
+ /**
366
+ * Quick mount without state management
367
+ */
368
+ mount(rootOrFn, renderFn) {
369
+ let root, render;
370
+
371
+ if (typeof rootOrFn === 'function') {
372
+ root = document.getElementById('app');
373
+ render = rootOrFn;
374
+ } else {
375
+ root = rootOrFn;
376
+ render = renderFn;
377
+ }
378
+
379
+ return createComponent(root, () => render(), {});
380
+ }
381
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * EVENT HANDLING SYSTEM
3
+ *
4
+ * Attach and manage DOM event listeners.
5
+ * Supports event delegation and automatic cleanup.
6
+ */
7
+
8
+ /**
9
+ * Attach events to DOM elements
10
+ * @param {HTMLElement} rootElement
11
+ * @param {Object} eventMap - { selector: { eventType: handler } }
12
+ */
13
+ export function attachEvents(rootElement, eventMap = {}) {
14
+ const attachedListeners = [];
15
+
16
+ Object.keys(eventMap).forEach(selector => {
17
+ const events = eventMap[selector];
18
+
19
+ // Find target elements
20
+ let targetElements;
21
+ if (selector === 'root' || selector === ':root') {
22
+ targetElements = [rootElement];
23
+ } else {
24
+ targetElements = [...rootElement.querySelectorAll(selector)];
25
+ }
26
+
27
+ if (!targetElements || targetElements.length === 0) {
28
+ console.warn(`[HumanJS] Element not found: ${selector}`);
29
+ return;
30
+ }
31
+
32
+ targetElements.forEach((targetElement) => {
33
+ Object.keys(events).forEach(eventType => {
34
+ const handler = events[eventType];
35
+ targetElement.addEventListener(eventType, handler);
36
+
37
+ attachedListeners.push({
38
+ element: targetElement,
39
+ type: eventType,
40
+ handler
41
+ });
42
+ });
43
+ });
44
+ });
45
+
46
+ // Return cleanup function
47
+ return () => {
48
+ attachedListeners.forEach(({ element, type, handler }) => {
49
+ element.removeEventListener(type, handler);
50
+ });
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Event delegation - attach one listener to parent
56
+ * @param {HTMLElement} parent
57
+ * @param {String} eventType
58
+ * @param {String} selector
59
+ * @param {Function} handler
60
+ */
61
+ export function delegate(parent, eventType, selector, handler) {
62
+ const listener = (event) => {
63
+ const target = event.target.closest(selector);
64
+ if (target && parent.contains(target)) {
65
+ handler.call(target, event);
66
+ }
67
+ };
68
+
69
+ parent.addEventListener(eventType, listener);
70
+
71
+ return () => parent.removeEventListener(eventType, listener);
72
+ }
73
+
74
+ /**
75
+ * Debounce function calls
76
+ * @param {Function} fn
77
+ * @param {Number} delay
78
+ */
79
+ export function debounce(fn, delay = 300) {
80
+ let timeoutId;
81
+ return function(...args) {
82
+ clearTimeout(timeoutId);
83
+ timeoutId = setTimeout(() => fn.apply(this, args), delay);
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Throttle function calls
89
+ * @param {Function} fn
90
+ * @param {Number} limit
91
+ */
92
+ export function throttle(fn, limit = 300) {
93
+ let inThrottle;
94
+ return function(...args) {
95
+ if (!inThrottle) {
96
+ fn.apply(this, args);
97
+ inThrottle = true;
98
+ setTimeout(() => inThrottle = false, limit);
99
+ }
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Common event helpers
105
+ */
106
+ export const on = {
107
+ click: (selector, handler) => ({ [selector]: { click: handler } }),
108
+ input: (selector, handler) => ({ [selector]: { input: handler } }),
109
+ submit: (selector, handler) => ({
110
+ [selector]: {
111
+ submit: (e) => {
112
+ e.preventDefault();
113
+ handler(e);
114
+ }
115
+ }
116
+ }),
117
+ change: (selector, handler) => ({ [selector]: { change: handler } }),
118
+ focus: (selector, handler) => ({ [selector]: { focus: handler } }),
119
+ blur: (selector, handler) => ({ [selector]: { blur: handler } }),
120
+ keydown: (selector, handler) => ({ [selector]: { keydown: handler } }),
121
+ keyup: (selector, handler) => ({ [selector]: { keyup: handler } })
122
+ };
123
+
124
+ /**
125
+ * Merge multiple event maps
126
+ * @param {...Object} eventMaps
127
+ */
128
+ export function mergeEvents(...eventMaps) {
129
+ return Object.assign({}, ...eventMaps);
130
+ }