@iyulab/router 0.7.6 → 0.9.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/CHANGELOG.md +27 -0
- package/README.md +79 -213
- package/dist/index.d.ts +75 -7
- package/dist/index.js +124 -63
- package/dist/react.d.ts +11 -3
- package/dist/react.js +1 -1
- package/dist/{share-sbAElOI7.js → share-CUGwxZKa.js} +12 -7
- package/package.json +1 -1
- package/skills/iyulab-router/SKILL.md +28 -6
- package/skills/iyulab-router/references/components.md +43 -0
- package/skills/iyulab-router/references/events-and-errors.md +39 -0
- package/skills/iyulab-router/references/guards-and-metadata.md +50 -0
- package/skills/iyulab-router/references/routing-basics.md +40 -0
- package/skills/iyulab-router/references/url-pattern.md +32 -0
- package/skills/iyulab-router/references/REFERENCE.md +0 -175
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as isExternalUrl, i as absolutePath, n as __decorate, o as parseUrl, r as __decorateMetadata, s as UOutlet, t as ULink } from "./share-
|
|
1
|
+
import { a as isExternalUrl, i as absolutePath, n as __decorate, o as parseUrl, r as __decorateMetadata, s as UOutlet, t as ULink } from "./share-CUGwxZKa.js";
|
|
2
2
|
import { LitElement, css, html } from "lit";
|
|
3
3
|
import { customElement, property } from "lit/decorators.js";
|
|
4
4
|
//#region src/types/RouteError.ts
|
|
@@ -24,6 +24,14 @@ var NotFoundError = class extends RouteError {
|
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
26
|
/**
|
|
27
|
+
* enter 가드가 false를 반환하여 접근이 거부되었을 때 발생하는 에러
|
|
28
|
+
*/
|
|
29
|
+
var AccessDeniedError = class extends RouteError {
|
|
30
|
+
constructor(path) {
|
|
31
|
+
super(403, `Access denied: ${path}`);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
27
35
|
* u-outlet 요소를 찾을 수 없을 때 발생하는 에러
|
|
28
36
|
*/
|
|
29
37
|
var OutletMissingError = class extends RouteError {
|
|
@@ -105,8 +113,12 @@ var RouteErrorEvent = class extends RouteEvent {
|
|
|
105
113
|
};
|
|
106
114
|
//#endregion
|
|
107
115
|
//#region src/components/UErrorPage.ts
|
|
108
|
-
var _ref;
|
|
116
|
+
var _ref, _ref2;
|
|
109
117
|
var UErrorPage = class UErrorPage extends LitElement {
|
|
118
|
+
constructor(error) {
|
|
119
|
+
super();
|
|
120
|
+
this.error = error;
|
|
121
|
+
}
|
|
110
122
|
render() {
|
|
111
123
|
const error = this.error || this.getDefaultError();
|
|
112
124
|
return html`
|
|
@@ -121,14 +133,13 @@ var UErrorPage = class UErrorPage extends LitElement {
|
|
|
121
133
|
}
|
|
122
134
|
/** 에러 코드에 따른 기본 아이콘 반환 */
|
|
123
135
|
getErrorIcon(code) {
|
|
124
|
-
|
|
125
|
-
const numericCode = typeof code === "string" ? parseInt(code) : code;
|
|
126
|
-
switch (codeStr) {
|
|
136
|
+
switch (String(code)) {
|
|
127
137
|
case "OUTLET_MISSING": return "📦";
|
|
128
138
|
case "CONTENT_LOAD_FAILED": return "📡";
|
|
129
139
|
case "CONTENT_RENDER_FAILED": return "🎨";
|
|
140
|
+
case "ACCESS_DENIED": return "🚫";
|
|
130
141
|
}
|
|
131
|
-
switch (
|
|
142
|
+
switch (typeof code === "string" ? parseInt(code) : code) {
|
|
132
143
|
case 404: return "🔍";
|
|
133
144
|
case 403: return "🚫";
|
|
134
145
|
case 401: return "🔐";
|
|
@@ -194,8 +205,8 @@ var UErrorPage = class UErrorPage extends LitElement {
|
|
|
194
205
|
`;
|
|
195
206
|
}
|
|
196
207
|
};
|
|
197
|
-
__decorate([property({ type: Object }), __decorateMetadata("design:type", typeof (
|
|
198
|
-
UErrorPage = __decorate([customElement("u-error-page")], UErrorPage);
|
|
208
|
+
__decorate([property({ type: Object }), __decorateMetadata("design:type", typeof (_ref2 = typeof RouteError !== "undefined" && RouteError) === "function" ? _ref2 : Object)], UErrorPage.prototype, "error", void 0);
|
|
209
|
+
UErrorPage = __decorate([customElement("u-error-page"), __decorateMetadata("design:paramtypes", [typeof (_ref = typeof RouteError !== "undefined" && RouteError) === "function" ? _ref : Object])], UErrorPage);
|
|
199
210
|
//#endregion
|
|
200
211
|
//#region src/internals/crypto-helpers.ts
|
|
201
212
|
/**
|
|
@@ -214,10 +225,11 @@ function getRandomID() {
|
|
|
214
225
|
* `u-outlet` 엘리먼트를 찾아 반환합니다.
|
|
215
226
|
*
|
|
216
227
|
* @param element 검색을 시작할 HTMLElement
|
|
228
|
+
* @param skip element 자신을 검사에서 제외할지 여부 (기본값: false)
|
|
217
229
|
* @returns 찾은 UOutlet 엘리먼트 또는 undefined
|
|
218
230
|
*/
|
|
219
|
-
function findOutlet(element) {
|
|
220
|
-
if (element
|
|
231
|
+
function findOutlet(element, skip = false) {
|
|
232
|
+
if (!skip && element instanceof UOutlet) return element;
|
|
221
233
|
const roots = element.shadowRoot ? [element.shadowRoot, element] : [element];
|
|
222
234
|
for (const root of roots) for (const child of Array.from(root.children)) {
|
|
223
235
|
const result = findOutlet(child);
|
|
@@ -322,15 +334,55 @@ function getRoutes(routes, pathname) {
|
|
|
322
334
|
return [];
|
|
323
335
|
}
|
|
324
336
|
//#endregion
|
|
337
|
+
//#region src/internals/RouteTracker.ts
|
|
338
|
+
/**
|
|
339
|
+
* 라우팅 체인 상태를 추적합니다.
|
|
340
|
+
* - 방문한 href를 기록하여 리다이렉트 사이클을 감지합니다.
|
|
341
|
+
* - 이미 실행된 route enter를 기록하여 중복 실행을 방지합니다.
|
|
342
|
+
*/
|
|
343
|
+
var RouteTracker = class {
|
|
344
|
+
constructor() {
|
|
345
|
+
this._history = /* @__PURE__ */ new Set();
|
|
346
|
+
this._processed = /* @__PURE__ */ new Set();
|
|
347
|
+
}
|
|
348
|
+
/** 새 네비게이션 시작 시 상태를 초기화합니다. */
|
|
349
|
+
reset() {
|
|
350
|
+
this._history.clear();
|
|
351
|
+
this._processed.clear();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* href 방문을 기록하고 사이클 여부를 반환합니다.
|
|
355
|
+
* @returns 사이클이 감지되면 true
|
|
356
|
+
*/
|
|
357
|
+
visit(href) {
|
|
358
|
+
if (this._history.has(href)) {
|
|
359
|
+
console.error("Router: Redirect cycle detected:", [...this._history, href].join(" → "));
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
this._history.add(href);
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* route enter를 아직 실행하지 않았다면 키를 등록하고 true를 반환합니다.
|
|
367
|
+
* 이미 실행된 route라면 false를 반환합니다 (중첩 라우트 redirect 체인에서 중복 방지).
|
|
368
|
+
*/
|
|
369
|
+
enter(route) {
|
|
370
|
+
const key = route.id ?? (route.path instanceof URLPattern ? route.path.pathname : String(route.path));
|
|
371
|
+
if (this._processed.has(key)) return false;
|
|
372
|
+
this._processed.add(key);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
//#endregion
|
|
325
377
|
//#region src/Router.ts
|
|
326
378
|
/**
|
|
327
379
|
* `lit-element`, `react`를 지원하는 SPA 클라이언트 라우터 객체입니다.
|
|
328
380
|
*/
|
|
329
381
|
var Router = class {
|
|
330
382
|
constructor(config) {
|
|
383
|
+
this._tracker = new RouteTracker();
|
|
331
384
|
this.handleWindowPopstate = async (_) => {
|
|
332
|
-
|
|
333
|
-
await this.go(href);
|
|
385
|
+
await this.go(window.location.href);
|
|
334
386
|
};
|
|
335
387
|
this.handleDocumentClick = async (e) => {
|
|
336
388
|
try {
|
|
@@ -353,6 +405,7 @@ var Router = class {
|
|
|
353
405
|
this._basepath = absolutePath(config.basepath || "/");
|
|
354
406
|
this._routes = setRoutes(config.routes || [], this._basepath);
|
|
355
407
|
this._fallback = config.fallback;
|
|
408
|
+
this._enter = config.enter;
|
|
356
409
|
window.addEventListener("popstate", this.handleWindowPopstate);
|
|
357
410
|
if (config.useIntercept !== false) document.addEventListener("click", this.handleDocumentClick);
|
|
358
411
|
if (config.initialLoad !== false) waitOutlet(this._rootElement).then(() => {
|
|
@@ -381,92 +434,100 @@ var Router = class {
|
|
|
381
434
|
/**
|
|
382
435
|
* 지정한 경로의 클라이언트 라우팅을 수행합니다. 상대경로일 경우 basepath와 조합되어 이동합니다.
|
|
383
436
|
* @param href 이동할 경로
|
|
437
|
+
* @param options 네비게이션 옵션
|
|
384
438
|
*/
|
|
385
|
-
async go(href) {
|
|
439
|
+
async go(href, options) {
|
|
440
|
+
if (!options?.isRedirect) this._tracker.reset();
|
|
386
441
|
const requestID = getRandomID();
|
|
387
442
|
this._requestID = requestID;
|
|
388
443
|
const context = parseUrl(href, this._basepath);
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
window.dispatchEvent(new RouteProgressEvent(context, progress));
|
|
444
|
+
if (this._tracker.visit(context.href)) return;
|
|
445
|
+
const useReplace = options?.isRedirect || options?.replace || context.href === window.location.href;
|
|
446
|
+
const historyState = {
|
|
447
|
+
basepath: context.basepath,
|
|
448
|
+
...options?.state
|
|
395
449
|
};
|
|
396
|
-
|
|
450
|
+
if (useReplace) window.history.replaceState(historyState, "", context.href);
|
|
451
|
+
else window.history.pushState(historyState, "", context.href);
|
|
397
452
|
let outlet = void 0;
|
|
453
|
+
let title = void 0;
|
|
398
454
|
try {
|
|
399
|
-
|
|
400
|
-
window.dispatchEvent(new RouteBeginEvent(context));
|
|
455
|
+
outlet = findOutletOrThrow(this._rootElement);
|
|
401
456
|
const routes = getRoutes(this._routes, context.pathname);
|
|
457
|
+
if (routes.length === 0) throw new NotFoundError(context.href);
|
|
402
458
|
const lastRoute = routes[routes.length - 1];
|
|
403
|
-
if (lastRoute
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
459
|
+
if (lastRoute.path instanceof URLPattern) context.params = lastRoute.path.exec({ pathname: context.pathname })?.pathname.groups || {};
|
|
460
|
+
context.progress = (value) => {
|
|
461
|
+
if (this._requestID !== requestID) return;
|
|
462
|
+
const progress = Math.max(0, Math.min(100, Math.round(value)));
|
|
463
|
+
window.dispatchEvent(new RouteProgressEvent(context, progress));
|
|
464
|
+
};
|
|
465
|
+
if (this._enter && !options?.isRedirect) {
|
|
466
|
+
const result = await this._enter(context);
|
|
467
|
+
if (this._requestID !== requestID) return;
|
|
468
|
+
if (result === false) throw new AccessDeniedError(context.pathname);
|
|
469
|
+
if (typeof result === "string") return void this.go(result, { isRedirect: true });
|
|
470
|
+
}
|
|
407
471
|
this._context = context;
|
|
408
|
-
|
|
409
|
-
let title = void 0;
|
|
410
|
-
let content = null;
|
|
411
|
-
if (routes.length === 0) throw new NotFoundError(context.href);
|
|
472
|
+
window.dispatchEvent(new RouteBeginEvent(context));
|
|
412
473
|
for (const route of routes) {
|
|
413
474
|
if (this._requestID !== requestID) return;
|
|
475
|
+
context.metadata = {
|
|
476
|
+
...context.metadata,
|
|
477
|
+
...route.metadata
|
|
478
|
+
};
|
|
479
|
+
if (route.enter && this._tracker.enter(route)) {
|
|
480
|
+
const result = await route.enter(context);
|
|
481
|
+
if (this._requestID !== requestID) return;
|
|
482
|
+
if (result === false) throw new AccessDeniedError(context.pathname);
|
|
483
|
+
if (typeof result === "string") return void this.go(result, { isRedirect: true });
|
|
484
|
+
}
|
|
414
485
|
if (!route.render) continue;
|
|
486
|
+
let content;
|
|
415
487
|
try {
|
|
416
488
|
content = await route.render(context);
|
|
417
489
|
if (content === false || content === void 0 || content === null) throw new Error("Failed to load content for the route.");
|
|
418
|
-
} catch (
|
|
419
|
-
throw new ContentLoadError(
|
|
490
|
+
} catch (e) {
|
|
491
|
+
throw new ContentLoadError(e);
|
|
420
492
|
}
|
|
421
493
|
try {
|
|
422
|
-
outlet.render({
|
|
494
|
+
outlet.render(content, {
|
|
423
495
|
id: route.id,
|
|
424
|
-
value: content,
|
|
425
496
|
force: route.force
|
|
426
497
|
});
|
|
427
|
-
} catch (
|
|
428
|
-
throw new ContentRenderError(
|
|
498
|
+
} catch (e) {
|
|
499
|
+
throw new ContentRenderError(e);
|
|
429
500
|
}
|
|
430
|
-
outlet = findOutlet(outlet) || outlet;
|
|
501
|
+
outlet = findOutlet(outlet, true) || outlet;
|
|
431
502
|
title = route.title || title;
|
|
432
503
|
}
|
|
433
|
-
document.title = title || document.title;
|
|
434
504
|
window.dispatchEvent(new RouteDoneEvent(context));
|
|
435
505
|
} catch (error) {
|
|
436
506
|
const routeError = error instanceof RouteError ? error : new RouteError(error?.status || error?.code || "UNKNOWN_ERROR", error?.message || "An unexpected error occurred", error);
|
|
437
507
|
window.dispatchEvent(new RouteErrorEvent(context, routeError));
|
|
438
|
-
console.error("Routing error:", routeError.original);
|
|
508
|
+
console.error("Routing error:", routeError.original || routeError);
|
|
439
509
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
document.
|
|
451
|
-
} else {
|
|
452
|
-
const errorContent = new UErrorPage();
|
|
453
|
-
errorContent.error = error;
|
|
454
|
-
if (outlet) outlet.render({
|
|
455
|
-
id: "#error",
|
|
456
|
-
value: errorContent,
|
|
457
|
-
force: true
|
|
458
|
-
});
|
|
459
|
-
else {
|
|
460
|
-
document.body.innerHTML = "";
|
|
461
|
-
document.body.appendChild(errorContent);
|
|
462
|
-
}
|
|
510
|
+
const content = this._fallback?.render ? await this._fallback.render({
|
|
511
|
+
...context,
|
|
512
|
+
error: routeError
|
|
513
|
+
}) : new UErrorPage(routeError);
|
|
514
|
+
if (outlet) outlet.render(content, {
|
|
515
|
+
id: getRandomID(),
|
|
516
|
+
force: true
|
|
517
|
+
});
|
|
518
|
+
else {
|
|
519
|
+
document.body.innerHTML = "";
|
|
520
|
+
document.body.appendChild(content instanceof Node ? content : new UErrorPage(routeError));
|
|
463
521
|
}
|
|
522
|
+
title = this._fallback?.title || routeError.message || "Error";
|
|
464
523
|
} catch (pageError) {
|
|
465
524
|
console.error("Failed to render error component:", pageError);
|
|
466
525
|
console.error("Original error:", routeError.original || routeError);
|
|
467
526
|
}
|
|
527
|
+
} finally {
|
|
528
|
+
document.title = title || document.title;
|
|
468
529
|
}
|
|
469
530
|
}
|
|
470
531
|
};
|
|
471
532
|
//#endregion
|
|
472
|
-
export { ContentLoadError, ContentRenderError, NotFoundError, OutletMissingError, RouteBeginEvent, RouteDoneEvent, RouteError, RouteErrorEvent, RouteProgressEvent, Router, ULink, UOutlet };
|
|
533
|
+
export { AccessDeniedError, ContentLoadError, ContentRenderError, NotFoundError, OutletMissingError, RouteBeginEvent, RouteDoneEvent, RouteError, RouteErrorEvent, RouteProgressEvent, Router, ULink, UOutlet };
|
package/dist/react.d.ts
CHANGED
|
@@ -10,8 +10,6 @@ declare interface RenderOption {
|
|
|
10
10
|
id?: string;
|
|
11
11
|
/** 강제 렌더링 여부 */
|
|
12
12
|
force?: boolean;
|
|
13
|
-
/** 렌더링할 값 */
|
|
14
|
-
value: unknown;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
/**
|
|
@@ -36,6 +34,16 @@ declare class ULink_2 extends LitElement {
|
|
|
36
34
|
* - `_top`: 최상위 프레임에서 링크 열기
|
|
37
35
|
*/
|
|
38
36
|
target?: string;
|
|
37
|
+
/**
|
|
38
|
+
* 링크 관계 rel 속성
|
|
39
|
+
*
|
|
40
|
+
* - `noopener`: target이 _blank인 경우 보안 강화 (window.opener 차단)
|
|
41
|
+
* - `noreferrer`: target이 _blank인 경우 보안 강화 + Referer 헤더 제거
|
|
42
|
+
* - `external`: 외부 링크임을 명시 (SEO/접근성에 도움)
|
|
43
|
+
* - `nofollow`: 검색 엔진이 링크를 따라가지 않도록 지시 (SEO에 영향)
|
|
44
|
+
* - 그 외 rel 값도 그대로 전달됩니다.
|
|
45
|
+
*/
|
|
46
|
+
rel?: string;
|
|
39
47
|
/**
|
|
40
48
|
* 링크 대상 URL, 다음 사항에 따라 SPA 라우팅 또는 브라우저 네비게이션이 결정됩니다.
|
|
41
49
|
*
|
|
@@ -82,7 +90,7 @@ declare class UOutlet_2 extends HTMLElement {
|
|
|
82
90
|
/**
|
|
83
91
|
* 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
|
|
84
92
|
*/
|
|
85
|
-
render(
|
|
93
|
+
render(value: unknown, options?: RenderOption): Promise<void>;
|
|
86
94
|
/**
|
|
87
95
|
* 기존 DOM을 삭제하여, 초기 상태로 되돌립니다.
|
|
88
96
|
*/
|
package/dist/react.js
CHANGED
|
@@ -9,9 +9,9 @@ var UOutlet = class extends HTMLElement {
|
|
|
9
9
|
/**
|
|
10
10
|
* 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
|
|
11
11
|
*/
|
|
12
|
-
async render(
|
|
13
|
-
if (this.routeId === id && force === false) return;
|
|
14
|
-
this.routeId = id;
|
|
12
|
+
async render(value, options) {
|
|
13
|
+
if (this.routeId === options?.id && options?.force === false) return;
|
|
14
|
+
this.routeId = options?.id;
|
|
15
15
|
this.reset();
|
|
16
16
|
if (value === null) throw new Error("Content is null and cannot be rendered.");
|
|
17
17
|
if (typeof value !== "object") throw new Error("Content is not a valid renderable object.");
|
|
@@ -88,7 +88,7 @@ function parseUrl(url, basepath) {
|
|
|
88
88
|
hash: urlObj.hash,
|
|
89
89
|
params: {},
|
|
90
90
|
progress: () => {},
|
|
91
|
-
|
|
91
|
+
metadata: {}
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
@@ -126,12 +126,12 @@ function catchBasepath(basepath) {
|
|
|
126
126
|
return basepath;
|
|
127
127
|
}
|
|
128
128
|
//#endregion
|
|
129
|
-
//#region \0@oxc-project+runtime@0.
|
|
129
|
+
//#region \0@oxc-project+runtime@0.123.0/helpers/decorateMetadata.js
|
|
130
130
|
function __decorateMetadata(k, v) {
|
|
131
131
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
132
132
|
}
|
|
133
133
|
//#endregion
|
|
134
|
-
//#region \0@oxc-project+runtime@0.
|
|
134
|
+
//#region \0@oxc-project+runtime@0.123.0/helpers/decorate.js
|
|
135
135
|
function __decorate(decorators, target, key, desc) {
|
|
136
136
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
137
137
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -189,7 +189,11 @@ var ULink = class ULink extends LitElement {
|
|
|
189
189
|
}
|
|
190
190
|
render() {
|
|
191
191
|
return html`
|
|
192
|
-
<a
|
|
192
|
+
<a
|
|
193
|
+
href=${this.compute(this.href)}
|
|
194
|
+
target=${ifDefined(this.target)}
|
|
195
|
+
rel=${ifDefined(this.rel)}
|
|
196
|
+
>
|
|
193
197
|
<slot></slot>
|
|
194
198
|
</a>
|
|
195
199
|
`;
|
|
@@ -231,6 +235,7 @@ var ULink = class ULink extends LitElement {
|
|
|
231
235
|
}
|
|
232
236
|
};
|
|
233
237
|
__decorate([property({ type: String }), __decorateMetadata("design:type", String)], ULink.prototype, "target", void 0);
|
|
238
|
+
__decorate([property({ type: String }), __decorateMetadata("design:type", String)], ULink.prototype, "rel", void 0);
|
|
234
239
|
__decorate([property({ type: String }), __decorateMetadata("design:type", String)], ULink.prototype, "href", void 0);
|
|
235
240
|
ULink = __decorate([customElement("u-link")], ULink);
|
|
236
241
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: iyulab-router
|
|
3
|
-
description: Client-side SPA router for Lit and React with URLPattern
|
|
3
|
+
description: Client-side SPA router for Lit and React with URLPattern matching, nested routes, route guards, metadata merging, fallback handling, and route events. Use when working with @iyulab/router to define routes, add guards, handle navigation, or integrate <u-outlet>/<u-link>.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Browser environments only (requires URLPattern and History API)
|
|
6
6
|
metadata:
|
|
7
7
|
author: iyulab
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.9.0"
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# @iyulab/router
|
|
12
12
|
|
|
13
|
-
Client-side router supporting Lit and React renders, nested routes, and URLPattern-based matching.
|
|
13
|
+
Client-side router supporting Lit and React renders, nested routes, route guards, and URLPattern-based matching.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -40,9 +40,19 @@ import { html } from 'lit';
|
|
|
40
40
|
const router = new Router({
|
|
41
41
|
root: document.body, // required — mount element containing <u-outlet>
|
|
42
42
|
basepath: '/', // optional
|
|
43
|
+
enter: (ctx) => {
|
|
44
|
+
if (!isAuthenticated() && ctx.pathname !== '/login') return '/login';
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
43
47
|
routes: [
|
|
44
48
|
{ index: true, render: () => html`<home-page></home-page>` },
|
|
45
49
|
{ path: '/user/:id', render: (ctx) => html`<user-page .id=${ctx.params.id}></user-page>` },
|
|
50
|
+
{
|
|
51
|
+
path: '/admin',
|
|
52
|
+
metadata: { role: 'admin' },
|
|
53
|
+
enter: (ctx) => ctx.metadata.role === 'admin' || '/forbidden',
|
|
54
|
+
render: () => html`<admin-page></admin-page>`,
|
|
55
|
+
},
|
|
46
56
|
],
|
|
47
57
|
fallback: {
|
|
48
58
|
render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`
|
|
@@ -57,9 +67,10 @@ const router = new Router({
|
|
|
57
67
|
| `path` | `string \| URLPattern` | URLPattern path; omit when `index: true` |
|
|
58
68
|
| `index` | `true` | Marks route as index of its parent path |
|
|
59
69
|
| `render` | `(ctx) => unknown` | Returns Lit `TemplateResult`, React element, or `HTMLElement` |
|
|
70
|
+
| `enter` | `(ctx) => string \| boolean \| Promise<string \| boolean>` | Guard before route render (`false` cancel, `string` redirect) |
|
|
60
71
|
| `children` | `RouteConfig[]` | Nested routes; parent must render `<u-outlet>` |
|
|
61
72
|
| `title` | `string` | Sets `document.title` on match |
|
|
62
|
-
| `
|
|
73
|
+
| `metadata` | `Record<string, unknown>` | Arbitrary metadata (auth, layout, analytics) |
|
|
63
74
|
| `force` | `boolean` | Force re-render on URL change (default `true` for leaf routes) |
|
|
64
75
|
|
|
65
76
|
## RouteContext Fields
|
|
@@ -69,7 +80,7 @@ ctx.params // URLPattern captured params
|
|
|
69
80
|
ctx.pathname // path without query/hash
|
|
70
81
|
ctx.path // full path including query + hash
|
|
71
82
|
ctx.query // URLSearchParams
|
|
72
|
-
ctx.
|
|
83
|
+
ctx.metadata // merged metadata from matched route chain
|
|
73
84
|
ctx.progress // (value: number) => void — report 0–100 loading progress
|
|
74
85
|
```
|
|
75
86
|
|
|
@@ -103,6 +114,12 @@ import { ULink } from '@iyulab/router/react';
|
|
|
103
114
|
<ULink href="/about">About</ULink>
|
|
104
115
|
```
|
|
105
116
|
|
|
117
|
+
`<u-link>` supports `href`, `target`, and `rel`.
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<u-link href="https://example.com" target="_blank" rel="noopener noreferrer">External</u-link>
|
|
121
|
+
```
|
|
122
|
+
|
|
106
123
|
## Route Events (window)
|
|
107
124
|
|
|
108
125
|
| Event | Fired when |
|
|
@@ -120,4 +137,9 @@ import { ULink } from '@iyulab/router/react';
|
|
|
120
137
|
| `CONTENT_LOAD_ERROR` | `ContentLoadError` |
|
|
121
138
|
| `CONTENT_RENDER_ERROR` | `ContentRenderError` |
|
|
122
139
|
|
|
123
|
-
|
|
140
|
+
References:
|
|
141
|
+
- [references/routing-basics.md](references/routing-basics.md)
|
|
142
|
+
- [references/url-pattern.md](references/url-pattern.md)
|
|
143
|
+
- [references/guards-and-metadata.md](references/guards-and-metadata.md)
|
|
144
|
+
- [references/components.md](references/components.md)
|
|
145
|
+
- [references/events-and-errors.md](references/events-and-errors.md)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Components
|
|
2
|
+
|
|
3
|
+
## Lit Usage
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import '@iyulab/router';
|
|
7
|
+
import { html } from 'lit';
|
|
8
|
+
|
|
9
|
+
html`
|
|
10
|
+
<nav>
|
|
11
|
+
<u-link href="/">Home</u-link>
|
|
12
|
+
<u-link href="/docs">Docs</u-link>
|
|
13
|
+
<u-link href="https://example.com" target="_blank" rel="noopener noreferrer">External</u-link>
|
|
14
|
+
</nav>
|
|
15
|
+
<main>
|
|
16
|
+
<u-outlet></u-outlet>
|
|
17
|
+
</main>
|
|
18
|
+
`;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## React Wrappers
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { ULink, UOutlet } from '@iyulab/router/react';
|
|
25
|
+
|
|
26
|
+
export function AppRoot() {
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
<nav>
|
|
30
|
+
<ULink href="/">Home</ULink>
|
|
31
|
+
<ULink href="/about">About</ULink>
|
|
32
|
+
</nav>
|
|
33
|
+
<main>
|
|
34
|
+
<UOutlet />
|
|
35
|
+
</main>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Nested Outlet Rule
|
|
42
|
+
|
|
43
|
+
A parent route must render `<u-outlet>` to host child route content.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Events and Errors
|
|
2
|
+
|
|
3
|
+
## Route Events
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
window.addEventListener('route-begin', (e) => {
|
|
7
|
+
console.log(e.context.pathname);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
window.addEventListener('route-progress', (e) => {
|
|
11
|
+
progressBar.value = e.progress;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
window.addEventListener('route-done', (e) => {
|
|
15
|
+
analytics.track(e.context.pathname);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
window.addEventListener('route-error', (e) => {
|
|
19
|
+
errorTracker.report(e.error);
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Fallback
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
fallback: {
|
|
27
|
+
render: (ctx) => {
|
|
28
|
+
const { code, message } = ctx.error;
|
|
29
|
+
if (code === 'NOT_FOUND') return html`<not-found-page></not-found-page>`;
|
|
30
|
+
return html`<error-page .message=${message}></error-page>`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Error Codes
|
|
36
|
+
|
|
37
|
+
- `NOT_FOUND`
|
|
38
|
+
- `CONTENT_LOAD_ERROR`
|
|
39
|
+
- `CONTENT_RENDER_ERROR`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Guards and Metadata
|
|
2
|
+
|
|
3
|
+
## Global Guard
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const router = new Router({
|
|
7
|
+
root: document.body,
|
|
8
|
+
enter: (ctx) => {
|
|
9
|
+
if (!isAuthenticated() && ctx.pathname !== '/login') return '/login';
|
|
10
|
+
return true;
|
|
11
|
+
},
|
|
12
|
+
routes: [...],
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Route Guard
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
{
|
|
20
|
+
path: '/admin',
|
|
21
|
+
enter: () => hasRole('admin') || '/forbidden',
|
|
22
|
+
render: () => html`<admin-page></admin-page>`
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Guard return values:
|
|
27
|
+
- `true` or `undefined`: continue
|
|
28
|
+
- `false`: cancel navigation
|
|
29
|
+
- `string`: redirect
|
|
30
|
+
|
|
31
|
+
## Route Metadata
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
{
|
|
35
|
+
path: '/admin',
|
|
36
|
+
metadata: { requiresAuth: true, section: 'admin' },
|
|
37
|
+
render: (ctx) => {
|
|
38
|
+
// merged metadata from matched chain
|
|
39
|
+
console.log(ctx.metadata);
|
|
40
|
+
return html`<admin-layout><u-outlet></u-outlet></admin-layout>`;
|
|
41
|
+
},
|
|
42
|
+
children: [
|
|
43
|
+
{
|
|
44
|
+
path: 'settings',
|
|
45
|
+
metadata: { tab: 'settings' },
|
|
46
|
+
render: (ctx) => html`<settings-page .metadata=${ctx.metadata}></settings-page>`
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Routing Basics
|
|
2
|
+
|
|
3
|
+
## Minimal Setup
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Router } from '@iyulab/router';
|
|
7
|
+
import { html } from 'lit';
|
|
8
|
+
|
|
9
|
+
const router = new Router({
|
|
10
|
+
root: document.body,
|
|
11
|
+
basepath: '/',
|
|
12
|
+
routes: [
|
|
13
|
+
{ index: true, render: () => html`<home-page></home-page>` },
|
|
14
|
+
{ path: '/users/:id', render: (ctx) => html`<user-page .id=${ctx.params.id}></user-page>` },
|
|
15
|
+
],
|
|
16
|
+
fallback: {
|
|
17
|
+
render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## RouterConfig Options
|
|
23
|
+
|
|
24
|
+
| Option | Default | Description |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `root` | - | Mount element (required) |
|
|
27
|
+
| `basepath` | `'/'` | URL base path |
|
|
28
|
+
| `routes` | `[]` | Route definitions |
|
|
29
|
+
| `enter` | - | Global guard before navigation |
|
|
30
|
+
| `fallback` | built-in error page | Error/404 handler |
|
|
31
|
+
| `useIntercept` | `true` | Intercept `<a>` clicks for client routing |
|
|
32
|
+
| `initialLoad` | `true` | Auto-navigate on initialization |
|
|
33
|
+
|
|
34
|
+
## Navigation
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
router.go('/dashboard');
|
|
38
|
+
router.go('settings');
|
|
39
|
+
router.destroy();
|
|
40
|
+
```
|