@iyulab/router 0.7.6 → 0.8.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] - 2026-04-08
4
+
5
+ ### Added
6
+ - Added navigation guards via `enter` hooks at both router level (`RouterConfig.enter`) and route level (`RouteConfig.enter`) with redirect/cancel flow support
7
+ - Added `rel` attribute support to `<u-link>` for secure external navigation patterns (for example `noopener noreferrer`)
8
+
9
+ ### Changed
10
+ - **Breaking:** Renamed route metadata fields from `meta` to `metadata` (`RouteConfig.metadata`, `RouteContext.metadata`)
11
+ - Updated nested outlet resolution to prefer child outlet discovery inside the current outlet, improving deep nested route rendering behavior
12
+
3
13
  ## [0.7.6] - 2026-04-02
4
14
 
5
15
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @iyulab/router
2
2
 
3
- A modern, lightweight client-side router for web applications with support for both Lit and React components.
3
+ Client-side SPA router for Lit and React with URLPattern matching, nested routes, and route lifecycle events.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,270 +10,136 @@ npm install @iyulab/router
10
10
 
11
11
  ## Quick Start
12
12
 
13
- ### Basic Setup
14
-
15
13
  ```typescript
16
14
  import { Router } from '@iyulab/router';
17
15
  import { html } from 'lit';
18
16
 
19
17
  const router = new Router({
18
+ root: document.body,
20
19
  basepath: '/',
21
20
  routes: [
22
- {
23
- index: true,
24
- render: () => html`<home-page></home-page>`
25
- },
26
- {
27
- path: '/user/:id', // URLPattern route
28
- render: (routeInfo) => html`<user-page .userId=${routeInfo.params.id}></user-page>`
29
- }
21
+ { index: true, render: () => html`<home-page></home-page>` },
22
+ { path: '/users/:id', render: (ctx) => html`<user-page .id=${ctx.params.id}></user-page>` },
30
23
  ],
31
- });
32
- ```
33
-
34
- ### Mixed Framework Support
35
-
36
- ```typescript
37
- import React from 'react';
38
-
39
- const routes = [
40
- // Lit component
41
- {
42
- path: '/lit-page',
43
- render: (routeInfo) => {
44
- return html`<my-lit-component .routeInfo=${routeInfo}></my-lit-component>`
45
- }
46
- },
47
- // React component
48
- {
49
- path: '/react-page',
50
- render: (routeInfo) => {
51
- return ( <MyComponent></MyComponent> )
52
- }
24
+ fallback: {
25
+ render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`,
53
26
  },
54
- // HTML element
55
- {
56
- path: '/element-page',
57
- render: (routeInfo) => {
58
- const element = document.createElement('my-element');
59
- element.data = routeInfo.params;
60
- return element;
61
- }
62
- }
63
- ];
64
- ```
65
-
66
- ### Nested Routes
67
-
68
- ```typescript
69
- import { RouteConfig } from '@iyulab/router';
27
+ });
70
28
 
71
- const routes: RouteConfig[] = [
72
- {
73
- path: '/dashboard',
74
- render: () => html`<dashboard-layout><u-outlet></u-outlet></dashboard-layout>`,
75
- children: [
76
- {
77
- index: true, // Matches '/dashboard'
78
- render: () => html`<dashboard-home></dashboard-home>`
79
- },
80
- {
81
- path: 'settings', // Matches '/dashboard/settings'
82
- render: () => html`<dashboard-settings></dashboard-settings>`
83
- }
84
- ]
85
- }
86
- ];
29
+ router.go('/users/1');
87
30
  ```
88
31
 
89
32
  ## Skills Usage
90
33
 
91
- Install the `iyulab-router` skill to give your AI agent knowledge of this package's API and patterns.
92
-
93
- **Using GitHub shorthand:**
34
+ Install the `iyulab-router` skill for agent-friendly package guidance.
94
35
 
95
36
  ```bash
96
37
  npx skills add iyulab/node-router
97
38
  ```
98
39
 
99
- **Using local path (after `npm install`):**
100
-
101
40
  ```bash
102
41
  npx skills add ./node_modules/@iyulab/router
103
42
  ```
104
43
 
105
- ## Usage Examples
44
+ ## Route Guards
106
45
 
107
- ### Using with Lit Elements
108
-
109
- ```typescript
110
- import { LitElement, html } from 'lit';
111
- import { customElement } from 'lit/decorators.js';
112
-
113
- import "@iyulab/router";
114
-
115
- @customElement('app-root')
116
- export class AppRoot extends LitElement {
117
- render() {
118
- return html`
119
- <nav>
120
- <u-link href="/">Home</u-link>
121
- <u-link href="/about">About</u-link>
122
- <u-link href="/user/123">User Profile</u-link>
123
- </nav>
124
- <main>
125
- <u-outlet></u-outlet>
126
- </main>
127
- `;
128
- }
129
- }
130
- ```
131
-
132
- ### Using with React Components
133
-
134
- ```tsx
135
- import React from 'react';
136
- import { UOutlet, ULink } from '@iyulab/router/react';
137
-
138
- export function AppRoot() {
139
- return (
140
- <div>
141
- <nav>
142
- <ULink href="/">Home</ULink>
143
- <ULink href="/about">About</ULink>
144
- <ULink href="/user/123">User Profile</ULink>
145
- </nav>
146
- <main>
147
- <UOutlet />
148
- </main>
149
- </div>
150
- );
151
- }
152
- ```
153
-
154
- ## Error Handling
155
-
156
- The router provides comprehensive error handling through `FallbackRouteContext`. When a routing error occurs, the fallback render function receives a context with full error information:
46
+ Use `enter` to run guard logic before rendering.
157
47
 
158
48
  ```typescript
159
49
  const router = new Router({
160
50
  root: document.body,
161
- basepath: '/',
162
- routes: [...],
163
- fallback: {
164
- title: 'Error',
165
- render: (ctx) => {
166
- // ctx.error contains RouteError with code, message, and original error
167
- const { code, message, original } = ctx.error;
168
-
169
- if (code === 'NOT_FOUND') {
170
- return html`<not-found-page .path=${ctx.pathname}></not-found-page>`;
171
- }
172
- if (code === 'CONTENT_LOAD_ERROR') {
173
- return html`<error-page .message=${message}></error-page>`;
174
- }
175
- return html`<error-page .error=${ctx.error}></error-page>`;
176
- }
177
- }
51
+ enter: (ctx) => {
52
+ if (!isAuthenticated() && ctx.pathname !== '/login') return '/login';
53
+ return true;
54
+ },
55
+ routes: [
56
+ { path: '/login', render: () => html`<login-page></login-page>` },
57
+ {
58
+ path: '/admin',
59
+ enter: () => hasRole('admin') || '/forbidden',
60
+ render: () => html`<admin-page></admin-page>`,
61
+ },
62
+ ],
178
63
  });
179
64
  ```
180
65
 
181
- Error types:
182
- - `NotFoundError` — No matching route found (code: `NOT_FOUND`)
183
- - `ContentLoadError` Route render function threw an error (code: `CONTENT_LOAD_ERROR`)
184
- - `ContentRenderError` Outlet rendering failed (code: `CONTENT_RENDER_ERROR`)
66
+ `enter` return values:
67
+ - `true` (or `undefined`): continue
68
+ - `false`: cancel navigation
69
+ - `string`: redirect to that path
185
70
 
186
71
  ## Route Metadata
187
72
 
188
- Routes can carry arbitrary metadata via the `meta` field. When a route matches, metadata from the entire matched route chain is merged (parent child order, child overrides parent):
73
+ Attach metadata to routes using `metadata`. The router merges metadata from parent to child and exposes it on `ctx.metadata`.
189
74
 
190
75
  ```typescript
191
- const router = new Router({
192
- root: document.body,
193
- basepath: '/',
194
- routes: [
195
- {
196
- path: '/admin',
197
- meta: { requiresAuth: true, layout: 'admin' },
198
- render: (ctx) => {
199
- // ctx.meta === { requiresAuth: true, layout: 'admin' }
200
- return html`<admin-layout><u-outlet></u-outlet></admin-layout>`;
76
+ const routes = [
77
+ {
78
+ path: '/dashboard',
79
+ metadata: { requiresAuth: true, section: 'dashboard' },
80
+ render: () => html`<dashboard-layout><u-outlet></u-outlet></dashboard-layout>`,
81
+ children: [
82
+ {
83
+ path: 'settings',
84
+ metadata: { tab: 'settings' },
85
+ render: (ctx) => html`<settings-page .metadata=${ctx.metadata}></settings-page>`,
201
86
  },
202
- children: [
203
- {
204
- path: 'settings',
205
- meta: { requiresAuth: true, role: 'superadmin' },
206
- render: (ctx) => {
207
- // ctx.meta === { requiresAuth: true, layout: 'admin', role: 'superadmin' }
208
- return html`<admin-settings></admin-settings>`;
209
- }
210
- }
211
- ]
212
- }
213
- ]
214
- });
87
+ ],
88
+ },
89
+ ];
215
90
  ```
216
91
 
217
- Use cases: authentication guards, SEO tags, analytics tracking, layout selection, and more.
92
+ ## Nested Routes
218
93
 
219
- ## Route Events
94
+ Parent routes must render `<u-outlet>` to host child route content.
220
95
 
221
- The router dispatches events on the `window` object during navigation:
96
+ ```typescript
97
+ const routes = [
98
+ {
99
+ path: '/nested',
100
+ render: () => html`<nested-layout><u-outlet></u-outlet></nested-layout>`,
101
+ children: [
102
+ { index: true, render: () => html`<nested-home></nested-home>` },
103
+ { path: 'lit', render: () => html`<nested-lit></nested-lit>` },
104
+ { path: 'react', render: () => <NestedReact /> },
105
+ ],
106
+ },
107
+ ];
108
+ ```
222
109
 
223
- | Event | Type | Description |
224
- |-------|------|-------------|
225
- | `route-begin` | `RouteBeginEvent` | Fired when navigation starts |
226
- | `route-progress` | `RouteProgressEvent` | Fired during async loading (0–100) |
227
- | `route-done` | `RouteDoneEvent` | Fired when navigation completes successfully |
228
- | `route-error` | `RouteErrorEvent` | Fired when a routing error occurs |
110
+ ## Link and Outlet Components
229
111
 
230
- ```typescript
231
- // Track navigation progress
232
- window.addEventListener('route-progress', (e: RouteProgressEvent) => {
233
- progressBar.value = e.progress;
234
- });
112
+ - `<u-link>`: SPA-aware anchor element
113
+ - `<u-outlet>`: render target for matched route output
235
114
 
236
- // Log navigation events
237
- window.addEventListener('route-begin', (e: RouteBeginEvent) => {
238
- console.log('Navigating to:', e.context.pathname);
239
- });
115
+ `<u-link>` supports `href`, `target`, and `rel`.
240
116
 
241
- window.addEventListener('route-done', (e: RouteDoneEvent) => {
242
- analytics.trackPageView(e.context.pathname);
243
- });
117
+ ```html
118
+ <u-link href="/docs">Docs</u-link>
119
+ <u-link href="https://example.com" target="_blank" rel="noopener noreferrer">External</u-link>
120
+ ```
244
121
 
245
- window.addEventListener('route-error', (e: RouteErrorEvent) => {
246
- errorTracker.report(e.error);
247
- });
122
+ React wrappers:
123
+
124
+ ```tsx
125
+ import { ULink, UOutlet } from '@iyulab/router/react';
248
126
  ```
249
127
 
250
- ## URL Parameters
128
+ ## Route Events
129
+
130
+ The router dispatches events on `window`:
251
131
 
252
- The router supports URLPattern-based parameter matching:
132
+ - `route-begin`
133
+ - `route-progress`
134
+ - `route-done`
135
+ - `route-error`
253
136
 
254
137
  ```typescript
255
- const routes: RouteConfig[] = [
256
- // Required parameter
257
- { path: '/user/:id', render: (ctx) => html`<user-page .id=${ctx.params.id}></user-page>` },
258
-
259
- // Optional parameter
260
- { path: '/posts/:category?', render: (ctx) => {
261
- const category = ctx.params.category || 'all';
262
- return html`<posts-page .category=${category}></posts-page>`;
263
- }},
264
-
265
- // Wildcard (catch-all)
266
- { path: '/docs/:path*', render: (ctx) => html`<docs-page .path=${ctx.params.path}></docs-page>` },
267
-
268
- // Multiple parameters
269
- { path: '/org/:orgId/repo/:repoId', render: (ctx) => {
270
- return html`<repo-page .orgId=${ctx.params.orgId} .repoId=${ctx.params.repoId}></repo-page>`;
271
- }}
272
- ];
138
+ window.addEventListener('route-progress', (e) => {
139
+ console.log(e.progress);
140
+ });
273
141
  ```
274
142
 
275
- When URL parameters change (e.g., navigating from `/user/1` to `/user/2`), leaf routes (without children) automatically re-render since `force` defaults to `true`. For parent routes with children, set `force: true` explicitly if re-rendering is needed on parameter changes.
276
-
277
143
  ## License
278
144
 
279
- MIT License - see [LICENSE](LICENSE) file for details.
145
+ MIT License. See [LICENSE](LICENSE).
package/dist/index.d.ts CHANGED
@@ -47,6 +47,17 @@ declare interface BaseRouteConfig {
47
47
  * ```
48
48
  */
49
49
  render?: (ctx: RouteContext) => Promise<unknown> | unknown;
50
+ /**
51
+ * 이 라우트 진입 전에 호출되는 enter 함수입니다.
52
+ * - `string` 반환: 해당 경로로 redirect
53
+ * - `false` 반환: 네비게이션 취소
54
+ * - `true` 반환: 통과
55
+ * @example
56
+ * ```typescript
57
+ * { path: '/admin', enter: (ctx) => ctx.metadata.role === 'admin' || '/forbidden' }
58
+ * ```
59
+ */
60
+ enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
50
61
  /**
51
62
  * 라우터 URL 변경시 렌더링을 강제할지 여부
52
63
  * - 기본값으로 children을 가질때 false로 설정되며, children이 없을 경우 true로 설정됩니다.
@@ -63,10 +74,10 @@ declare interface BaseRouteConfig {
63
74
  * - 인증, SEO, 분석 등의 용도로 사용할 수 있습니다.
64
75
  * @example
65
76
  * ```typescript
66
- * { path: '/admin', meta: { requiresAuth: true, role: 'admin' } }
77
+ * { path: '/admin', metadata: { requiresAuth: true, role: 'admin' } }
67
78
  * ```
68
79
  */
69
- meta?: Record<string, unknown>;
80
+ metadata?: Record<string, unknown>;
70
81
  }
71
82
 
72
83
  /**
@@ -156,8 +167,6 @@ declare interface RenderOption {
156
167
  id?: string;
157
168
  /** 강제 렌더링 여부 */
158
169
  force?: boolean;
159
- /** 렌더링할 값 */
160
- value: unknown;
161
170
  }
162
171
 
163
172
  /**
@@ -244,7 +253,7 @@ export declare interface RouteContext {
244
253
  * 매칭된 라우트 체인의 병합된 메타데이터
245
254
  * - 부모 라우트에서 자식 라우트 순서로 병합됩니다.
246
255
  */
247
- meta: Record<string, unknown>;
256
+ metadata: Record<string, unknown>;
248
257
  }
249
258
 
250
259
  /**
@@ -316,6 +325,7 @@ export declare class Router {
316
325
  private readonly _basepath;
317
326
  private readonly _routes;
318
327
  private readonly _fallback?;
328
+ private readonly _enter?;
319
329
  /** 현재 라우팅 요청 ID */
320
330
  private _requestID?;
321
331
  /** 현재 라우팅 정보 */
@@ -333,7 +343,7 @@ export declare class Router {
333
343
  * 지정한 경로의 클라이언트 라우팅을 수행합니다. 상대경로일 경우 basepath와 조합되어 이동합니다.
334
344
  * @param href 이동할 경로
335
345
  */
336
- go(href: string): Promise<void>;
346
+ go(href: string): Promise<undefined>;
337
347
  /** 브라우저 히스토리 이벤트가 발생시 라우팅 처리 */
338
348
  private handleWindowPopstate;
339
349
  /** 클릭 이벤트에서 라우터로 처리할 앵커를 찾아 클라이언트 라우팅 수행 */
@@ -361,6 +371,19 @@ export declare interface RouterConfig {
361
371
  * - 라우트는 렌더링할 엘리먼트 또는 컴포넌트를 지정합니다.
362
372
  */
363
373
  routes?: RouteConfig[];
374
+ /**
375
+ * 모든 라우트 전환 전에 호출되는 글로벌 enter 함수입니다.
376
+ * - `string` 반환: 해당 경로로 redirect
377
+ * - `false` 반환: 네비게이션 취소
378
+ * - `true` 반환: 통과
379
+ * @example
380
+ * ```typescript
381
+ * enter: async (ctx) => {
382
+ * if (!isAuthenticated() && ctx.pathname !== '/login') return '/login';
383
+ * }
384
+ * ```
385
+ */
386
+ enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
364
387
  /**
365
388
  * 라우트 매칭 실패 또는 오류 발생 시 대체 라우트 설정
366
389
  * - 지정된 설정이 없을 경우, 기본 오류 페이지가 렌더링됩니다.
@@ -395,6 +418,16 @@ export declare class ULink extends LitElement {
395
418
  * - `_top`: 최상위 프레임에서 링크 열기
396
419
  */
397
420
  target?: string;
421
+ /**
422
+ * 링크 관계 rel 속성
423
+ *
424
+ * - `noopener`: target이 _blank인 경우 보안 강화 (window.opener 차단)
425
+ * - `noreferrer`: target이 _blank인 경우 보안 강화 + Referer 헤더 제거
426
+ * - `external`: 외부 링크임을 명시 (SEO/접근성에 도움)
427
+ * - `nofollow`: 검색 엔진이 링크를 따라가지 않도록 지시 (SEO에 영향)
428
+ * - 그 외 rel 값도 그대로 전달됩니다.
429
+ */
430
+ rel?: string;
398
431
  /**
399
432
  * 링크 대상 URL, 다음 사항에 따라 SPA 라우팅 또는 브라우저 네비게이션이 결정됩니다.
400
433
  *
@@ -436,7 +469,7 @@ export declare class UOutlet extends HTMLElement {
436
469
  /**
437
470
  * 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
438
471
  */
439
- render({ id, value, force }: RenderOption): Promise<void>;
472
+ render(value: unknown, options?: RenderOption): Promise<void>;
440
473
  /**
441
474
  * 기존 DOM을 삭제하여, 초기 상태로 되돌립니다.
442
475
  */
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-sbAElOI7.js";
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
@@ -214,10 +214,11 @@ function getRandomID() {
214
214
  * `u-outlet` 엘리먼트를 찾아 반환합니다.
215
215
  *
216
216
  * @param element 검색을 시작할 HTMLElement
217
+ * @param skip element 자신을 검사에서 제외할지 여부 (기본값: false)
217
218
  * @returns 찾은 UOutlet 엘리먼트 또는 undefined
218
219
  */
219
- function findOutlet(element) {
220
- if (element.tagName === "U-OUTLET") return element;
220
+ function findOutlet(element, skip = false) {
221
+ if (!skip && element instanceof UOutlet) return element;
221
222
  const roots = element.shadowRoot ? [element.shadowRoot, element] : [element];
222
223
  for (const root of roots) for (const child of Array.from(root.children)) {
223
224
  const result = findOutlet(child);
@@ -353,6 +354,7 @@ var Router = class {
353
354
  this._basepath = absolutePath(config.basepath || "/");
354
355
  this._routes = setRoutes(config.routes || [], this._basepath);
355
356
  this._fallback = config.fallback;
357
+ this._enter = config.enter;
356
358
  window.addEventListener("popstate", this.handleWindowPopstate);
357
359
  if (config.useIntercept !== false) document.addEventListener("click", this.handleDocumentClick);
358
360
  if (config.initialLoad !== false) waitOutlet(this._rootElement).then(() => {
@@ -396,14 +398,20 @@ var Router = class {
396
398
  context.progress = progressCallback;
397
399
  let outlet = void 0;
398
400
  try {
401
+ if (this._enter) {
402
+ const result = await this._enter(context);
403
+ if (this._requestID !== requestID) return;
404
+ if (typeof result === "string") return void this.go(result);
405
+ if (result === false) return;
406
+ }
399
407
  if (this._requestID !== requestID) return;
400
408
  window.dispatchEvent(new RouteBeginEvent(context));
401
409
  const routes = getRoutes(this._routes, context.pathname);
402
410
  const lastRoute = routes[routes.length - 1];
403
411
  if (lastRoute && lastRoute.path instanceof URLPattern) context.params = lastRoute.path.exec({ pathname: context.pathname })?.pathname.groups || {};
404
412
  const mergedMeta = {};
405
- for (const route of routes) if (route.meta) Object.assign(mergedMeta, route.meta);
406
- context.meta = mergedMeta;
413
+ for (const route of routes) if (route.metadata) Object.assign(mergedMeta, route.metadata);
414
+ context.metadata = mergedMeta;
407
415
  this._context = context;
408
416
  outlet = findOutletOrThrow(this._rootElement);
409
417
  let title = void 0;
@@ -411,6 +419,12 @@ var Router = class {
411
419
  if (routes.length === 0) throw new NotFoundError(context.href);
412
420
  for (const route of routes) {
413
421
  if (this._requestID !== requestID) return;
422
+ if (route.enter) {
423
+ const result = await route.enter(context);
424
+ if (this._requestID !== requestID) return;
425
+ if (typeof result === "string") return void this.go(result);
426
+ if (result === false) return;
427
+ }
414
428
  if (!route.render) continue;
415
429
  try {
416
430
  content = await route.render(context);
@@ -419,15 +433,14 @@ var Router = class {
419
433
  throw new ContentLoadError(LoadError);
420
434
  }
421
435
  try {
422
- outlet.render({
436
+ outlet.render(content, {
423
437
  id: route.id,
424
- value: content,
425
438
  force: route.force
426
439
  });
427
440
  } catch (renderError) {
428
441
  throw new ContentRenderError(renderError);
429
442
  }
430
- outlet = findOutlet(outlet) || outlet;
443
+ outlet = findOutlet(outlet, true) || outlet;
431
444
  title = route.title || title;
432
445
  }
433
446
  document.title = title || document.title;
@@ -442,18 +455,16 @@ var Router = class {
442
455
  ...context,
443
456
  error: routeError
444
457
  });
445
- outlet.render({
458
+ outlet.render(fallbackContent, {
446
459
  id: "#fallback",
447
- value: fallbackContent,
448
460
  force: true
449
461
  });
450
462
  document.title = this._fallback.title || document.title;
451
463
  } else {
452
464
  const errorContent = new UErrorPage();
453
465
  errorContent.error = error;
454
- if (outlet) outlet.render({
466
+ if (outlet) outlet.render(errorContent, {
455
467
  id: "#error",
456
- value: errorContent,
457
468
  force: true
458
469
  });
459
470
  else {
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({ id, value, force }: RenderOption): Promise<void>;
93
+ render(value: unknown, options?: RenderOption): Promise<void>;
86
94
  /**
87
95
  * 기존 DOM을 삭제하여, 초기 상태로 되돌립니다.
88
96
  */
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { s as UOutlet$1, t as ULink$1 } from "./share-sbAElOI7.js";
1
+ import { s as UOutlet$1, t as ULink$1 } from "./share-CUGwxZKa.js";
2
2
  import React from "react";
3
3
  import { createComponent } from "@lit/react";
4
4
  //#region src/react.ts
@@ -9,9 +9,9 @@ var UOutlet = class extends HTMLElement {
9
9
  /**
10
10
  * 주어진 렌더링 옵션에 따라 컨텐츠를 렌더링합니다.
11
11
  */
12
- async render({ id, value, force }) {
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
- meta: {}
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.122.0/helpers/decorateMetadata.js
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.122.0/helpers/decorate.js
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 target=${ifDefined(this.target)} href=${this.compute(this.href)}>
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/router",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "description": "A modern client-side router for web applications with support for Lit and React components",
5
5
  "keywords": [
6
6
  "lit",
@@ -1,16 +1,16 @@
1
1
  ---
2
2
  name: iyulab-router
3
- description: Client-side SPA router for Lit and React with URLPattern-based matching, nested routes, fallback handling, and route events. Use when working with @iyulab/router setting up routing, defining routes, handling navigation, nested layouts with <u-outlet>, or listening to route lifecycle events.
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.7.4"
8
+ version: "0.8.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
- | `meta` | `Record<string, unknown>` | Arbitrary metadata (auth, layout, analytics) |
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.meta // merged meta from matched route chain
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
- See [references/REFERENCE.md](references/REFERENCE.md) for URL parameter patterns, React usage, and advanced examples.
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
+ ```
@@ -0,0 +1,32 @@
1
+ # URL Pattern
2
+
3
+ ## Supported Patterns
4
+
5
+ ```ts
6
+ // Required
7
+ { path: '/user/:id' }
8
+
9
+ // Optional
10
+ { path: '/posts/:category?' }
11
+
12
+ // Wildcard
13
+ { path: '/docs/:path*' }
14
+
15
+ // Multiple params
16
+ { path: '/org/:orgId/repo/:repoId' }
17
+ ```
18
+
19
+ Access params via `ctx.params`.
20
+
21
+ ```ts
22
+ ctx.params.id;
23
+ ctx.params.category;
24
+ ```
25
+
26
+ ## Query and Hash
27
+
28
+ ```ts
29
+ ctx.query.get('q');
30
+ ctx.query.get('page');
31
+ ctx.hash;
32
+ ```
@@ -1,175 +0,0 @@
1
- # @iyulab/router — Reference
2
-
3
- ## URL Parameter Patterns
4
-
5
- ```ts
6
- // Required
7
- { path: '/user/:id' }
8
-
9
- // Optional
10
- { path: '/posts/:category?' }
11
-
12
- // Wildcard (catch-all)
13
- { path: '/docs/:path*' }
14
-
15
- // Multiple
16
- { path: '/org/:orgId/repo/:repoId' }
17
- ```
18
-
19
- Access via `ctx.params.id`, `ctx.params.category`, etc.
20
-
21
- ## Query String
22
-
23
- ```ts
24
- // URL: /search?q=hello&page=2
25
- ctx.query.get('q') // 'hello'
26
- ctx.query.get('page') // '2'
27
- ```
28
-
29
- ## Route Meta
30
-
31
- Meta from the full matched chain is merged (parent → child, child overrides):
32
-
33
- ```ts
34
- {
35
- path: '/admin',
36
- meta: { requiresAuth: true, layout: 'admin' },
37
- render: (ctx) => {
38
- // ctx.meta === { requiresAuth: true, layout: 'admin' }
39
- return html`<admin-layout><u-outlet></u-outlet></admin-layout>`;
40
- },
41
- children: [
42
- {
43
- path: 'settings',
44
- meta: { role: 'superadmin' },
45
- render: (ctx) => {
46
- // ctx.meta === { requiresAuth: true, layout: 'admin', role: 'superadmin' }
47
- return html`<admin-settings></admin-settings>`;
48
- }
49
- }
50
- ]
51
- }
52
- ```
53
-
54
- ## Async Render with Progress
55
-
56
- ```ts
57
- {
58
- path: '/user/:id',
59
- render: async (ctx) => {
60
- ctx.progress(20);
61
- const user = await fetchUser(ctx.params.id);
62
- ctx.progress(80);
63
- return html`<user-profile .data=${user}></user-profile>`;
64
- }
65
- }
66
- ```
67
-
68
- ## Route Events
69
-
70
- ```ts
71
- window.addEventListener('route-begin', (e) => {
72
- console.log('navigating to:', e.context.pathname);
73
- });
74
-
75
- window.addEventListener('route-progress', (e) => {
76
- progressBar.value = e.progress; // 0–100
77
- });
78
-
79
- window.addEventListener('route-done', (e) => {
80
- analytics.track(e.context.pathname);
81
- });
82
-
83
- window.addEventListener('route-error', (e) => {
84
- errorTracker.report(e.error);
85
- });
86
- ```
87
-
88
- ## Error Fallback
89
-
90
- ```ts
91
- fallback: {
92
- render: (ctx) => {
93
- const { code, message } = ctx.error;
94
- if (code === 'NOT_FOUND') return html`<not-found-page></not-found-page>`;
95
- return html`<error-page .message=${message}></error-page>`;
96
- }
97
- }
98
- ```
99
-
100
- ## React Usage
101
-
102
- ```tsx
103
- import { UOutlet, ULink } from '@iyulab/router/react';
104
-
105
- export function AppRoot() {
106
- return (
107
- <div>
108
- <nav>
109
- <ULink href="/">Home</ULink>
110
- <ULink href="/about">About</ULink>
111
- </nav>
112
- <main>
113
- <UOutlet />
114
- </main>
115
- </div>
116
- );
117
- }
118
- ```
119
-
120
- Mixed Lit + React routes:
121
-
122
- ```ts
123
- const routes = [
124
- {
125
- path: '/lit-page',
126
- render: () => html`<my-lit-component></my-lit-component>`
127
- },
128
- {
129
- path: '/react-page',
130
- render: () => <MyReactComponent />
131
- },
132
- {
133
- path: '/raw-element',
134
- render: (ctx) => {
135
- const el = document.createElement('my-element');
136
- el.data = ctx.params;
137
- return el;
138
- }
139
- }
140
- ];
141
- ```
142
-
143
- ## RouterConfig Options
144
-
145
- | Option | Default | Description |
146
- |---|---|---|
147
- | `root` | — | Mount element (required) |
148
- | `basepath` | `'/'` | URL base path |
149
- | `routes` | `[]` | Route definitions |
150
- | `fallback` | built-in error page | Error/404 handler |
151
- | `useIntercept` | `true` | Intercept `<a>` clicks for client routing |
152
- | `initialLoad` | `true` | Auto-navigate to current URL on init |
153
-
154
- ## Lit Element Integration
155
-
156
- ```ts
157
- import "@iyulab/router"; // registers <u-outlet> and <u-link>
158
- import { LitElement, html } from 'lit';
159
- import { customElement } from 'lit/decorators.js';
160
-
161
- @customElement('app-root')
162
- export class AppRoot extends LitElement {
163
- render() {
164
- return html`
165
- <nav>
166
- <u-link href="/">Home</u-link>
167
- <u-link href="/about">About</u-link>
168
- </nav>
169
- <main>
170
- <u-outlet></u-outlet>
171
- </main>
172
- `;
173
- }
174
- }
175
- ```