@gaonjs/vue 0.1.4 → 0.3.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/dist/api.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Serialized } from './serialize.js';
2
+ /**
3
+ * 라우트 매니페스트 — .gaon/routes.d.ts 가 채우는 GaonRouteMap 옆에 함께
4
+ * 채워지는 런타임/타입 지도. 키는 'controller#action'.
5
+ *
6
+ * pageProps 는 타입만 알면 되지만 api() 는 실제 HTTP 메서드·경로를 알아야
7
+ * 요청을 보낼 수 있다 — 그래서 라우트 지도가 필요하다. 생성기가 declaration
8
+ * merging + 값 export 로 함께 채운다(추가 생성 파일 X · 기존 파일에 병합).
9
+ */
10
+ export interface GaonRouteInfo {
11
+ readonly method: 'get' | 'post' | 'put' | 'patch' | 'delete';
12
+ readonly path: string;
13
+ }
14
+ declare global {
15
+ interface GaonRoutes {
16
+ }
17
+ }
18
+ /** GaonRouteMap[K] (액션 함수) 에서 render 결과 P 를 뽑아 낸다 — pageProps 와 동일. */
19
+ type PropsOf<F> = F extends (...args: never[]) => infer R ? Awaited<R> extends {
20
+ page: string;
21
+ props: infer P;
22
+ } ? P : Awaited<R> : never;
23
+ /**
24
+ * 요청 파라미터 — 라우트에 :id 같은 자리표시자가 있으면 여기서 뽑고, 나머지는
25
+ * GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 붙는다. 값은 문자열
26
+ * 화된다(경로/쿼리 특성).
27
+ */
28
+ export type ApiParams = Record<string, string | number | bigint | boolean | undefined | null>;
29
+ export interface ApiOptions {
30
+ /** 기본 http 헤더에 얹어 보낸다(Authorization 등). */
31
+ readonly headers?: Record<string, string>;
32
+ /** AbortController 지원(취소·타임아웃). */
33
+ readonly signal?: AbortSignal;
34
+ /** URL prefix (앱 프리픽스가 web 이 아니면 지정). 생략 시 window.origin. */
35
+ readonly baseUrl?: string;
36
+ /** fetch 구현 주입(테스트·SSR). 생략 시 globalThis.fetch. */
37
+ readonly fetch?: typeof fetch;
38
+ }
39
+ /**
40
+ * 라우트 키로 서버 액션을 부른다.
41
+ * · 반환 타입: 액션 반환의 Serialized<> (직렬화 경계 재사용).
42
+ * · 요청: 메서드는 라우트 정의에서, 경로는 :param 을 채워 완성.
43
+ * · 실패: 4xx/5xx 는 예외로 던지고, ValidationError(422)는 issues 를 포함한다.
44
+ */
45
+ export declare function api<K extends keyof GaonRouteMap>(key: K, params?: ApiParams, opts?: ApiOptions): Promise<Serialized<PropsOf<GaonRouteMap[K]>>>;
46
+ /**
47
+ * 라우트 매니페스트를 등록한다 — 프론트엔드 부트스트랩(app 엔트리)이 한 번
48
+ * 호출하면 이후 api() 가 method·path 를 안다. 앱 빌드 시 .gaon/routes.d.ts
49
+ * 와 함께 생성되는 매니페스트 모듈이 이 함수를 호출한다.
50
+ */
51
+ export declare function registerRoutes(routes: Record<string, GaonRouteInfo>): void;
52
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,127 @@
1
+ // @gaonjs/vue · api() — 타입드 클라이언트 (errata E-3 §C)
2
+ //
3
+ // pageProps 와 **같은 .gaon/routes.d.ts 브리지**를 재사용한다 — 추가 생성 파일
4
+ // 없음. 라우트 키 하나로 서버 액션의 반환 타입을 클라이언트에서 그대로 안다.
5
+ //
6
+ // import { api } from 'gaonjs/vue'
7
+ // const { results } = await api('posts#search', { q: keyword.value })
8
+ //
9
+ // 반환 타입은 액션 반환 타입의 **Serialized<>** (M4 render props 직렬화와 같은
10
+ // 규칙 · 같은 매핑 재사용). 별도 직렬화 규칙을 두지 않는다(The One Way).
11
+ //
12
+ // 두 번째 인자는 라우트 파라미터 + 쿼리(HTTP 메서드는 라우트 정의에서 결정).
13
+ // 라우트에 `:id` 처럼 자리표시자가 있으면 params 에서 뽑아 경로에 채우고,
14
+ // 남는 값은 GET 은 쿼리스트링으로, POST/PUT/PATCH/DELETE 는 JSON 본문으로 실어
15
+ // 보낸다. 서버 쪽 this.params 병합 우선순위(라우트>body>query)와 일치한다.
16
+ // ── 런타임 ────────────────────────────────────────────────────
17
+ /**
18
+ * 라우트 키로 서버 액션을 부른다.
19
+ * · 반환 타입: 액션 반환의 Serialized<> (직렬화 경계 재사용).
20
+ * · 요청: 메서드는 라우트 정의에서, 경로는 :param 을 채워 완성.
21
+ * · 실패: 4xx/5xx 는 예외로 던지고, ValidationError(422)는 issues 를 포함한다.
22
+ */
23
+ export async function api(key, params = {}, opts = {}) {
24
+ const routes = (globalThis.__GAON_ROUTES__) ??
25
+ {};
26
+ const info = routes[key];
27
+ if (!info) {
28
+ throw new Error(`[gaonjs/vue] 라우트 '${String(key)}' 를 찾을 수 없습니다.\n` +
29
+ `→ apps/<app>/routes.ts 에 정의돼 있는지, gaon dev/check 가 .gaon/routes.d.ts 를 재생성했는지 확인하세요.`);
30
+ }
31
+ const { url, leftovers } = buildUrl(info, params);
32
+ const method = info.method.toUpperCase();
33
+ const useBody = method !== 'GET' && method !== 'HEAD';
34
+ const headers = {
35
+ Accept: 'application/json',
36
+ ...(opts.headers ?? {}),
37
+ };
38
+ let body;
39
+ let finalUrl = url;
40
+ if (useBody) {
41
+ body = JSON.stringify(leftovers);
42
+ headers['Content-Type'] = 'application/json; charset=utf-8';
43
+ // CSRF: 세션 앱은 쿠키 기반 CSRF 를 쓴다 — 프론트 부트스트랩이 심어 놓은
44
+ // 메타 태그 값을 자동으로 붙여 준다(있으면).
45
+ const csrf = readCsrfToken();
46
+ if (csrf)
47
+ headers['X-CSRF-Token'] = csrf;
48
+ }
49
+ else if (Object.keys(leftovers).length > 0) {
50
+ const q = new URLSearchParams();
51
+ for (const [k, v] of Object.entries(leftovers)) {
52
+ if (v === undefined || v === null)
53
+ continue;
54
+ q.append(k, String(v));
55
+ }
56
+ finalUrl = url + (url.includes('?') ? '&' : '?') + q.toString();
57
+ }
58
+ const base = opts.baseUrl ?? '';
59
+ const doFetch = opts.fetch ?? globalThis.fetch;
60
+ const res = await doFetch(base + finalUrl, {
61
+ method,
62
+ headers,
63
+ body,
64
+ signal: opts.signal,
65
+ credentials: 'same-origin',
66
+ });
67
+ if (!res.ok) {
68
+ // 422 는 서버 검증 실패(§7.5.3). 파싱 시도 후 issues 를 실어 재던진다.
69
+ let payload = undefined;
70
+ try {
71
+ payload = await res.json();
72
+ }
73
+ catch {
74
+ /* 본문 없음 */
75
+ }
76
+ const err = new Error(`[gaonjs/vue] ${String(key)} 요청 실패 (${res.status} ${res.statusText}).`);
77
+ err.status = res.status;
78
+ err.body = payload;
79
+ throw err;
80
+ }
81
+ // 204 No Content 는 빈 값으로.
82
+ if (res.status === 204)
83
+ return undefined;
84
+ const json = (await res.json());
85
+ return json;
86
+ }
87
+ /**
88
+ * 라우트 매니페스트를 등록한다 — 프론트엔드 부트스트랩(app 엔트리)이 한 번
89
+ * 호출하면 이후 api() 가 method·path 를 안다. 앱 빌드 시 .gaon/routes.d.ts
90
+ * 와 함께 생성되는 매니페스트 모듈이 이 함수를 호출한다.
91
+ */
92
+ export function registerRoutes(routes) {
93
+ const g = globalThis;
94
+ g.__GAON_ROUTES__ = { ...(g.__GAON_ROUTES__ ?? {}), ...routes };
95
+ }
96
+ // ── 내부 ──────────────────────────────────────────────────────
97
+ function buildUrl(info, params) {
98
+ const leftovers = {};
99
+ let url = info.path;
100
+ // 사용된 자리표시자는 leftovers 에서 빼고, 남는 값은 쿼리/본문에 실린다.
101
+ const used = new Set();
102
+ url = url.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name) => {
103
+ used.add(name);
104
+ const v = params[name];
105
+ if (v === undefined || v === null) {
106
+ throw new Error(`[gaonjs/vue] 라우트 파라미터 ':${name}' 값이 없습니다 (path=${info.path}).\n` +
107
+ `→ api('${info.method} ${info.path}', { ${name}: ... }) 처럼 넘기세요.`);
108
+ }
109
+ return encodeURIComponent(String(v));
110
+ });
111
+ for (const [k, v] of Object.entries(params)) {
112
+ if (used.has(k) || v === undefined || v === null)
113
+ continue;
114
+ leftovers[k] = String(v);
115
+ }
116
+ return { url, leftovers };
117
+ }
118
+ function readCsrfToken() {
119
+ const doc = globalThis.document;
120
+ if (!doc)
121
+ return undefined;
122
+ const el = doc.querySelector('meta[name="csrf-token"]');
123
+ if (!el)
124
+ return undefined;
125
+ const c = el.getAttribute('content');
126
+ return c ?? undefined;
127
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,6 @@ export declare const version: string;
2
2
  export declare const status: "planned";
3
3
  export { pageProps } from "./pageProps.js";
4
4
  export { serializeProps, type Serialized } from "./serialize.js";
5
+ export { api, registerRoutes, type ApiParams, type ApiOptions, type GaonRouteInfo, } from "./api.js";
6
+ export { createGaonApp, type CreateGaonAppOptions, type PageMap } from "./runtime.js";
7
+ export { findDefaultLayout, resolvePageLayout } from "./layout.js";
package/dist/index.js CHANGED
@@ -12,3 +12,9 @@ export const status = "planned";
12
12
  export { pageProps } from "./pageProps.js";
13
13
  // 직렬화 경계 (M4) — Rec → JSON-safe 페이지 props (Date/bigint→string, hidden 제외)
14
14
  export { serializeProps } from "./serialize.js";
15
+ // 타입드 클라이언트 (errata E-3) — 라우트 키 → Serialized 반환
16
+ export { api, registerRoutes, } from "./api.js";
17
+ // Inertia SPA 부팅 (§6.4 · M3 최소 웹 레이어) — apps/<앱>/main.ts 진입점.
18
+ export { createGaonApp } from "./runtime.js";
19
+ // 레이아웃 자동 적용 (E-5 §2.3) — layouts/Default.vue 결정 로직.
20
+ export { findDefaultLayout, resolvePageLayout } from "./layout.js";
@@ -0,0 +1,17 @@
1
+ import type { Component } from 'vue';
2
+ /**
3
+ * 페이지 모듈 지도(import.meta.glob 결과)에서 `layouts/Default.vue` 를
4
+ * 뽑는다. glob 은 페이지 지도와 같은 root(main.ts)에서 만들어져 넘어오므로,
5
+ * 키는 './layouts/Default.vue' 형식이다.
6
+ */
7
+ export declare function findDefaultLayout(layouts: Record<string, unknown> | undefined): Component | undefined;
8
+ /**
9
+ * 페이지 컴포넌트에 레이아웃을 결정해 붙인다(E-5 §2.3).
10
+ * · pageLayout === null → 명시 opt-out. 그대로 null 유지.
11
+ * · pageLayout 값 존재 → 명시 지정. 그대로.
12
+ * · pageLayout === undefined → Default.vue 있으면 그것, 없으면 그대로.
13
+ *
14
+ * 반환은 실제로 붙일 레이아웃(Component) 또는 undefined(레이아웃 없음).
15
+ * 결정 로직만 담고, 붙이는 것은 호출자(runtime.createGaonApp)가 한다.
16
+ */
17
+ export declare function resolvePageLayout(pageLayout: unknown, defaultLayout: Component | undefined): Component | undefined;
package/dist/layout.js ADDED
@@ -0,0 +1,48 @@
1
+ // @gaonjs/vue · Vue 레이아웃 자동 적용 (v0.16 §6.6 · errata E-5 §2.3)
2
+ //
3
+ // 정본: "layouts/Default.vue 가 존재하면 그 앱의 모든 페이지에 자동
4
+ // 적용된다(파일 존재 = 등록 원칙). 다른 레이아웃이 필요한 페이지만
5
+ // 페이지 파일에서 명시적으로 바꾼다."
6
+ //
7
+ // 페이지 컴포넌트의 정적 프로퍼티 `layout` 이 다음 세 값을 갖는다:
8
+ // · 없음(undefined) → Default.vue 가 있으면 자동 적용, 없으면 raw.
9
+ // · null → 명시 opt-out(로그인 페이지처럼 레이아웃 없음).
10
+ // · Component → 그 컴포넌트를 명시 지정.
11
+ //
12
+ // Inertia 의 persistent layout 규약과 정합 — 페이지 컴포넌트 자체가
13
+ // `layout` 을 들고 있으면 어댑터가 페이지 전환 시 레이아웃 상태를 유지한다.
14
+ /**
15
+ * 페이지 모듈 지도(import.meta.glob 결과)에서 `layouts/Default.vue` 를
16
+ * 뽑는다. glob 은 페이지 지도와 같은 root(main.ts)에서 만들어져 넘어오므로,
17
+ * 키는 './layouts/Default.vue' 형식이다.
18
+ */
19
+ export function findDefaultLayout(layouts) {
20
+ if (!layouts)
21
+ return undefined;
22
+ const mod = layouts['./layouts/Default.vue'];
23
+ if (!mod)
24
+ return undefined;
25
+ return extractDefault(mod);
26
+ }
27
+ /**
28
+ * 페이지 컴포넌트에 레이아웃을 결정해 붙인다(E-5 §2.3).
29
+ * · pageLayout === null → 명시 opt-out. 그대로 null 유지.
30
+ * · pageLayout 값 존재 → 명시 지정. 그대로.
31
+ * · pageLayout === undefined → Default.vue 있으면 그것, 없으면 그대로.
32
+ *
33
+ * 반환은 실제로 붙일 레이아웃(Component) 또는 undefined(레이아웃 없음).
34
+ * 결정 로직만 담고, 붙이는 것은 호출자(runtime.createGaonApp)가 한다.
35
+ */
36
+ export function resolvePageLayout(pageLayout, defaultLayout) {
37
+ if (pageLayout === null)
38
+ return undefined;
39
+ if (pageLayout !== undefined)
40
+ return pageLayout;
41
+ return defaultLayout;
42
+ }
43
+ function extractDefault(mod) {
44
+ if (mod && typeof mod === 'object' && 'default' in mod) {
45
+ return mod.default;
46
+ }
47
+ return mod;
48
+ }
@@ -0,0 +1,42 @@
1
+ import { type GaonRouteInfo } from './api.js';
2
+ /**
3
+ * import.meta.glob 이 반환하는 페이지 지도.
4
+ * 값은 즉시(eager) 로드된 모듈이거나 지연 로드 함수다 — 둘 다 지원.
5
+ */
6
+ export type PageMap = Record<string, unknown | (() => Promise<unknown>)>;
7
+ export interface CreateGaonAppOptions {
8
+ /**
9
+ * 페이지 모듈 지도. main.ts 에서
10
+ * `import.meta.glob('./pages/**\/*.vue')` (지연 로드)
11
+ * 또는
12
+ * `import.meta.glob('./pages/**\/*.vue', { eager: true })`
13
+ * 로 만들어 넘긴다. 키는 './pages/<name>.vue' 형식.
14
+ */
15
+ readonly pages: PageMap;
16
+ /**
17
+ * 레이아웃 모듈 지도. Default.vue 가 있으면 자동 적용(E-5 §2.3).
18
+ * `import.meta.glob('./layouts/*.vue', { eager: true })`
19
+ * eager 인 편이 편하다 — 레이아웃은 소수·최초 진입에도 필요.
20
+ */
21
+ readonly layouts?: PageMap;
22
+ /**
23
+ * 앱의 라우트 매니페스트(method + path). api() 클라이언트가 소비한다
24
+ * (E-3 §3). .gaon/routes.manifest.ts 가 생성해 이 옵션으로 넘긴다.
25
+ * 미지정 시 api() 는 라우트 조회에서 명확한 에러를 던진다.
26
+ */
27
+ readonly routes?: Record<string, GaonRouteInfo>;
28
+ /** 브라우저 탭 타이틀 조합자. Inertia 옵션 그대로 통과. */
29
+ readonly title?: (title: string) => string;
30
+ /** Inertia 진행 표시자 옵션(색상·delay 등). false 로 끌 수 있다. */
31
+ readonly progress?: false | {
32
+ readonly color?: string;
33
+ readonly delay?: number;
34
+ readonly includeCSS?: boolean;
35
+ readonly showSpinner?: boolean;
36
+ };
37
+ }
38
+ /**
39
+ * Inertia SPA 부팅(브라우저 전용). 페이지·레이아웃 지도와 라우트 매니페스트를
40
+ * 받아 Vue 앱을 마운트한다. 서버는 apps/<앱>/main.ts 에서 이 함수를 부른다.
41
+ */
42
+ export declare function createGaonApp(opts: CreateGaonAppOptions): Promise<void>;
@@ -0,0 +1,68 @@
1
+ /// <reference lib="dom" />
2
+ // @gaonjs/vue · Vue 3 클라이언트 런타임 (v0.16 §6.4 · M3 최소 웹 레이어)
3
+ //
4
+ // 이 파일은 브라우저에서 Inertia SPA 를 부팅한다:
5
+ // 1) apps/<앱>/main.ts 가 페이지·레이아웃 지도를 만든다(Vite 정합).
6
+ // 2) `createGaonApp` 에 지도와 라우트 매니페스트를 넘긴다.
7
+ // 3) 내부에서 @inertiajs/vue3 의 createInertiaApp 을 호출해 마운트.
8
+ //
9
+ // 정본 준수:
10
+ // · Inertia SPA + 서버 라우팅 · **SSR 아님** (§6.1 · CLAUDE.md 규칙 12).
11
+ // · 자동 import 금지 — 페이지·레이아웃 지도는 사용자 코드가 명시적으로
12
+ // import.meta.glob 으로 만들어 넘긴다(E-5 §2.4).
13
+ // · `layouts/Default.vue` 존재 = 자동 적용(E-5 §2.3, ./layout.ts).
14
+ //
15
+ // 서버 응답의 data-page (packages/web/src/inertia.ts) 를 Inertia 어댑터가
16
+ // 읽어 마운트한다 — 초기 페이지 · 이후 SPA 네비게이션 모두 같은 경로.
17
+ import { createApp, h } from 'vue';
18
+ import { createInertiaApp } from '@inertiajs/vue3';
19
+ import { registerRoutes } from './api.js';
20
+ import { findDefaultLayout, resolvePageLayout } from './layout.js';
21
+ /**
22
+ * Inertia SPA 부팅(브라우저 전용). 페이지·레이아웃 지도와 라우트 매니페스트를
23
+ * 받아 Vue 앱을 마운트한다. 서버는 apps/<앱>/main.ts 에서 이 함수를 부른다.
24
+ */
25
+ export async function createGaonApp(opts) {
26
+ if (opts.routes)
27
+ registerRoutes(opts.routes);
28
+ const defaultLayout = findDefaultLayout(opts.layouts);
29
+ await createInertiaApp({
30
+ title: opts.title,
31
+ progress: opts.progress,
32
+ resolve: async (name) => {
33
+ const key = `./pages/${name}.vue`;
34
+ const entry = opts.pages[key];
35
+ if (entry === undefined) {
36
+ // 에러가 곧 수리 안내서(§7.5.3): 어디 파일을 만들어야 하는지 말해준다.
37
+ throw new Error(`[gaonjs/vue] Inertia 페이지 '${name}' 를 찾을 수 없습니다 (looked up '${key}').\n` +
38
+ `→ apps/<앱>/pages/${name}.vue 를 만들거나, 컨트롤러의 this.render('${name}', ...) 인자를 확인하세요.`);
39
+ }
40
+ const mod = await loadPageModule(entry);
41
+ const page = mod.default;
42
+ // 레이아웃 자동 적용(E-5 §2.3) — 페이지가 layout 을 명시 안 했으면 Default.
43
+ const chosen = resolvePageLayout(page.layout, defaultLayout);
44
+ if (chosen !== undefined) {
45
+ ;
46
+ page.layout = chosen;
47
+ }
48
+ else if (page.layout === null) {
49
+ // 명시 opt-out 은 그대로 null 로 남긴다 — Inertia 는 null 을 "레이아웃 없음"으로 취급.
50
+ }
51
+ return mod;
52
+ },
53
+ setup({ el, App, props, plugin }) {
54
+ if (el === null)
55
+ return;
56
+ createApp({ render: () => h(App, props) })
57
+ .use(plugin)
58
+ .mount(el);
59
+ },
60
+ });
61
+ }
62
+ async function loadPageModule(entry) {
63
+ if (typeof entry === 'function') {
64
+ const loaded = await entry();
65
+ return loaded;
66
+ }
67
+ return entry;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/vue",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Gaon 프론트엔드 어댑터: Vue 3 · Inertia 브리지 · 타입 전파 · SSR (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,11 +23,15 @@
23
23
  "dist",
24
24
  "README.md"
25
25
  ],
26
- "dependencies": {
27
- "@gaonjs/core": "0.1.3",
28
- "@gaonjs/web": "0.2.0"
29
- },
30
26
  "scripts": {
31
27
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
28
+ },
29
+ "dependencies": {
30
+ "@gaonjs/core": "workspace:*",
31
+ "@gaonjs/web": "workspace:*",
32
+ "@inertiajs/vue3": "^3.6.1"
33
+ },
34
+ "peerDependencies": {
35
+ "vue": "^3.5.0"
32
36
  }
33
- }
37
+ }