pulse-js-framework 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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/cli/build.js +199 -0
- package/cli/dev.js +225 -0
- package/cli/index.js +324 -0
- package/compiler/index.js +65 -0
- package/compiler/lexer.js +581 -0
- package/compiler/parser.js +900 -0
- package/compiler/transformer.js +552 -0
- package/index.js +19 -0
- package/loader/vite-plugin.js +160 -0
- package/package.json +58 -0
- package/runtime/dom.js +484 -0
- package/runtime/index.js +13 -0
- package/runtime/pulse.js +339 -0
- package/runtime/router.js +392 -0
- package/runtime/store.js +301 -0
package/runtime/pulse.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse - Reactive Primitives
|
|
3
|
+
*
|
|
4
|
+
* The core reactivity system based on "pulsations" -
|
|
5
|
+
* reactive values that propagate changes through the system.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Current tracking context for automatic dependency collection
|
|
9
|
+
let currentEffect = null;
|
|
10
|
+
let batchDepth = 0;
|
|
11
|
+
let pendingEffects = new Set();
|
|
12
|
+
let isRunningEffects = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pulse - A reactive value container
|
|
16
|
+
* When the value changes, it "pulses" to all its dependents
|
|
17
|
+
*/
|
|
18
|
+
export class Pulse {
|
|
19
|
+
#value;
|
|
20
|
+
#subscribers = new Set();
|
|
21
|
+
#equals;
|
|
22
|
+
|
|
23
|
+
constructor(value, options = {}) {
|
|
24
|
+
this.#value = value;
|
|
25
|
+
this.#equals = options.equals ?? Object.is;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the current value and track dependency if in an effect context
|
|
30
|
+
*/
|
|
31
|
+
get() {
|
|
32
|
+
if (currentEffect) {
|
|
33
|
+
this.#subscribers.add(currentEffect);
|
|
34
|
+
currentEffect.dependencies.add(this);
|
|
35
|
+
}
|
|
36
|
+
return this.#value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set a new value and notify all subscribers
|
|
41
|
+
*/
|
|
42
|
+
set(newValue) {
|
|
43
|
+
if (this.#equals(this.#value, newValue)) return;
|
|
44
|
+
this.#value = newValue;
|
|
45
|
+
this.#notify();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Update value using a function
|
|
50
|
+
*/
|
|
51
|
+
update(fn) {
|
|
52
|
+
this.set(fn(this.#value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to changes
|
|
57
|
+
*/
|
|
58
|
+
subscribe(fn) {
|
|
59
|
+
const subscriber = { run: fn, dependencies: new Set() };
|
|
60
|
+
this.#subscribers.add(subscriber);
|
|
61
|
+
return () => this.#subscribers.delete(subscriber);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a derived pulse that recomputes when this changes
|
|
66
|
+
*/
|
|
67
|
+
derive(fn) {
|
|
68
|
+
return computed(() => fn(this.get()));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Notify all subscribers of a change
|
|
73
|
+
*/
|
|
74
|
+
#notify() {
|
|
75
|
+
// Copy subscribers to avoid mutation during iteration
|
|
76
|
+
const subs = [...this.#subscribers];
|
|
77
|
+
|
|
78
|
+
for (const subscriber of subs) {
|
|
79
|
+
if (batchDepth > 0 || isRunningEffects) {
|
|
80
|
+
pendingEffects.add(subscriber);
|
|
81
|
+
} else {
|
|
82
|
+
runEffect(subscriber);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Unsubscribe a specific subscriber
|
|
89
|
+
*/
|
|
90
|
+
_unsubscribe(subscriber) {
|
|
91
|
+
this.#subscribers.delete(subscriber);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get value without tracking (for debugging/inspection)
|
|
96
|
+
*/
|
|
97
|
+
peek() {
|
|
98
|
+
return this.#value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Initialize value without triggering notifications (internal use)
|
|
103
|
+
*/
|
|
104
|
+
_init(value) {
|
|
105
|
+
this.#value = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run a single effect safely
|
|
111
|
+
*/
|
|
112
|
+
function runEffect(effectFn) {
|
|
113
|
+
if (!effectFn || !effectFn.run) return;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
effectFn.run();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Effect error:', error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Flush all pending effects
|
|
124
|
+
*/
|
|
125
|
+
function flushEffects() {
|
|
126
|
+
if (isRunningEffects) return;
|
|
127
|
+
|
|
128
|
+
isRunningEffects = true;
|
|
129
|
+
let iterations = 0;
|
|
130
|
+
const maxIterations = 100; // Prevent infinite loops
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
while (pendingEffects.size > 0 && iterations < maxIterations) {
|
|
134
|
+
iterations++;
|
|
135
|
+
const effects = [...pendingEffects];
|
|
136
|
+
pendingEffects.clear();
|
|
137
|
+
|
|
138
|
+
for (const effect of effects) {
|
|
139
|
+
runEffect(effect);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (iterations >= maxIterations) {
|
|
144
|
+
console.warn('Pulse: Maximum effect iterations reached. Possible infinite loop.');
|
|
145
|
+
pendingEffects.clear();
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
isRunningEffects = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a simple pulse with an initial value
|
|
154
|
+
*/
|
|
155
|
+
export function pulse(value, options) {
|
|
156
|
+
return new Pulse(value, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a computed pulse that automatically updates
|
|
161
|
+
* when its dependencies change
|
|
162
|
+
*/
|
|
163
|
+
export function computed(fn) {
|
|
164
|
+
const p = new Pulse(undefined);
|
|
165
|
+
let initialized = false;
|
|
166
|
+
|
|
167
|
+
effect(() => {
|
|
168
|
+
const newValue = fn();
|
|
169
|
+
if (initialized) {
|
|
170
|
+
p._init(newValue); // Use _init to avoid triggering notifications during compute
|
|
171
|
+
} else {
|
|
172
|
+
p._init(newValue);
|
|
173
|
+
initialized = true;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Override set to make it read-only
|
|
178
|
+
p.set = () => {
|
|
179
|
+
throw new Error('Cannot set a computed pulse directly');
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return p;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create an effect that runs when its dependencies change
|
|
187
|
+
*/
|
|
188
|
+
export function effect(fn) {
|
|
189
|
+
const effectFn = {
|
|
190
|
+
run: () => {
|
|
191
|
+
// Clean up old dependencies
|
|
192
|
+
for (const dep of effectFn.dependencies) {
|
|
193
|
+
dep._unsubscribe(effectFn);
|
|
194
|
+
}
|
|
195
|
+
effectFn.dependencies.clear();
|
|
196
|
+
|
|
197
|
+
// Set as current effect for dependency tracking
|
|
198
|
+
const prevEffect = currentEffect;
|
|
199
|
+
currentEffect = effectFn;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
fn();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error('Effect execution error:', error);
|
|
205
|
+
} finally {
|
|
206
|
+
currentEffect = prevEffect;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
dependencies: new Set()
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Run immediately to collect dependencies
|
|
213
|
+
effectFn.run();
|
|
214
|
+
|
|
215
|
+
// Return cleanup function
|
|
216
|
+
return () => {
|
|
217
|
+
for (const dep of effectFn.dependencies) {
|
|
218
|
+
dep._unsubscribe(effectFn);
|
|
219
|
+
}
|
|
220
|
+
effectFn.dependencies.clear();
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Batch multiple updates into one
|
|
226
|
+
* Effects only run after all updates complete
|
|
227
|
+
*/
|
|
228
|
+
export function batch(fn) {
|
|
229
|
+
batchDepth++;
|
|
230
|
+
try {
|
|
231
|
+
fn();
|
|
232
|
+
} finally {
|
|
233
|
+
batchDepth--;
|
|
234
|
+
if (batchDepth === 0) {
|
|
235
|
+
flushEffects();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a reactive state object from a plain object
|
|
242
|
+
* Each property becomes a pulse
|
|
243
|
+
*/
|
|
244
|
+
export function createState(obj) {
|
|
245
|
+
const state = {};
|
|
246
|
+
const pulses = {};
|
|
247
|
+
|
|
248
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
249
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
250
|
+
// Recursively create state for nested objects
|
|
251
|
+
state[key] = createState(value);
|
|
252
|
+
} else {
|
|
253
|
+
pulses[key] = new Pulse(value);
|
|
254
|
+
|
|
255
|
+
Object.defineProperty(state, key, {
|
|
256
|
+
get() {
|
|
257
|
+
return pulses[key].get();
|
|
258
|
+
},
|
|
259
|
+
set(newValue) {
|
|
260
|
+
pulses[key].set(newValue);
|
|
261
|
+
},
|
|
262
|
+
enumerable: true
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Expose the raw pulses for advanced use
|
|
268
|
+
state.$pulses = pulses;
|
|
269
|
+
|
|
270
|
+
// Helper to get a pulse by key
|
|
271
|
+
state.$pulse = (key) => pulses[key];
|
|
272
|
+
|
|
273
|
+
return state;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Watch specific pulses and run a callback when they change
|
|
278
|
+
*/
|
|
279
|
+
export function watch(sources, callback) {
|
|
280
|
+
const sourcesArray = Array.isArray(sources) ? sources : [sources];
|
|
281
|
+
|
|
282
|
+
let oldValues = sourcesArray.map(s => s.peek());
|
|
283
|
+
|
|
284
|
+
return effect(() => {
|
|
285
|
+
const newValues = sourcesArray.map(s => s.get());
|
|
286
|
+
callback(newValues, oldValues);
|
|
287
|
+
oldValues = newValues;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Create a pulse from a promise
|
|
293
|
+
*/
|
|
294
|
+
export function fromPromise(promise, initialValue = undefined) {
|
|
295
|
+
const p = pulse(initialValue);
|
|
296
|
+
const loading = pulse(true);
|
|
297
|
+
const error = pulse(null);
|
|
298
|
+
|
|
299
|
+
promise
|
|
300
|
+
.then(value => {
|
|
301
|
+
batch(() => {
|
|
302
|
+
p.set(value);
|
|
303
|
+
loading.set(false);
|
|
304
|
+
});
|
|
305
|
+
})
|
|
306
|
+
.catch(err => {
|
|
307
|
+
batch(() => {
|
|
308
|
+
error.set(err);
|
|
309
|
+
loading.set(false);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return { value: p, loading, error };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Untrack - read pulses without creating dependencies
|
|
318
|
+
*/
|
|
319
|
+
export function untrack(fn) {
|
|
320
|
+
const prevEffect = currentEffect;
|
|
321
|
+
currentEffect = null;
|
|
322
|
+
try {
|
|
323
|
+
return fn();
|
|
324
|
+
} finally {
|
|
325
|
+
currentEffect = prevEffect;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export default {
|
|
330
|
+
Pulse,
|
|
331
|
+
pulse,
|
|
332
|
+
computed,
|
|
333
|
+
effect,
|
|
334
|
+
batch,
|
|
335
|
+
createState,
|
|
336
|
+
watch,
|
|
337
|
+
fromPromise,
|
|
338
|
+
untrack
|
|
339
|
+
};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Router - SPA routing system
|
|
3
|
+
*
|
|
4
|
+
* A simple but powerful router that integrates with Pulse reactivity
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { pulse, effect, batch } from './pulse.js';
|
|
8
|
+
import { el, mount } from './dom.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a route pattern into a regex and extract param names
|
|
12
|
+
* Supports: /users/:id, /posts/:id/comments, /files/*path
|
|
13
|
+
*/
|
|
14
|
+
function parsePattern(pattern) {
|
|
15
|
+
const paramNames = [];
|
|
16
|
+
let regexStr = pattern
|
|
17
|
+
// Escape special regex chars except : and *
|
|
18
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
19
|
+
// Handle wildcard params (*name)
|
|
20
|
+
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
21
|
+
paramNames.push(name);
|
|
22
|
+
return '(.*)';
|
|
23
|
+
})
|
|
24
|
+
// Handle named params (:name)
|
|
25
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
26
|
+
paramNames.push(name);
|
|
27
|
+
return '([^/]+)';
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Ensure exact match
|
|
31
|
+
regexStr = `^${regexStr}$`;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
regex: new RegExp(regexStr),
|
|
35
|
+
paramNames
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Match a path against a route pattern
|
|
41
|
+
*/
|
|
42
|
+
function matchRoute(pattern, path) {
|
|
43
|
+
const { regex, paramNames } = parsePattern(pattern);
|
|
44
|
+
const match = path.match(regex);
|
|
45
|
+
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
|
|
48
|
+
const params = {};
|
|
49
|
+
paramNames.forEach((name, i) => {
|
|
50
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return params;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse query string into object
|
|
58
|
+
*/
|
|
59
|
+
function parseQuery(search) {
|
|
60
|
+
const params = new URLSearchParams(search);
|
|
61
|
+
const query = {};
|
|
62
|
+
for (const [key, value] of params) {
|
|
63
|
+
if (key in query) {
|
|
64
|
+
// Multiple values for same key
|
|
65
|
+
if (Array.isArray(query[key])) {
|
|
66
|
+
query[key].push(value);
|
|
67
|
+
} else {
|
|
68
|
+
query[key] = [query[key], value];
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
query[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return query;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a router instance
|
|
79
|
+
*/
|
|
80
|
+
export function createRouter(options = {}) {
|
|
81
|
+
const {
|
|
82
|
+
routes = {},
|
|
83
|
+
mode = 'history', // 'history' or 'hash'
|
|
84
|
+
base = ''
|
|
85
|
+
} = options;
|
|
86
|
+
|
|
87
|
+
// Reactive state
|
|
88
|
+
const currentPath = pulse(getPath());
|
|
89
|
+
const currentRoute = pulse(null);
|
|
90
|
+
const currentParams = pulse({});
|
|
91
|
+
const currentQuery = pulse({});
|
|
92
|
+
const isLoading = pulse(false);
|
|
93
|
+
|
|
94
|
+
// Compiled routes
|
|
95
|
+
const compiledRoutes = [];
|
|
96
|
+
for (const [pattern, handler] of Object.entries(routes)) {
|
|
97
|
+
compiledRoutes.push({
|
|
98
|
+
pattern,
|
|
99
|
+
...parsePattern(pattern),
|
|
100
|
+
handler
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Hooks
|
|
105
|
+
const beforeHooks = [];
|
|
106
|
+
const afterHooks = [];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get current path based on mode
|
|
110
|
+
*/
|
|
111
|
+
function getPath() {
|
|
112
|
+
if (mode === 'hash') {
|
|
113
|
+
return window.location.hash.slice(1) || '/';
|
|
114
|
+
}
|
|
115
|
+
let path = window.location.pathname;
|
|
116
|
+
if (base && path.startsWith(base)) {
|
|
117
|
+
path = path.slice(base.length) || '/';
|
|
118
|
+
}
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find matching route
|
|
124
|
+
*/
|
|
125
|
+
function findRoute(path) {
|
|
126
|
+
for (const route of compiledRoutes) {
|
|
127
|
+
const params = matchRoute(route.pattern, path);
|
|
128
|
+
if (params !== null) {
|
|
129
|
+
return { route, params };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Navigate to a path
|
|
137
|
+
*/
|
|
138
|
+
async function navigate(path, options = {}) {
|
|
139
|
+
const { replace = false, query = {} } = options;
|
|
140
|
+
|
|
141
|
+
// Build full path with query
|
|
142
|
+
let fullPath = path;
|
|
143
|
+
const queryString = new URLSearchParams(query).toString();
|
|
144
|
+
if (queryString) {
|
|
145
|
+
fullPath += '?' + queryString;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Run before hooks
|
|
149
|
+
for (const hook of beforeHooks) {
|
|
150
|
+
const result = await hook(path, currentPath.peek());
|
|
151
|
+
if (result === false) return false;
|
|
152
|
+
if (typeof result === 'string') {
|
|
153
|
+
return navigate(result, options);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Update URL
|
|
158
|
+
const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
|
|
159
|
+
if (replace) {
|
|
160
|
+
window.history.replaceState(null, '', url);
|
|
161
|
+
} else {
|
|
162
|
+
window.history.pushState(null, '', url);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update reactive state
|
|
166
|
+
await updateRoute(path, parseQuery(queryString));
|
|
167
|
+
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update the current route state
|
|
173
|
+
*/
|
|
174
|
+
async function updateRoute(path, query = {}) {
|
|
175
|
+
const match = findRoute(path);
|
|
176
|
+
|
|
177
|
+
batch(() => {
|
|
178
|
+
currentPath.set(path);
|
|
179
|
+
currentQuery.set(query);
|
|
180
|
+
|
|
181
|
+
if (match) {
|
|
182
|
+
currentRoute.set(match.route);
|
|
183
|
+
currentParams.set(match.params);
|
|
184
|
+
} else {
|
|
185
|
+
currentRoute.set(null);
|
|
186
|
+
currentParams.set({});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Run after hooks
|
|
191
|
+
for (const hook of afterHooks) {
|
|
192
|
+
await hook(path);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Handle browser navigation (back/forward)
|
|
198
|
+
*/
|
|
199
|
+
function handlePopState() {
|
|
200
|
+
const path = getPath();
|
|
201
|
+
const query = parseQuery(window.location.search);
|
|
202
|
+
updateRoute(path, query);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Start listening to navigation events
|
|
207
|
+
*/
|
|
208
|
+
function start() {
|
|
209
|
+
window.addEventListener('popstate', handlePopState);
|
|
210
|
+
|
|
211
|
+
// Initial route
|
|
212
|
+
const query = parseQuery(window.location.search);
|
|
213
|
+
updateRoute(getPath(), query);
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
window.removeEventListener('popstate', handlePopState);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create a link element that uses the router
|
|
222
|
+
*/
|
|
223
|
+
function link(path, content, options = {}) {
|
|
224
|
+
const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
|
|
225
|
+
const a = el('a', content);
|
|
226
|
+
a.href = href;
|
|
227
|
+
|
|
228
|
+
a.addEventListener('click', (e) => {
|
|
229
|
+
// Allow ctrl/cmd+click for new tab
|
|
230
|
+
if (e.ctrlKey || e.metaKey) return;
|
|
231
|
+
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
navigate(path, options);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Add active class when route matches
|
|
237
|
+
effect(() => {
|
|
238
|
+
const current = currentPath.get();
|
|
239
|
+
if (current === path || (options.exact === false && current.startsWith(path))) {
|
|
240
|
+
a.classList.add(options.activeClass || 'active');
|
|
241
|
+
} else {
|
|
242
|
+
a.classList.remove(options.activeClass || 'active');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return a;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Router outlet - renders the current route's component
|
|
251
|
+
*/
|
|
252
|
+
function outlet(container) {
|
|
253
|
+
if (typeof container === 'string') {
|
|
254
|
+
container = document.querySelector(container);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let currentView = null;
|
|
258
|
+
let cleanup = null;
|
|
259
|
+
|
|
260
|
+
effect(() => {
|
|
261
|
+
const route = currentRoute.get();
|
|
262
|
+
const params = currentParams.get();
|
|
263
|
+
const query = currentQuery.get();
|
|
264
|
+
|
|
265
|
+
// Cleanup previous view
|
|
266
|
+
if (cleanup) cleanup();
|
|
267
|
+
if (currentView) {
|
|
268
|
+
container.innerHTML = '';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (route && route.handler) {
|
|
272
|
+
// Create context for the route handler
|
|
273
|
+
const ctx = {
|
|
274
|
+
params,
|
|
275
|
+
query,
|
|
276
|
+
path: currentPath.peek(),
|
|
277
|
+
navigate,
|
|
278
|
+
router
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Call handler and render result
|
|
282
|
+
const result = typeof route.handler === 'function'
|
|
283
|
+
? route.handler(ctx)
|
|
284
|
+
: route.handler;
|
|
285
|
+
|
|
286
|
+
if (result instanceof Node) {
|
|
287
|
+
container.appendChild(result);
|
|
288
|
+
currentView = result;
|
|
289
|
+
} else if (result && typeof result.then === 'function') {
|
|
290
|
+
// Async component
|
|
291
|
+
isLoading.set(true);
|
|
292
|
+
result.then(component => {
|
|
293
|
+
isLoading.set(false);
|
|
294
|
+
const view = typeof component === 'function' ? component(ctx) : component;
|
|
295
|
+
if (view instanceof Node) {
|
|
296
|
+
container.appendChild(view);
|
|
297
|
+
currentView = view;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return container;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Add navigation guard
|
|
309
|
+
*/
|
|
310
|
+
function beforeEach(hook) {
|
|
311
|
+
beforeHooks.push(hook);
|
|
312
|
+
return () => {
|
|
313
|
+
const index = beforeHooks.indexOf(hook);
|
|
314
|
+
if (index > -1) beforeHooks.splice(index, 1);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Add after navigation hook
|
|
320
|
+
*/
|
|
321
|
+
function afterEach(hook) {
|
|
322
|
+
afterHooks.push(hook);
|
|
323
|
+
return () => {
|
|
324
|
+
const index = afterHooks.indexOf(hook);
|
|
325
|
+
if (index > -1) afterHooks.splice(index, 1);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Go back in history
|
|
331
|
+
*/
|
|
332
|
+
function back() {
|
|
333
|
+
window.history.back();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Go forward in history
|
|
338
|
+
*/
|
|
339
|
+
function forward() {
|
|
340
|
+
window.history.forward();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Go to specific history entry
|
|
345
|
+
*/
|
|
346
|
+
function go(delta) {
|
|
347
|
+
window.history.go(delta);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const router = {
|
|
351
|
+
// Reactive state (read-only)
|
|
352
|
+
path: currentPath,
|
|
353
|
+
route: currentRoute,
|
|
354
|
+
params: currentParams,
|
|
355
|
+
query: currentQuery,
|
|
356
|
+
loading: isLoading,
|
|
357
|
+
|
|
358
|
+
// Methods
|
|
359
|
+
navigate,
|
|
360
|
+
start,
|
|
361
|
+
link,
|
|
362
|
+
outlet,
|
|
363
|
+
beforeEach,
|
|
364
|
+
afterEach,
|
|
365
|
+
back,
|
|
366
|
+
forward,
|
|
367
|
+
go,
|
|
368
|
+
|
|
369
|
+
// Utils
|
|
370
|
+
matchRoute,
|
|
371
|
+
parseQuery
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return router;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Create a simple router for quick setup
|
|
379
|
+
*/
|
|
380
|
+
export function simpleRouter(routes, target = '#app') {
|
|
381
|
+
const router = createRouter({ routes });
|
|
382
|
+
router.start();
|
|
383
|
+
router.outlet(target);
|
|
384
|
+
return router;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default {
|
|
388
|
+
createRouter,
|
|
389
|
+
simpleRouter,
|
|
390
|
+
matchRoute,
|
|
391
|
+
parseQuery
|
|
392
|
+
};
|