@vyckr/tachyon 1.1.11 → 1.2.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/.env.example +7 -4
- package/LICENSE +21 -0
- package/README.md +210 -90
- package/package.json +50 -33
- package/src/cli/bundle.ts +37 -0
- package/src/cli/serve.ts +100 -0
- package/src/{client/template.js → compiler/render-template.js} +10 -17
- package/src/compiler/template-compiler.ts +419 -0
- package/src/runtime/hot-reload-client.ts +15 -0
- package/src/{client/dev.html → runtime/shells/development.html} +2 -2
- package/src/runtime/shells/not-found.html +73 -0
- package/src/{client/prod.html → runtime/shells/production.html} +1 -1
- package/src/runtime/spa-renderer.ts +439 -0
- package/src/server/console-logger.ts +39 -0
- package/src/server/process-executor.ts +287 -0
- package/src/server/process-pool.ts +80 -0
- package/src/server/route-handler.ts +229 -0
- package/src/server/schema-validator.ts +161 -0
- package/bun.lock +0 -127
- package/components/clicker.html +0 -30
- package/deno.lock +0 -19
- package/go.mod +0 -3
- package/lib/gson-2.3.jar +0 -0
- package/main.js +0 -13
- package/routes/DELETE +0 -18
- package/routes/GET +0 -17
- package/routes/HTML +0 -135
- package/routes/POST +0 -32
- package/routes/SOCKET +0 -26
- package/routes/api/:version/DELETE +0 -10
- package/routes/api/:version/GET +0 -29
- package/routes/api/:version/PATCH +0 -24
- package/routes/api/GET +0 -29
- package/routes/api/POST +0 -16
- package/routes/api/PUT +0 -21
- package/src/client/404.html +0 -7
- package/src/client/dist.ts +0 -20
- package/src/client/hmr.ts +0 -12
- package/src/client/render.ts +0 -417
- package/src/client/routes.json +0 -1
- package/src/client/yon.ts +0 -364
- package/src/router.ts +0 -186
- package/src/serve.ts +0 -147
- package/src/server/logger.ts +0 -31
- package/src/server/tach.ts +0 -238
- package/tests/index.test.ts +0 -110
- package/tests/stream.ts +0 -24
- package/tests/worker.ts +0 -7
- package/tsconfig.json +0 -17
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
type RenderFn = (elementId?: string | null, eventDetail?: unknown) => Promise<string>;
|
|
2
|
+
|
|
3
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
4
|
+
let pageRender: RenderFn;
|
|
5
|
+
let layoutRender: RenderFn | null = null;
|
|
6
|
+
let currentLayoutPath: string | null = null;
|
|
7
|
+
let previousHTML = '';
|
|
8
|
+
let focusTarget: string | null = null;
|
|
9
|
+
let freshNavigation = false;
|
|
10
|
+
|
|
11
|
+
const routes = new Map<string, Record<string, number>>();
|
|
12
|
+
const layouts: Record<string, string> = {};
|
|
13
|
+
const slugs: Record<string, string> = {};
|
|
14
|
+
let params: (string | number | boolean | null | undefined)[] = [];
|
|
15
|
+
|
|
16
|
+
const parser = new DOMParser();
|
|
17
|
+
|
|
18
|
+
// ── Bootstrap ──────────────────────────────────────────────────────────────────
|
|
19
|
+
Promise.all([
|
|
20
|
+
fetch('/routes.json').then(r => r.json()),
|
|
21
|
+
fetch('/layouts.json').then(r => r.json()),
|
|
22
|
+
]).then(([routeData, layoutData]) => {
|
|
23
|
+
for (const [path, s] of Object.entries(routeData))
|
|
24
|
+
routes.set(path, s as Record<string, number>);
|
|
25
|
+
Object.assign(layouts, layoutData);
|
|
26
|
+
navigate(location.pathname);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── Event Delegation ───────────────────────────────────────────────────────────
|
|
30
|
+
// Single delegated listener at the document level instead of per-element binding.
|
|
31
|
+
// Handles both `@event` attribute actions and `:value` two-way binding.
|
|
32
|
+
document.addEventListener('click', (ev: MouseEvent) => {
|
|
33
|
+
// SPA link interception
|
|
34
|
+
const anchor = (ev.target as Element)?.closest('a[href]') as HTMLAnchorElement | null;
|
|
35
|
+
if (anchor) {
|
|
36
|
+
const url = new URL(anchor.href, location.origin);
|
|
37
|
+
if (url.origin === location.origin) {
|
|
38
|
+
ev.preventDefault();
|
|
39
|
+
navigate(url.pathname);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Delegated @click
|
|
45
|
+
const target = findEventTarget(ev.target as Element, 'click');
|
|
46
|
+
if (target) {
|
|
47
|
+
ev.preventDefault();
|
|
48
|
+
dispatchAction(target);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Value-change events (input, change, sl-input, sl-change)
|
|
53
|
+
for (const eventName of ['input', 'change', 'sl-input', 'sl-change'] as const) {
|
|
54
|
+
document.addEventListener(eventName, (ev: Event) => {
|
|
55
|
+
const el = ev.target as HTMLElement;
|
|
56
|
+
if (!el?.id || !el.hasAttribute('value')) return;
|
|
57
|
+
const value = (el as HTMLInputElement).value;
|
|
58
|
+
rerender(el.id, { value });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
window.addEventListener('popstate', () => navigate(location.pathname));
|
|
63
|
+
|
|
64
|
+
// ── Event helpers ──────────────────────────────────────────────────────────────
|
|
65
|
+
function findEventTarget(el: Element | null, eventName: string): Element | null {
|
|
66
|
+
while (el && el !== document.body) {
|
|
67
|
+
if (el.hasAttribute(`@${eventName}`)) return el;
|
|
68
|
+
el = el.parentElement;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dispatchAction(el: Element) {
|
|
74
|
+
rerender(el.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findLazyAncestor(elementId: string): HTMLElement | null {
|
|
78
|
+
let el = document.getElementById(elementId);
|
|
79
|
+
while (el) {
|
|
80
|
+
if (lazyRenders.has(el.id)) return el;
|
|
81
|
+
el = el.parentElement;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function rerender(triggerId: string, eventDetail?: unknown) {
|
|
87
|
+
focusTarget = triggerId;
|
|
88
|
+
|
|
89
|
+
// Check if the event is inside a lazy-loaded component
|
|
90
|
+
const lazyContainer = findLazyAncestor(triggerId);
|
|
91
|
+
if (lazyContainer) {
|
|
92
|
+
const render = lazyRenders.get(lazyContainer.id) as RenderFn;
|
|
93
|
+
await render(triggerId, eventDetail);
|
|
94
|
+
const html = await render();
|
|
95
|
+
morphChildren(lazyContainer, parseFragment(html));
|
|
96
|
+
postPatch();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const inSlot = layoutRender ? isInsideSlot(triggerId) : false;
|
|
101
|
+
|
|
102
|
+
if (layoutRender && inSlot) {
|
|
103
|
+
// First call executes the matched action (mutates state), second builds clean HTML
|
|
104
|
+
await pageRender(triggerId, eventDetail);
|
|
105
|
+
const html = await pageRender();
|
|
106
|
+
patchSlot(html);
|
|
107
|
+
} else if (layoutRender) {
|
|
108
|
+
await rerenderLayout(triggerId, eventDetail);
|
|
109
|
+
} else {
|
|
110
|
+
await pageRender(triggerId, eventDetail);
|
|
111
|
+
patchBody(await pageRender());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isInsideSlot(elementId: string): boolean {
|
|
116
|
+
const el = document.getElementById(elementId);
|
|
117
|
+
if (!el) return false;
|
|
118
|
+
let node: Element | null = el;
|
|
119
|
+
while (node) {
|
|
120
|
+
if (node.id === 'ty-layout-slot') return true;
|
|
121
|
+
node = node.parentElement;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Layout ─────────────────────────────────────────────────────────────────────
|
|
127
|
+
async function rerenderLayout(triggerId?: string | null, eventDetail?: unknown) {
|
|
128
|
+
if (!layoutRender) return;
|
|
129
|
+
const slotHTML = document.getElementById('ty-layout-slot')?.innerHTML ?? '';
|
|
130
|
+
// First call executes the matched action, second builds clean HTML
|
|
131
|
+
if (triggerId) await layoutRender(triggerId, eventDetail);
|
|
132
|
+
const html = await layoutRender();
|
|
133
|
+
document.body.innerHTML = html;
|
|
134
|
+
const slot = document.getElementById('ty-layout-slot');
|
|
135
|
+
if (slot) slot.innerHTML = slotHTML;
|
|
136
|
+
postPatch();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveLayout(pathname: string): string | null {
|
|
140
|
+
if (pathname !== '/') {
|
|
141
|
+
const segs = pathname.split('/');
|
|
142
|
+
for (let i = segs.length; i >= 1; i--) {
|
|
143
|
+
const prefix = segs.slice(0, i).join('/') || '/';
|
|
144
|
+
if (layouts[prefix]) return layouts[prefix];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return layouts['/'] ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── DOM Patching ───────────────────────────────────────────────────────────────
|
|
151
|
+
function patchSlot(html: string) {
|
|
152
|
+
const slot = document.getElementById('ty-layout-slot');
|
|
153
|
+
if (!slot || (!html && !previousHTML)) return;
|
|
154
|
+
if (html === previousHTML) return;
|
|
155
|
+
previousHTML = html;
|
|
156
|
+
|
|
157
|
+
if (freshNavigation) {
|
|
158
|
+
slot.innerHTML = html;
|
|
159
|
+
} else {
|
|
160
|
+
morphChildren(slot, parseFragment(html));
|
|
161
|
+
}
|
|
162
|
+
postPatch();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function patchBody(html: string) {
|
|
166
|
+
if (!html || html === previousHTML) return;
|
|
167
|
+
previousHTML = html;
|
|
168
|
+
|
|
169
|
+
if (freshNavigation) {
|
|
170
|
+
document.body.innerHTML = html;
|
|
171
|
+
} else {
|
|
172
|
+
morphChildren(document.body, parseFragment(html));
|
|
173
|
+
}
|
|
174
|
+
postPatch();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseFragment(html: string): DocumentFragment {
|
|
178
|
+
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
|
179
|
+
const frag = document.createDocumentFragment();
|
|
180
|
+
while (doc.body.firstChild) frag.appendChild(doc.body.firstChild);
|
|
181
|
+
return frag;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Lazy Component Loading ─────────────────────────────────────────────────────
|
|
185
|
+
const lazyLoaded = new Set<string>();
|
|
186
|
+
const lazyRenders = new Map<string, Function>();
|
|
187
|
+
|
|
188
|
+
const lazyObserver = new IntersectionObserver((entries) => {
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (!entry.isIntersecting) continue;
|
|
191
|
+
const el = entry.target as HTMLElement;
|
|
192
|
+
const id = el.id;
|
|
193
|
+
if (lazyLoaded.has(id)) continue;
|
|
194
|
+
lazyLoaded.add(id);
|
|
195
|
+
lazyObserver.unobserve(el);
|
|
196
|
+
loadLazyComponent(el);
|
|
197
|
+
}
|
|
198
|
+
}, { rootMargin: '100px' });
|
|
199
|
+
|
|
200
|
+
async function loadLazyComponent(el: HTMLElement) {
|
|
201
|
+
const path = el.dataset.lazyComponent!;
|
|
202
|
+
const modulePath = el.dataset.lazyPath!;
|
|
203
|
+
const props = el.dataset.lazyProps || '';
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const mod = await import(modulePath);
|
|
207
|
+
const render = await mod.default(props);
|
|
208
|
+
lazyRenders.set(el.id, render);
|
|
209
|
+
el.innerHTML = await render();
|
|
210
|
+
el.removeAttribute('data-lazy-component');
|
|
211
|
+
el.removeAttribute('data-lazy-path');
|
|
212
|
+
el.removeAttribute('data-lazy-props');
|
|
213
|
+
|
|
214
|
+
// Wire up event delegation for lazy-loaded content
|
|
215
|
+
const eventEls = el.querySelectorAll('[\\@click]');
|
|
216
|
+
// Events are already handled by delegation — no extra wiring needed
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error(`[tachyon] Failed to load lazy component "${path}":`, e);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function observeLazyComponents() {
|
|
223
|
+
const placeholders = document.querySelectorAll('[data-lazy-component]');
|
|
224
|
+
for (const el of placeholders) {
|
|
225
|
+
if (!lazyLoaded.has(el.id)) {
|
|
226
|
+
lazyObserver.observe(el);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function postPatch() {
|
|
232
|
+
cleanBooleanAttrs();
|
|
233
|
+
observeLazyComponents();
|
|
234
|
+
if (focusTarget) {
|
|
235
|
+
const el = document.getElementById(focusTarget);
|
|
236
|
+
if (el) try { el.focus(); } catch {}
|
|
237
|
+
focusTarget = null;
|
|
238
|
+
}
|
|
239
|
+
freshNavigation = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Remove boolean HTML attributes that are explicitly "false" */
|
|
243
|
+
function cleanBooleanAttrs() {
|
|
244
|
+
const all = document.body.querySelectorAll('*');
|
|
245
|
+
for (const el of all) {
|
|
246
|
+
for (const attr of Array.from(el.attributes)) {
|
|
247
|
+
if (attr.name.endsWith('ed') && attr.value === 'false')
|
|
248
|
+
el.removeAttribute(attr.name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── DOM Morphing ───────────────────────────────────────────────────────────────
|
|
254
|
+
// Efficient keyed reconciliation: matches nodes by id, then by tag+position.
|
|
255
|
+
function morphChildren(parent: Element | DocumentFragment, desired: DocumentFragment) {
|
|
256
|
+
const oldNodes = Array.from(parent.childNodes);
|
|
257
|
+
const newNodes = Array.from(desired.childNodes);
|
|
258
|
+
|
|
259
|
+
const maxLen = Math.max(oldNodes.length, newNodes.length);
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < maxLen; i++) {
|
|
262
|
+
const oldChild = oldNodes[i];
|
|
263
|
+
const newChild = newNodes[i];
|
|
264
|
+
|
|
265
|
+
if (!oldChild && newChild) {
|
|
266
|
+
parent.appendChild(newChild.cloneNode(true));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (oldChild && !newChild) {
|
|
270
|
+
parent.removeChild(oldChild);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!oldChild || !newChild) continue;
|
|
274
|
+
|
|
275
|
+
if (!isSameNode(oldChild, newChild)) {
|
|
276
|
+
parent.replaceChild(newChild.cloneNode(true), oldChild);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Text nodes
|
|
281
|
+
if (oldChild.nodeType === Node.TEXT_NODE) {
|
|
282
|
+
if (oldChild.textContent !== newChild.textContent)
|
|
283
|
+
oldChild.textContent = newChild.textContent;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Element nodes — sync attributes then recurse
|
|
288
|
+
if (oldChild.nodeType === Node.ELEMENT_NODE) {
|
|
289
|
+
// Preserve lazy-loaded components — the page render outputs an empty placeholder
|
|
290
|
+
// but the live DOM has the loaded component content
|
|
291
|
+
if (lazyRenders.has((oldChild as Element).id)) continue;
|
|
292
|
+
|
|
293
|
+
syncAttributes(oldChild as Element, newChild as Element);
|
|
294
|
+
// Convert newChild children to a fragment for recursion
|
|
295
|
+
const childFrag = document.createDocumentFragment();
|
|
296
|
+
while (newChild.firstChild) childFrag.appendChild(newChild.firstChild);
|
|
297
|
+
morphChildren(oldChild as Element, childFrag);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Trim excess old nodes
|
|
302
|
+
while (parent.childNodes.length > newNodes.length) {
|
|
303
|
+
parent.removeChild(parent.lastChild!);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isSameNode(a: Node, b: Node): boolean {
|
|
308
|
+
if (a.nodeType !== b.nodeType) return false;
|
|
309
|
+
if (a.nodeType === Node.ELEMENT_NODE) {
|
|
310
|
+
const ae = a as Element, be = b as Element;
|
|
311
|
+
if (ae.tagName !== be.tagName) return false;
|
|
312
|
+
// Prefer matching by id for keyed reconciliation
|
|
313
|
+
if (ae.id && be.id) return ae.id === be.id;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function syncAttributes(oldEl: Element, newEl: Element) {
|
|
319
|
+
// Remove stale attributes (skip event attributes — they're declarative)
|
|
320
|
+
for (const attr of Array.from(oldEl.attributes)) {
|
|
321
|
+
if (!attr.name.startsWith('@') && !newEl.hasAttribute(attr.name))
|
|
322
|
+
oldEl.removeAttribute(attr.name);
|
|
323
|
+
}
|
|
324
|
+
// Add/update attributes
|
|
325
|
+
for (const attr of Array.from(newEl.attributes)) {
|
|
326
|
+
if (!attr.name.startsWith('@') && oldEl.getAttribute(attr.name) !== attr.value)
|
|
327
|
+
oldEl.setAttribute(attr.name, attr.value);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Navigation / Routing ───────────────────────────────────────────────────────
|
|
332
|
+
function navigate(pathname: string) {
|
|
333
|
+
let handler: string;
|
|
334
|
+
let pageURL: string;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
handler = resolveHandler(pathname);
|
|
338
|
+
pageURL = `/pages${handler === '/' ? '' : handler}/HTML.js`;
|
|
339
|
+
} catch {
|
|
340
|
+
pageURL = '/pages/404.js';
|
|
341
|
+
handler = '';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const layoutPath = resolveLayout(pathname);
|
|
345
|
+
const layoutChanged = layoutPath !== currentLayoutPath;
|
|
346
|
+
|
|
347
|
+
const loadPage = async () => {
|
|
348
|
+
const mod = await import(pageURL);
|
|
349
|
+
if (location.pathname !== pathname) history.pushState({}, '', pathname);
|
|
350
|
+
else history.replaceState({}, '', pathname);
|
|
351
|
+
pageRender = await mod.default();
|
|
352
|
+
freshNavigation = true;
|
|
353
|
+
previousHTML = '';
|
|
354
|
+
lazyLoaded.clear();
|
|
355
|
+
lazyRenders.clear();
|
|
356
|
+
try {
|
|
357
|
+
if (layoutRender) {
|
|
358
|
+
patchSlot(await pageRender());
|
|
359
|
+
} else {
|
|
360
|
+
patchBody(await pageRender());
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
freshNavigation = false;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (layoutPath && layoutChanged) {
|
|
368
|
+
import(layoutPath).then(async (mod) => {
|
|
369
|
+
currentLayoutPath = layoutPath;
|
|
370
|
+
layoutRender = await mod.default();
|
|
371
|
+
freshNavigation = true;
|
|
372
|
+
previousHTML = '';
|
|
373
|
+
try {
|
|
374
|
+
document.body.innerHTML = await layoutRender!();
|
|
375
|
+
} finally {
|
|
376
|
+
freshNavigation = false;
|
|
377
|
+
}
|
|
378
|
+
await loadPage();
|
|
379
|
+
});
|
|
380
|
+
} else if (!layoutPath && currentLayoutPath) {
|
|
381
|
+
// Leaving a layout
|
|
382
|
+
currentLayoutPath = null;
|
|
383
|
+
layoutRender = null;
|
|
384
|
+
loadPage();
|
|
385
|
+
} else {
|
|
386
|
+
loadPage();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resolveHandler(pathname: string): string {
|
|
391
|
+
if (pathname === '/') return pathname;
|
|
392
|
+
|
|
393
|
+
const segments = pathname.split('/').slice(1);
|
|
394
|
+
let bestKey = '';
|
|
395
|
+
let bestLen = -1;
|
|
396
|
+
|
|
397
|
+
for (const [routeKey] of routes) {
|
|
398
|
+
const routeSegs = routeKey.split('/');
|
|
399
|
+
if (routeSegs.length > segments.length) continue;
|
|
400
|
+
|
|
401
|
+
const slugMap = routes.get(routeKey) ?? {};
|
|
402
|
+
let match = true;
|
|
403
|
+
|
|
404
|
+
for (let i = 0; i < routeSegs.length; i++) {
|
|
405
|
+
if (!slugMap[routeSegs[i]] && routeSegs[i] !== segments[i]) {
|
|
406
|
+
match = false;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (match && routeSegs.length > bestLen) {
|
|
412
|
+
bestKey = routeKey;
|
|
413
|
+
bestLen = routeSegs.length;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!bestKey) throw new Error(`Route ${pathname} not found`);
|
|
418
|
+
|
|
419
|
+
// Set slugs and params
|
|
420
|
+
const slugMap = routes.get(bestKey) ?? {};
|
|
421
|
+
for (const [key, idx] of Object.entries(slugMap)) {
|
|
422
|
+
slugs[key.replace(':', '')] = segments[idx];
|
|
423
|
+
}
|
|
424
|
+
params = parseParams(segments.slice(bestLen));
|
|
425
|
+
|
|
426
|
+
return bestKey;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function parseParams(input: string[]) {
|
|
430
|
+
return input.map(p => {
|
|
431
|
+
const n = Number(p);
|
|
432
|
+
if (!Number.isNaN(n)) return n;
|
|
433
|
+
if (p === 'true') return true;
|
|
434
|
+
if (p === 'false') return false;
|
|
435
|
+
if (p === 'null') return null;
|
|
436
|
+
if (p === 'undefined') return undefined;
|
|
437
|
+
return p;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** ANSI reset escape code */
|
|
2
|
+
const reset = '\x1b[0m'
|
|
3
|
+
|
|
4
|
+
/** Returns a formatted UTC timestamp string */
|
|
5
|
+
const formatDate = (): string => new Date().toISOString().replace('T', ' ').replace('Z', '')
|
|
6
|
+
|
|
7
|
+
const LOG_LEVEL_COLORS = {
|
|
8
|
+
INFO: '\x1b[32m',
|
|
9
|
+
ERROR: '\x1b[31m',
|
|
10
|
+
DEBUG: '\x1b[36m',
|
|
11
|
+
WARN: '\x1b[33m',
|
|
12
|
+
TRACE: '\x1b[35m',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
type LogLevel = keyof typeof LOG_LEVEL_COLORS
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a leveled console logger that prepends a timestamp and log level prefix.
|
|
19
|
+
* The last argument is treated as the context identifier (e.g. process PID).
|
|
20
|
+
* @param level - The log level label
|
|
21
|
+
* @returns A console-compatible logger function
|
|
22
|
+
*/
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
function createLeveledLogger(level: LogLevel): (...args: any[]) => void {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
return (...args: any[]) => {
|
|
27
|
+
const context = args.length > 1 ? args.pop() : process.pid
|
|
28
|
+
const color = process.stdout.isTTY ? LOG_LEVEL_COLORS[level] : ''
|
|
29
|
+
const resetCode = process.stdout.isTTY ? reset : ''
|
|
30
|
+
const prefix = `[${formatDate()}]${color} ${level}${resetCode} (${context})`
|
|
31
|
+
console.log(prefix, ...args)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.info = createLeveledLogger('INFO')
|
|
36
|
+
console.error = createLeveledLogger('ERROR')
|
|
37
|
+
console.debug = createLeveledLogger('DEBUG')
|
|
38
|
+
console.warn = createLeveledLogger('WARN')
|
|
39
|
+
console.trace = createLeveledLogger('TRACE')
|