soseki 0.0.1

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.
Files changed (88) hide show
  1. package/.config/_is-debug-mode.ts +9 -0
  2. package/.config/dependency-cruiser.cjs +408 -0
  3. package/.config/env.d.ts +8 -0
  4. package/.config/mise.toml +28 -0
  5. package/.config/navigation-api.d.ts +18 -0
  6. package/.config/tsconfig.build.json +28 -0
  7. package/.config/tsconfig.config.json +21 -0
  8. package/.config/tsconfig.test.json +26 -0
  9. package/.config/tsconfig.web.json +26 -0
  10. package/.config/vitest.client.ts +30 -0
  11. package/package.json +45 -0
  12. package/src/components/action-id.tsx +35 -0
  13. package/src/components/browser-router.tsx +31 -0
  14. package/src/components/hidden-input.tsx +39 -0
  15. package/src/components/outlet.tsx +17 -0
  16. package/src/components/router.tsx +234 -0
  17. package/src/contexts/route-context.ts +21 -0
  18. package/src/contexts/router-context.ts +55 -0
  19. package/src/core/_action-id-registry.ts +11 -0
  20. package/src/core/_capture-stack-trace.ts +12 -0
  21. package/src/core/_compare-route-paths.ts +90 -0
  22. package/src/core/_create-html-form-element-form-form-data.ts +32 -0
  23. package/src/core/_encode-pathname.ts +17 -0
  24. package/src/core/_is-error.ts +16 -0
  25. package/src/core/_is-promise-like.ts +14 -0
  26. package/src/core/_match-route-path.ts +39 -0
  27. package/src/core/_process-routes.ts +56 -0
  28. package/src/core/_singleton.ts +45 -0
  29. package/src/core/_unreachable.ts +22 -0
  30. package/src/core/_use-singleton.ts +24 -0
  31. package/src/core/_valibot.ts +147 -0
  32. package/src/core/_weak-id-registry.ts +125 -0
  33. package/src/core/constants.ts +4 -0
  34. package/src/core/data-map.types.ts +28 -0
  35. package/src/core/data-store.types.ts +25 -0
  36. package/src/core/deferred-promise.ts +408 -0
  37. package/src/core/errors.ts +680 -0
  38. package/src/core/expect-history-entry.ts +95 -0
  39. package/src/core/history-entry-id-schema.ts +27 -0
  40. package/src/core/history-entry-url-schema.ts +35 -0
  41. package/src/core/init-loaders.ts +79 -0
  42. package/src/core/match-routes.ts +91 -0
  43. package/src/core/readonly-form-data.types.ts +63 -0
  44. package/src/core/readonly-url.types.ts +156 -0
  45. package/src/core/redirect-response.ts +36 -0
  46. package/src/core/route-request.ts +92 -0
  47. package/src/core/route.types.ts +351 -0
  48. package/src/core/start-actions.ts +274 -0
  49. package/src/core/start-loaders.ts +254 -0
  50. package/src/core.ts +43 -0
  51. package/src/engines/engine.types.ts +216 -0
  52. package/src/engines/navigation-api-engine.ts +406 -0
  53. package/src/hooks/_use-route-context.ts +19 -0
  54. package/src/hooks/_use-router-context.ts +25 -0
  55. package/src/hooks/use-action-data.ts +37 -0
  56. package/src/hooks/use-loader-data.ts +28 -0
  57. package/src/hooks/use-navigate.ts +64 -0
  58. package/src/hooks/use-params.ts +11 -0
  59. package/src/hooks/use-pathname.ts +10 -0
  60. package/src/hooks/use-submit.ts +111 -0
  61. package/src/soseki.ts +75 -0
  62. package/src/utils/get-action-id.ts +12 -0
  63. package/src/utils/href.ts +17 -0
  64. package/src/utils/redirect.ts +13 -0
  65. package/src/utils/route-index.ts +70 -0
  66. package/src/utils/route-route.ts +111 -0
  67. package/src/utils/set-action-id.ts +14 -0
  68. package/tests/core/_capture-stack-trace.test.ts +46 -0
  69. package/tests/core/_compare-route-paths.test.ts +134 -0
  70. package/tests/core/_encode-pathname.test.ts +77 -0
  71. package/tests/core/_is-error.test.ts +108 -0
  72. package/tests/core/_is-promise-like.test.ts +100 -0
  73. package/tests/core/_match-route-path.test.ts +74 -0
  74. package/tests/core/_process-routes.test.ts +146 -0
  75. package/tests/core/_singleton.test.ts +102 -0
  76. package/tests/core/_unreachable.test.ts +38 -0
  77. package/tests/core/_use-singleton.test.ts +47 -0
  78. package/tests/core/_weak-id-registry.test.ts +137 -0
  79. package/tests/core/deferred-promise.test.ts +218 -0
  80. package/tests/core/expect-history-entry.test.ts +112 -0
  81. package/tests/core/init-loaders.test.ts +172 -0
  82. package/tests/core/match-routes.test.ts +178 -0
  83. package/tests/core/redirect-response.test.ts +36 -0
  84. package/tests/core/route-request.test.ts +93 -0
  85. package/tests/core/start-actions.test.ts +319 -0
  86. package/tests/core/start-loaders.test.ts +276 -0
  87. package/tests/engines/navigation-api-engine.test.ts +162 -0
  88. package/tsconfig.json +7 -0
@@ -0,0 +1,111 @@
1
+ import * as React from "react";
2
+ import type { ReadonlyFormData } from "../core/readonly-form-data.types.js";
3
+ import type { ReadonlyURLSearchParams } from "../core/readonly-url.types.js";
4
+ import type { IAction } from "../core/route.types.js";
5
+ import useRouteContext from "./_use-route-context.js";
6
+ import useRouterContext from "./_use-router-context.js";
7
+
8
+ /**
9
+ * GET メソッドによる送信時のオプションです。
10
+ */
11
+ export type SubmitGetOptions = {
12
+ /**
13
+ * 送信先のパスです。
14
+ */
15
+ readonly action?: string | undefined;
16
+
17
+ /**
18
+ * 現在の履歴エントリーを置き換えるかどうかを指定します。
19
+ */
20
+ readonly replace?: boolean | undefined;
21
+ };
22
+
23
+ /**
24
+ * POST メソッドによる送信時のオプションです。
25
+ */
26
+ export type SubmitPostOptions = {
27
+ /**
28
+ * 送信先のパスです。
29
+ */
30
+ readonly action?: string | undefined;
31
+
32
+ /**
33
+ * 実行するアクションの定義です。
34
+ */
35
+ readonly actionId?: IAction | undefined;
36
+ };
37
+
38
+ /**
39
+ * 送信処理に使用するオプションの連合型です。
40
+ */
41
+ export type SubmitOptions = SubmitGetOptions | SubmitPostOptions;
42
+
43
+ /**
44
+ * データを送信するための関数インターフェースです。
45
+ */
46
+ export interface ISubmit {
47
+ /**
48
+ * フォーム データを送信します。
49
+ *
50
+ * @param target 送信対象のフォームデータです。
51
+ * @param options 送信時のオプションです。
52
+ */
53
+ (target: ReadonlyFormData, options?: SubmitPostOptions | undefined): void;
54
+
55
+ /**
56
+ * URL クエリー パラメーターを送信します。
57
+ *
58
+ * @param target 送信対象の URL クエリーパラメーターです。
59
+ * @param options 送信時のオプションです。
60
+ */
61
+ (target: ReadonlyURLSearchParams, options?: SubmitGetOptions | undefined): void;
62
+
63
+ /**
64
+ * フォーム データまたは URL クエリーパラメーターを送信します。
65
+ *
66
+ * @param target 送信対象のデータです。
67
+ * @param options 送信時のオプションです。
68
+ */
69
+ (target: ReadonlyURLSearchParams | ReadonlyFormData, options?: SubmitOptions | undefined): void;
70
+ }
71
+
72
+ /**
73
+ * 命令的にデータを送信するための submit 関数を提供するフックです。
74
+ *
75
+ * @returns データを送信するための ISubmit 関数を返します。
76
+ */
77
+ export default function useSubmit(): ISubmit {
78
+ const { urlPath } = useRouteContext();
79
+ const call = useRouterContext(router => router.submit);
80
+ return React.useCallback(
81
+ function submit(target, options = {}) {
82
+ if (target instanceof FormData) {
83
+ const {
84
+ action = urlPath,
85
+ actionId,
86
+ } = options as SubmitPostOptions;
87
+ return call({
88
+ target,
89
+ action,
90
+ actionId,
91
+ });
92
+ } else {
93
+ const {
94
+ action = urlPath,
95
+ replace,
96
+ } = options as SubmitGetOptions;
97
+ return call({
98
+ target: target as URLSearchParams,
99
+ action,
100
+ history: replace
101
+ ? "replace"
102
+ : "push",
103
+ });
104
+ }
105
+ },
106
+ [
107
+ call,
108
+ urlPath,
109
+ ],
110
+ );
111
+ }
package/src/soseki.ts ADDED
@@ -0,0 +1,75 @@
1
+ export type * from "./components/action-id.jsx";
2
+ export { default as ActionId } from "./components/action-id.jsx";
3
+
4
+ export type * from "./components/browser-router.jsx";
5
+ export { default as BrowserRouter } from "./components/browser-router.jsx";
6
+
7
+ export type * from "./components/hidden-input.jsx";
8
+ export { default as HiddenInput } from "./components/hidden-input.jsx";
9
+
10
+ export type * from "./components/outlet.jsx";
11
+ export { default as Outlet } from "./components/outlet.jsx";
12
+
13
+ export type * from "./core/deferred-promise.js";
14
+ export { default as DeferredPromise } from "./core/deferred-promise.js";
15
+
16
+ export type { ErrorMeta, ErrorOptions, ISosekiErrorConstructor, Issue } from "./core/errors.js";
17
+ export {
18
+ ActionConditionError,
19
+ ActionExecutionError,
20
+ ErrorBase,
21
+ LoaderConditionError,
22
+ MultipleRedirectError,
23
+ NavigationApiNotSupportedError,
24
+ RouteContextMissingError,
25
+ RouterContextMissingError,
26
+ setErrorMessage,
27
+ UnexpectedValidationError,
28
+ UnreachableError,
29
+ ValidationErrorBase,
30
+ } from "./core/errors.js";
31
+
32
+ export type * from "./core/readonly-form-data.types.js";
33
+
34
+ export type * from "./core/readonly-url.types.js";
35
+
36
+ export type * from "./core/route-request.js";
37
+ export { default as RouteRequest } from "./core/route-request.js";
38
+
39
+ export type * from "./core/route.types.js";
40
+
41
+ export type * from "./hooks/use-action-data.js";
42
+ export { default as useActionData } from "./hooks/use-action-data.js";
43
+
44
+ export type * from "./hooks/use-loader-data.js";
45
+ export { default as useLoaderData } from "./hooks/use-loader-data.js";
46
+
47
+ export type * from "./hooks/use-navigate.js";
48
+ export { default as useNavigate } from "./hooks/use-navigate.js";
49
+
50
+ export type * from "./hooks/use-params.js";
51
+ export { default as useParams } from "./hooks/use-params.js";
52
+
53
+ export type * from "./hooks/use-pathname.js";
54
+ export { default as usePathname } from "./hooks/use-pathname.js";
55
+
56
+ export type * from "./hooks/use-submit.js";
57
+ export { default as useSubmit } from "./hooks/use-submit.js";
58
+
59
+ export type * from "./utils/get-action-id.js";
60
+ export { default as getActionId } from "./utils/get-action-id.js";
61
+
62
+ export type * from "./utils/href.js";
63
+ export { default as href } from "./utils/href.js";
64
+
65
+ export type * from "./utils/redirect.js";
66
+ export { default as redirect } from "./utils/redirect.js";
67
+
68
+ export type * from "./utils/route-index.js";
69
+ export { default as index } from "./utils/route-index.js";
70
+
71
+ export type * from "./utils/route-route.js";
72
+ export { default as route } from "./utils/route-route.js";
73
+
74
+ export type * from "./utils/set-action-id.js";
75
+ export { default as setActionId } from "./utils/set-action-id.js";
@@ -0,0 +1,12 @@
1
+ import { ACTION_ID_FORM_DATA_NAME } from "../core/constants.js";
2
+
3
+ /**
4
+ * フォームデータからアクション ID を取得します。
5
+ *
6
+ * @param formData 取得対象のフォームデータです。
7
+ * @returns 取得したアクション ID を返します。文字列でない場合や存在しない場合は null を返します。
8
+ */
9
+ export default function getActionId(formData: FormData): string | null {
10
+ const id = formData.get(ACTION_ID_FORM_DATA_NAME);
11
+ return typeof id === "string" ? id : null;
12
+ }
@@ -0,0 +1,17 @@
1
+ import { inject, type RouteParams } from "regexparam";
2
+ import type { PathParams } from "../core/route.types.js";
3
+
4
+ /**
5
+ * 指定されたパスのテンプレートにパラメーターを注入し、完全な URL パスを生成します。
6
+ *
7
+ * @template TPath パスのテンプレート文字列の型です。
8
+ * @param path パスのテンプレート文字列です(例: `/users/:id`)。
9
+ * @param params パスに注入するパラメーターのオブジェクトです。
10
+ * @returns パラメーターが注入された後の文字列を返します。
11
+ */
12
+ export default function href<const TPath extends string>(
13
+ path: TPath,
14
+ params: PathParams<TPath>,
15
+ ): string {
16
+ return inject(path, params as RouteParams<TPath>);
17
+ }
@@ -0,0 +1,13 @@
1
+ import RedirectResponse from "../core/redirect-response.js";
2
+
3
+ /**
4
+ * 指定されたパスネームへのリダイレクトを表すレスポンスオブジェクトを生成します。
5
+ *
6
+ * アクションの中で、別のページへ遷移させるために使用されます。
7
+ *
8
+ * @param pathname リダイレクト先のパスネームです。
9
+ * @returns 生成された `RedirectResponse` オブジェクトを返します。
10
+ */
11
+ export default function redirect(pathname: string): RedirectResponse {
12
+ return new RedirectResponse(pathname);
13
+ }
@@ -0,0 +1,70 @@
1
+ import type { DataFunctionObject, RouteDefinition } from "../core/route.types.js";
2
+ import route, { type RouteEntryModule, type RouteModule } from "./route-route.js";
3
+
4
+ /**
5
+ * インデックスルートの定義を構成するモジュールの型定義です。
6
+ *
7
+ * @template TPath ルートのパス(パスパターン)の型です。
8
+ */
9
+ export type IndexRouteModule<TPath extends string = string> = RouteModule<TPath>;
10
+
11
+ /**
12
+ * インデックスルートの起点となるエントリーモジュールの型定義です。
13
+ *
14
+ * @template TPath ルートのパス(パスパターン)の型です。
15
+ */
16
+ export type IndexRouteEntryModule<TPath extends string = string> = RouteEntryModule<TPath>;
17
+
18
+ /**
19
+ * 複数のモジュール配列からインデックスルート定義を生成するオーバーロードです。
20
+ *
21
+ * @template TPath ルートのパスの型です。
22
+ * @param mods エントリーモジュールと追加のデータ操作関数の配列です。
23
+ * @returns 構成されたインデックスルート定義オブジェクトを返します。
24
+ */
25
+ function index<const TPath extends string = string>(
26
+ mods: readonly [entry: IndexRouteEntryModule<TPath>, ...ui: DataFunctionObject<TPath>[]],
27
+ ): RouteDefinition<TPath>;
28
+
29
+ /**
30
+ * 単一のルートモジュールからインデックスルート定義を生成するオーバーロードです。
31
+ *
32
+ * @template TPath ルートのパスの型です。
33
+ * @param mod ルートモジュールオブジェクトです。
34
+ * @returns 構成されたインデックスルート定義オブジェクトを返します。
35
+ */
36
+ function index<const TPath extends string = string>(
37
+ mod: IndexRouteModule<TPath>,
38
+ ): RouteDefinition<TPath>;
39
+
40
+ /**
41
+ * インデックスルートモジュールまたはモジュール配列を受け取り、インデックスルート定義を生成します。
42
+ *
43
+ * @template TPath ルートのパスの型です。
44
+ * @param modOrMods ルートモジュールまたはエントリーモジュールを含む配列です。
45
+ * @param children 子ルートの配列です(インデックスルートでは通常使用されません)。
46
+ * @returns 構成されたインデックスルート定義オブジェクトを返します。
47
+ */
48
+ function index<const TPath extends string = string>(
49
+ modOrMods:
50
+ | IndexRouteModule<TPath>
51
+ | readonly [entry: IndexRouteEntryModule<TPath>, ...ui: DataFunctionObject<TPath>[]],
52
+ children: readonly RouteDefinition[],
53
+ ): RouteDefinition<TPath>;
54
+
55
+ /**
56
+ * インデックスルート定義を構築する関数の実体です。
57
+ * `route` 関数を利用して定義を生成し、`children` を明示的に `undefined` に設定します。
58
+ */
59
+ function index(
60
+ arg0:
61
+ | IndexRouteModule
62
+ | readonly [entry: IndexRouteEntryModule, ...ui: DataFunctionObject[]],
63
+ ): RouteDefinition {
64
+ // route 関数でベースとなる定義を生成した後、子ルートを持たないように上書きします。
65
+ return Object.assign(route(arg0, []), {
66
+ children: undefined,
67
+ });
68
+ }
69
+
70
+ export default index;
@@ -0,0 +1,111 @@
1
+ import type { DataFunctionObject, RouteDefinition } from "../core/route.types.js";
2
+
3
+ /**
4
+ * ルートの定義を構成するモジュールの型定義です。
5
+ *
6
+ * @template TPath ルートのパス(パスパターン)の型です。
7
+ */
8
+ export type RouteModule<TPath extends string = string> = DataFunctionObject<TPath> & {
9
+ /**
10
+ * ルートのパス(パスパターン)です。
11
+ */
12
+ readonly path: TPath;
13
+
14
+ /**
15
+ * このルートで描画される React コンポーネントです。
16
+ */
17
+ readonly default?: React.ComponentType<{}> | undefined;
18
+ };
19
+
20
+ /**
21
+ * ルートの起点となるエントリーモジュールの型定義です。
22
+ *
23
+ * データ操作関数の定義を直接含みます。
24
+ *
25
+ * @template TPath ルートのパス(パスパターン)の型です。
26
+ */
27
+ export type RouteEntryModule<TPath extends string = string> = DataFunctionObject<TPath> & {
28
+ /**
29
+ * ルートのパス(パスパターン)です。
30
+ */
31
+ readonly path: TPath;
32
+
33
+ /**
34
+ * このルートで描画される React コンポーネントです。
35
+ */
36
+ readonly default?: React.ComponentType<{}> | undefined;
37
+ };
38
+
39
+ /**
40
+ * 複数のモジュール配列からルート定義を生成するオーバーロードです。
41
+ *
42
+ * @template TPath ルートのパスの型です。
43
+ * @param mods エントリーモジュールと追加のデータ操作関数の配列です。
44
+ * @param children 子ルートの配列です。
45
+ * @returns 構成されたルート定義オブジェクトを返します。
46
+ */
47
+ function route<const TPath extends string = string>(
48
+ mods: readonly [entry: RouteEntryModule<TPath>, ...ui: DataFunctionObject<TPath>[]],
49
+ children: readonly RouteDefinition[],
50
+ ): RouteDefinition<TPath>;
51
+
52
+ /**
53
+ * 単一のルートモジュールからルート定義を生成するオーバーロードです。
54
+ *
55
+ * @template TPath ルートのパスの型です。
56
+ * @param mod ルートモジュールオブジェクトです。
57
+ * @param children 子ルートの配列です。
58
+ * @returns 構成されたルート定義オブジェクトを返します。
59
+ */
60
+ function route<const TPath extends string = string>(
61
+ mod: RouteModule<TPath>,
62
+ children: readonly RouteDefinition[],
63
+ ): RouteDefinition<TPath>;
64
+
65
+ /**
66
+ * ルートモジュールまたはモジュール配列を受け取り、ルート定義を生成します。
67
+ *
68
+ * @template TPath ルートのパスの型です。
69
+ * @param modOrMods ルートモジュールまたはエントリーモジュールを含む配列です。
70
+ * @param children 子ルートの配列です。
71
+ * @returns 構成されたルート定義オブジェクトを返します。
72
+ */
73
+ function route<const TPath extends string = string>(
74
+ modOrMods:
75
+ | RouteModule<TPath>
76
+ | readonly [entry: RouteEntryModule<TPath>, ...ui: DataFunctionObject<TPath>[]],
77
+ children: readonly RouteDefinition[],
78
+ ): RouteDefinition<TPath>;
79
+
80
+ /**
81
+ * ルート定義を構築する関数の実体です。
82
+ */
83
+ function route(
84
+ modOrMods:
85
+ | RouteModule
86
+ | readonly [entry: RouteEntryModule, ...ui: DataFunctionObject[]],
87
+ children: readonly RouteDefinition[],
88
+ ): RouteDefinition {
89
+ // 配列形式で渡された場合の処理です。
90
+ if (Array.isArray(modOrMods)) {
91
+ const mods = modOrMods;
92
+ const [entry, ...ui] = mods;
93
+ return {
94
+ path: entry.path,
95
+ children,
96
+ component: entry.default,
97
+ dataFunctions: [entry, ...ui],
98
+ };
99
+ }
100
+
101
+ // オブジェクト形式で渡された場合の処理です。
102
+ const mod = modOrMods;
103
+ return {
104
+ path: mod.path,
105
+ children,
106
+ component: mod.default,
107
+ dataFunctions: [mod],
108
+ };
109
+ }
110
+
111
+ export default route;
@@ -0,0 +1,14 @@
1
+ import actionIdRegistry from "../core/_action-id-registry.js";
2
+ import { ACTION_ID_FORM_DATA_NAME } from "../core/constants.js";
3
+ import type { IAction } from "../core/route.types.js";
4
+
5
+ /**
6
+ * フォームデータに対してアクション ID を発行し、設定します。
7
+ *
8
+ * @param formData アクション ID を設定する対象のフォームデータです。
9
+ * @param action アクション関数です。
10
+ */
11
+ export default function setActionId(formData: FormData, action: IAction): void {
12
+ const id = actionIdRegistry.set(action);
13
+ formData.set(ACTION_ID_FORM_DATA_NAME, id);
14
+ }
@@ -0,0 +1,46 @@
1
+ import { test, vi } from "vitest";
2
+ import captureStackTrace from "../../src/core/_capture-stack-trace.js";
3
+
4
+ test("Error.captureStackTrace が定義されている環境で実行したとき、対象のオブジェクトにスタックトレースが設定される", ({ expect }) => {
5
+ // Arrange
6
+ const targetObject = {};
7
+ const constructorOpt = () => {};
8
+ // @ts-expect-error グローバルの型系義に captureStackTrace メソッドがない
9
+ const mockCapture = vi.spyOn(Error, "captureStackTrace");
10
+
11
+ try {
12
+ // Act
13
+ captureStackTrace(targetObject, constructorOpt);
14
+
15
+ // Assert
16
+ expect(mockCapture).toHaveBeenCalledWith(targetObject, constructorOpt);
17
+ } finally {
18
+ mockCapture.mockRestore();
19
+ }
20
+ });
21
+
22
+ test("Error.captureStackTrace が未定義の環境で実行したとき、エラーを投げずに正常終了する", ({ expect }) => {
23
+ // Arrange
24
+ const targetObject = {};
25
+ const constructorOpt = () => {};
26
+
27
+ // Error.captureStackTrace を一時的に削除して、非サポート環境をシミュレートする
28
+ // @ts-expect-error グローバルの型系義に captureStackTrace メソッドがない
29
+ const originalCaptureStackTrace = Error.captureStackTrace;
30
+ // @ts-expect-error: テストのためにプロパティを削除する
31
+ delete Error.captureStackTrace;
32
+
33
+ try {
34
+ // Act & Assert
35
+ // 例外が発生せず、undefined が返る(void 関数の正常終了)ことを確認する
36
+ expect(() => {
37
+ captureStackTrace(targetObject, constructorOpt);
38
+ })
39
+ .not
40
+ .toThrow();
41
+ } finally {
42
+ // 環境を元に戻す
43
+ // @ts-expect-error グローバルの型系義に captureStackTrace メソッドがない
44
+ Error.captureStackTrace = originalCaptureStackTrace;
45
+ }
46
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, test } from "vitest";
2
+ import compareRoutePaths from "../../src/core/_compare-route-paths.js";
3
+
4
+ describe("静的なパスと動的なパスの比較", () => {
5
+ test("静的なパスが、パラメーターを含むパスより優先されるとき、戻り値が負数になる", ({ expect }) => {
6
+ // Arrange
7
+ const staticPath = "/books/horror";
8
+ const paramPath = "/books/:genre";
9
+
10
+ // Act
11
+ const result = compareRoutePaths(staticPath, paramPath);
12
+
13
+ // Assert
14
+ expect(result).toBeLessThan(0);
15
+ });
16
+
17
+ test("パラメーターを含むパスが、ワイルドカードを含むパスより優先されるとき、戻り値が負数になる", ({ expect }) => {
18
+ // Arrange
19
+ const paramPath = "/books/:genre";
20
+ const wildcardPath = "/*";
21
+
22
+ // Act
23
+ const result = compareRoutePaths(paramPath, wildcardPath);
24
+
25
+ // Assert
26
+ expect(result).toBeLessThan(0);
27
+ });
28
+ });
29
+
30
+ describe("パラメーターの具体性による比較", () => {
31
+ test("必須パラメーターが、任意パラメーターより優先されるとき、戻り値が負数になる", ({ expect }) => {
32
+ // Arrange
33
+ const requiredParam = "/books/:genre";
34
+ const optionalParam = "/books/:genre?";
35
+
36
+ // Act
37
+ const result = compareRoutePaths(requiredParam, optionalParam);
38
+
39
+ // Assert
40
+ expect(result).toBeLessThan(0);
41
+ });
42
+
43
+ test("任意パラメーターが、ワイルドカードより優先されるとき、戻り値が負数になる", ({ expect }) => {
44
+ // Arrange
45
+ const optionalParam = "/users/:id?";
46
+ const wildcard = "/users/*";
47
+
48
+ // Act
49
+ const result = compareRoutePaths(optionalParam, wildcard);
50
+
51
+ // Assert
52
+ expect(result).toBeLessThan(0);
53
+ });
54
+ });
55
+
56
+ describe("パスの深さによる比較", () => {
57
+ test("より深い階層を持つ静的パスが、浅い静的パスより優先されるとき、戻り値が負数になる", ({ expect }) => {
58
+ // Arrange
59
+ const deepPath = "/books/horror/goosebumps";
60
+ const shallowPath = "/books/horror";
61
+
62
+ // Act
63
+ const result = compareRoutePaths(deepPath, shallowPath);
64
+
65
+ // Assert
66
+ expect(result).toBeLessThan(0);
67
+ });
68
+
69
+ test("同じ深さでスコアが等しいとき、文字列の辞書順で比較される", ({ expect }) => {
70
+ // Arrange
71
+ const pathA = "/books/apple";
72
+ const pathB = "/books/banana";
73
+
74
+ // Act
75
+ const result = compareRoutePaths(pathA, pathB);
76
+
77
+ // Assert
78
+ // 'apple' は 'banana' より辞書順で前のため、負数を期待する
79
+ expect(result).toBeLessThan(0);
80
+ });
81
+ });
82
+
83
+ describe("複雑なルート群のソート結果の検証", () => {
84
+ test("複数のルートをソートしたとき、仕様で定義された優先順位に従って並ぶ", ({ expect }) => {
85
+ // Arrange
86
+ const routes = [
87
+ "/*",
88
+ "/books/:genre",
89
+ "/books/horror",
90
+ "/books/:genre/:title?",
91
+ "/users/*?",
92
+ "/books/horror/goosebumps",
93
+ ];
94
+
95
+ // Act
96
+ const sorted = [...routes].sort(compareRoutePaths);
97
+
98
+ // Assert
99
+ const expected = [
100
+ "/books/horror/goosebumps", // 最も深い静的パス
101
+ "/books/horror", // 静的パス
102
+ "/books/:genre/:title?", // 深いパラメーター
103
+ "/books/:genre", // パラメーター
104
+ "/users/*?", // 任意ワイルドカード
105
+ "/*", // 必須ワイルドカード
106
+ ];
107
+ expect(sorted).toStrictEqual(expected);
108
+ });
109
+ });
110
+
111
+ describe("エッジケース", () => {
112
+ test("全く同じパスを比較したとき、0 を返す", ({ expect }) => {
113
+ // Arrange
114
+ const path = "/books/:genre";
115
+
116
+ // Act
117
+ const result = compareRoutePaths(path, path);
118
+
119
+ // Assert
120
+ expect(result).toBe(0);
121
+ });
122
+
123
+ test("末尾のスラッシュの有無に関わらず、セグメントの内容が同じであれば同一とみなす", ({ expect }) => {
124
+ // Arrange
125
+ const pathA = "/books/horror";
126
+ const pathB = "/books/horror/";
127
+
128
+ // Act
129
+ const result = compareRoutePaths(pathA, pathB);
130
+
131
+ // Assert
132
+ expect(result).toBe(0);
133
+ });
134
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, test } from "vitest";
2
+ import encodePathname from "../../src/core/_encode-pathname.js";
3
+
4
+ describe("パスの正規化に関する振る舞い", () => {
5
+ test("スラッシュから始まるパス名を渡したとき、そのままの形で返る", ({ expect }) => {
6
+ // Arrange
7
+ const pathname = "/foo/bar";
8
+
9
+ // Act
10
+ const result = encodePathname(pathname);
11
+
12
+ // Assert
13
+ expect(result).toBe("/foo/bar");
14
+ });
15
+
16
+ test("スラッシュから始まらないパス名を渡したとき、先頭にスラッシュが付与される", ({ expect }) => {
17
+ // Arrange
18
+ const pathname = "foo/bar";
19
+
20
+ // Act
21
+ const result = encodePathname(pathname);
22
+
23
+ // Assert
24
+ expect(result).toBe("/foo/bar");
25
+ });
26
+
27
+ test("連続したスラッシュが含まれる場合、それらは 1 にまとめられる", ({ expect }) => {
28
+ // Arrange
29
+ const input = "///assets//images///logo.png";
30
+
31
+ // Act
32
+ const result = encodePathname(input);
33
+
34
+ // Assert
35
+ expect(result).toBe("/assets/images/logo.png");
36
+ });
37
+ });
38
+
39
+ describe("URL エンコードに関する振る舞い", () => {
40
+ test("日本語の文字列が含まれる場合、パーセントエンコーディングされたパスが返る", ({ expect }) => {
41
+ // Arrange
42
+ const input = "/ディレクトリ/ファイル.txt";
43
+
44
+ // Act
45
+ const result = encodePathname(input);
46
+
47
+ // Assert
48
+ expect(result).toBe(
49
+ "/%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%88%E3%83%AA/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.txt",
50
+ );
51
+ });
52
+
53
+ test("特殊文字やスペースが含まれる場合、適切にエンコードされたパスが返る", ({ expect }) => {
54
+ // Arrange
55
+ const input = "/path with spaces/test?query";
56
+
57
+ // Act
58
+ const result = encodePathname(input);
59
+
60
+ // Assert
61
+ // URL オブジェクトの仕様に基づき、 '?' 以降は query と見なされ pathname からは除外される振る舞いを検証
62
+ expect(result).toBe("/path%20with%20spaces/test");
63
+ });
64
+
65
+ test("一部が既にエンコードされている場合、未エンコードの箇所のみがエンコードされ、二重エンコードにならない", ({ expect }) => {
66
+ // Arrange
67
+ // "テスト" はエンコード済み( %E3%83%86%E3%82%B9%E3%83%88 )、 "データ" は未エンコード
68
+ const input = "/%E3%83%86%E3%82%B9%E3%83%88/データ.txt";
69
+
70
+ // Act
71
+ const result = encodePathname(input);
72
+
73
+ // Assert
74
+ // 既にエンコードされた部分は維持され、 "データ" のみがエンコードされることを確認
75
+ expect(result).toBe("/%E3%83%86%E3%82%B9%E3%83%88/%E3%83%87%E3%83%BC%E3%82%BF.txt");
76
+ });
77
+ });