@gaonjs/cli 0.23.0 → 0.25.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/README.md +10 -3
- package/dist/commands/db.js +2 -2
- package/dist/db.d.ts +7 -2
- package/dist/db.js +30 -17
- package/dist/doctor/async-offload.d.ts +23 -0
- package/dist/doctor/async-offload.js +236 -0
- package/dist/doctor/page-layout-breakpoint.d.ts +8 -0
- package/dist/doctor/page-layout-breakpoint.js +94 -0
- package/dist/doctor/pageprops-destructure.d.ts +5 -0
- package/dist/doctor/pageprops-destructure.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor/ui-kit-wiring.js +11 -7
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +17 -2
- package/dist/index.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +12 -11
- package/dist/templates/auth/Login.vue.tpl +18 -16
- package/dist/templates/auth/Signup.vue.tpl +16 -15
- package/dist/templates/project/AGENTS.md.tpl +25 -2
- package/dist/templates/project/agents/async.md.tpl +55 -0
- package/dist/templates/project/agents/data.md.tpl +31 -0
- package/dist/templates/project/agents/frontend.md.tpl +76 -25
- package/dist/templates/project/agents/web.md.tpl +20 -7
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/apps/web/style.css.tpl +62 -41
- package/dist/templates/project/tsconfig.json.tpl +5 -1
- package/dist/templates/project/vite.config.ts.tpl +16 -0
- package/dist/templates/ui-kit/EmptyState.vue.tpl +23 -0
- package/dist/templates/ui-kit/PageHeader.vue.tpl +25 -0
- package/dist/templates/ui-kit/PageShell.vue.tpl +27 -0
- package/dist/templates/ui-kit/Pagination.vue.tpl +60 -0
- package/dist/templates/ui-kit/utils.ts.tpl +1 -1
- package/dist/uikit.d.ts +4 -4
- package/dist/uikit.js +56 -22
- package/package.json +6 -6
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import { pageProps } from 'gaonjs/vue'
|
|
4
4
|
import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
|
|
5
|
-
import Card from '
|
|
6
|
-
import Badge from '
|
|
5
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
6
|
+
import Badge from '@shared/components/ui/Badge.vue'
|
|
7
7
|
|
|
8
8
|
// home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
|
|
9
9
|
// 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
|
|
@@ -230,7 +230,7 @@ const routeLines = computed<CodeLine[]>(() => {
|
|
|
230
230
|
|
|
231
231
|
<p class="mt-8 text-center text-xs text-muted-foreground">
|
|
232
232
|
<Badge variant="secondary">UI 킷</Badge>
|
|
233
|
-
이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 —
|
|
233
|
+
이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — shared/components/ui/ 에서 소유·수정하세요.
|
|
234
234
|
</p>
|
|
235
235
|
</div>
|
|
236
236
|
</template>
|
|
@@ -6,53 +6,63 @@
|
|
|
6
6
|
* :root/.dark 의 CSS 변수 = 디자인 토큰(shadcn 관례). UI 킷 컴포넌트는 이
|
|
7
7
|
* 토큰(bg-primary·text-muted-foreground …)만 참조하므로, 브랜드 색을 바꾸려면
|
|
8
8
|
* 여기 한 곳만 고친다. hsl 채널 값으로 두는 이유는 tailwind.config.ts 가
|
|
9
|
-
* hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다.
|
|
9
|
+
* hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다.
|
|
10
|
+
*
|
|
11
|
+
* 결정 107: 토큰(:root·.dark)은 @layer base **밖**에 둔다 — @layer 안의 클래스
|
|
12
|
+
* 선택자 규칙(.dark)은 content 에 'dark' 문자열이 없으면 Tailwind 가 purge 해
|
|
13
|
+
* 다크 모드가 조용히 안 됐다(:root 등 요소 선택자는 남지만 .dark 는 사라짐).
|
|
14
|
+
* 토큰은 CSS 변수만 정의하므로 layer 밖이어도 유틸 속성과 충돌하지 않는다. */
|
|
10
15
|
@tailwind base;
|
|
11
16
|
@tailwind components;
|
|
12
17
|
@tailwind utilities;
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
:root {
|
|
20
|
+
--background: 0 0% 100%;
|
|
21
|
+
--foreground: 240 10% 3.9%;
|
|
22
|
+
--card: 0 0% 100%;
|
|
23
|
+
--card-foreground: 240 10% 3.9%;
|
|
24
|
+
--primary: 240 5.9% 10%;
|
|
25
|
+
--primary-foreground: 0 0% 98%;
|
|
26
|
+
--secondary: 240 4.8% 95.9%;
|
|
27
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
28
|
+
--muted: 240 4.8% 95.9%;
|
|
29
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
30
|
+
--accent: 240 4.8% 95.9%;
|
|
31
|
+
--accent-foreground: 240 5.9% 10%;
|
|
32
|
+
--destructive: 0 84.2% 60.2%;
|
|
33
|
+
--destructive-foreground: 0 0% 98%;
|
|
34
|
+
--border: 240 5.9% 90%;
|
|
35
|
+
--input: 240 5.9% 90%;
|
|
36
|
+
--ring: 240 5.9% 10%;
|
|
37
|
+
--radius: 0.5rem;
|
|
38
|
+
/* 모바일·반응형 토큰(결정 107). tap-target-min = 터치 최소 타깃(권장 44px) ·
|
|
39
|
+
font-size-min = 입력·본문 최소 글꼴(iOS 가 16px 미만 입력에 포커스하면
|
|
40
|
+
화면을 자동 확대하는 것을 막는 값). 라이트/다크 공통이라 :root 한 곳에 둔다. */
|
|
41
|
+
--tap-target-min: 44px;
|
|
42
|
+
--font-size-min: 16px;
|
|
43
|
+
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
.dark {
|
|
46
|
+
--background: 240 10% 3.9%;
|
|
47
|
+
--foreground: 0 0% 98%;
|
|
48
|
+
--card: 240 10% 3.9%;
|
|
49
|
+
--card-foreground: 0 0% 98%;
|
|
50
|
+
--primary: 0 0% 98%;
|
|
51
|
+
--primary-foreground: 240 5.9% 10%;
|
|
52
|
+
--secondary: 240 3.7% 15.9%;
|
|
53
|
+
--secondary-foreground: 0 0% 98%;
|
|
54
|
+
--muted: 240 3.7% 15.9%;
|
|
55
|
+
--muted-foreground: 240 5% 64.9%;
|
|
56
|
+
--accent: 240 3.7% 15.9%;
|
|
57
|
+
--accent-foreground: 0 0% 98%;
|
|
58
|
+
--destructive: 0 62.8% 30.6%;
|
|
59
|
+
--destructive-foreground: 0 0% 98%;
|
|
60
|
+
--border: 240 3.7% 15.9%;
|
|
61
|
+
--input: 240 3.7% 15.9%;
|
|
62
|
+
--ring: 240 4.9% 83.9%;
|
|
63
|
+
}
|
|
55
64
|
|
|
65
|
+
@layer base {
|
|
56
66
|
* {
|
|
57
67
|
border-color: hsl(var(--border));
|
|
58
68
|
}
|
|
@@ -63,4 +73,15 @@
|
|
|
63
73
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
64
74
|
-webkit-font-smoothing: antialiased;
|
|
65
75
|
}
|
|
76
|
+
|
|
77
|
+
/* 모바일(≤640px): 입력 글꼴이 16px 미만이면 iOS 가 포커스 시 화면을 확대한다.
|
|
78
|
+
UI 킷 Input 은 text-sm(14px)이라 좁은 화면에서만 최소 글꼴을 보장한다(결정 107).
|
|
79
|
+
데스크톱은 원래 text-sm 을 그대로 둔다. */
|
|
80
|
+
@media (max-width: 640px) {
|
|
81
|
+
input,
|
|
82
|
+
textarea,
|
|
83
|
+
select {
|
|
84
|
+
font-size: var(--font-size-min);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
66
87
|
}
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
"resolveJsonModule": true,
|
|
12
12
|
"noEmit": true,
|
|
13
13
|
"jsx": "preserve",
|
|
14
|
-
"types": ["node", "vite/client"]
|
|
14
|
+
"types": ["node", "vite/client"],
|
|
15
|
+
"baseUrl": ".",
|
|
16
|
+
"paths": {
|
|
17
|
+
"@shared/*": ["./shared/*"]
|
|
18
|
+
}
|
|
15
19
|
},
|
|
16
20
|
"include": [
|
|
17
21
|
"apps/**/*.ts",
|
|
@@ -7,13 +7,29 @@
|
|
|
7
7
|
// 앱이 하나 이상이면 각 앱마다 vite.config.ts 를 두는 것이 아니라, 이 루트
|
|
8
8
|
// 파일 하나가 root 를 apps/<앱> 으로 잡고 여러 번 실행된다(gaon dev 가 앱별
|
|
9
9
|
// Vite 서버를 띄운다). 관례가 곧 배치.
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
10
11
|
import { defineConfig } from 'vite'
|
|
11
12
|
import vue from '@vitejs/plugin-vue'
|
|
12
13
|
|
|
14
|
+
// @shared → 프로젝트 루트의 shared/ (결정 105). UI 킷은 shared/components/ui 에
|
|
15
|
+
// 프로젝트당 한 벌만 있고, 앱 페이지는 @shared 로 참조한다. root 가 apps/web 이라
|
|
16
|
+
// shared 는 root 밖이므로, dev 서버가 읽을 수 있게 fs.allow 에 프로젝트 루트를 넣는다.
|
|
17
|
+
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
|
18
|
+
const sharedDir = fileURLToPath(new URL('./shared', import.meta.url))
|
|
19
|
+
|
|
13
20
|
export default defineConfig({
|
|
14
21
|
// 기본 앱은 apps/web · gaon dev 가 다른 앱에 대해 root 를 재정의한다.
|
|
15
22
|
root: 'apps/web',
|
|
16
23
|
plugins: [vue()],
|
|
24
|
+
resolve: {
|
|
25
|
+
// 앱 페이지에서 shared UI 킷을 @shared 로 참조한다(결정 105).
|
|
26
|
+
// 예) import Button from '@shared/components/ui/Button.vue'
|
|
27
|
+
alias: { '@shared': sharedDir },
|
|
28
|
+
},
|
|
29
|
+
server: {
|
|
30
|
+
// root(apps/web) 밖의 shared 를 dev 서버가 읽도록 허용한다.
|
|
31
|
+
fs: { allow: [projectRoot] },
|
|
32
|
+
},
|
|
17
33
|
build: {
|
|
18
34
|
outDir: '../../dist/web',
|
|
19
35
|
emptyOutDir: true,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · EmptyState (결정 106). 빈 목록·무결과 안내. 아이콘(슬롯)·제목·
|
|
3
|
+
// 설명·액션(슬롯)으로 조합한다. 성격 중립 — 관리자/프론트 구분 없이 전 앱에서 쓴다.
|
|
4
|
+
import { useSlots } from 'vue'
|
|
5
|
+
|
|
6
|
+
defineProps<{ title?: string; description?: string }>()
|
|
7
|
+
const slots = useSlots()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div
|
|
12
|
+
class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 py-12 text-center"
|
|
13
|
+
>
|
|
14
|
+
<div v-if="slots.icon" class="text-muted-foreground"><slot name="icon" /></div>
|
|
15
|
+
<div class="space-y-1">
|
|
16
|
+
<p class="text-sm font-medium"><slot name="title">{{ title }}</slot></p>
|
|
17
|
+
<p v-if="description || slots.description" class="text-sm text-muted-foreground">
|
|
18
|
+
<slot name="description">{{ description }}</slot>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div v-if="slots.action" class="mt-1"><slot name="action" /></div>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · PageHeader (결정 106). 페이지 상단 — 제목 + 설명 + 액션(슬롯).
|
|
3
|
+
// 모바일에선 액션이 제목 아래로 줄바꿈되고 넓은 화면에선 오른쪽에 붙는다
|
|
4
|
+
// (반응형은 블록 책임 · 결정 107). 액션 슬롯이 없으면 액션 영역은 렌더되지 않는다.
|
|
5
|
+
import { useSlots } from 'vue'
|
|
6
|
+
|
|
7
|
+
defineProps<{ title?: string; description?: string }>()
|
|
8
|
+
const slots = useSlots()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
13
|
+
<div class="min-w-0 space-y-1">
|
|
14
|
+
<h1 class="truncate text-2xl font-bold tracking-tight">
|
|
15
|
+
<slot name="title">{{ title }}</slot>
|
|
16
|
+
</h1>
|
|
17
|
+
<p v-if="description || slots.description" class="text-sm text-muted-foreground">
|
|
18
|
+
<slot name="description">{{ description }}</slot>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div v-if="slots.actions" class="flex shrink-0 flex-wrap items-center gap-2">
|
|
22
|
+
<slot name="actions" />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · PageShell (결정 106). 페이지 바깥 골격 — 최대폭·좌우 여백·세로
|
|
3
|
+
// 리듬을 한 곳에서 소유한다. 반응형(폭·여백)은 이 블록이 책임지므로(결정 107)
|
|
4
|
+
// 페이지 코드는 레이아웃 브레이크포인트를 직접 쓰지 않는다. size 로 최대폭을 고른다.
|
|
5
|
+
import { computed } from 'vue'
|
|
6
|
+
import { cn } from '../../lib/utils.js'
|
|
7
|
+
|
|
8
|
+
type Size = 'default' | 'narrow' | 'wide' | 'full'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{ size?: Size }>(), { size: 'default' })
|
|
11
|
+
|
|
12
|
+
const MAX: Record<Size, string> = {
|
|
13
|
+
narrow: 'max-w-2xl',
|
|
14
|
+
default: 'max-w-4xl',
|
|
15
|
+
wide: 'max-w-6xl',
|
|
16
|
+
full: 'max-w-none',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 좌우 여백·세로 리듬을 브레이크포인트로 한 곳에서 정한다 — 페이지는 이 값을 모른다.
|
|
20
|
+
const classes = computed(() =>
|
|
21
|
+
cn('mx-auto w-full px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10', MAX[props.size]),
|
|
22
|
+
)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div :class="classes"><slot /></div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · Pagination (결정 106). 페이지 이동 컨트롤. 라우트를 모르는 순수 UI —
|
|
3
|
+
// 현재 페이지를 v-model(update:page)로 올려보내고, 실제 이동(Link·router)은 페이지가
|
|
4
|
+
// 정한다(shared 순수성 · 결정 25). 모바일은 압축형(이전/다음 + "n / m"), sm 이상에선
|
|
5
|
+
// 현재 주변 번호를 함께 보인다(반응형은 블록 책임 · 결정 107). 버튼은 모바일 44px·
|
|
6
|
+
// 데스크톱 40px 로 터치 타깃을 확보한다(결정 107).
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import { cn } from '../../lib/utils.js'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{ page: number; pageCount: number; siblings?: number }>(),
|
|
12
|
+
{ siblings: 1 },
|
|
13
|
+
)
|
|
14
|
+
const emit = defineEmits<{ (e: 'update:page', page: number): void }>()
|
|
15
|
+
|
|
16
|
+
const total = computed(() => Math.max(1, props.pageCount))
|
|
17
|
+
const clamped = computed(() => Math.min(Math.max(1, props.page), total.value))
|
|
18
|
+
const canPrev = computed(() => clamped.value > 1)
|
|
19
|
+
const canNext = computed(() => clamped.value < total.value)
|
|
20
|
+
|
|
21
|
+
// 현재 주변 번호 창(sm 이상): [현재-siblings, 현재+siblings]를 1..total 로 자른다.
|
|
22
|
+
const windowPages = computed<number[]>(() => {
|
|
23
|
+
const from = Math.max(1, clamped.value - props.siblings)
|
|
24
|
+
const to = Math.min(total.value, clamped.value + props.siblings)
|
|
25
|
+
const out: number[] = []
|
|
26
|
+
for (let p = from; p <= to; p++) out.push(p)
|
|
27
|
+
return out
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function go(p: number): void {
|
|
31
|
+
const next = Math.min(Math.max(1, p), total.value)
|
|
32
|
+
if (next !== clamped.value) emit('update:page', next)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BTN =
|
|
36
|
+
'inline-flex h-11 min-w-11 sm:h-10 sm:min-w-10 items-center justify-center rounded-md border ' +
|
|
37
|
+
'px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ' +
|
|
38
|
+
'disabled:pointer-events-none disabled:opacity-50'
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<nav class="flex items-center justify-between gap-2 sm:justify-center" aria-label="페이지 이동">
|
|
43
|
+
<button type="button" :class="BTN" :disabled="!canPrev" @click="go(clamped - 1)">이전</button>
|
|
44
|
+
|
|
45
|
+
<span class="text-sm text-muted-foreground sm:hidden">{{ clamped }} / {{ total }}</span>
|
|
46
|
+
|
|
47
|
+
<span class="hidden items-center gap-1 sm:inline-flex">
|
|
48
|
+
<button
|
|
49
|
+
v-for="p in windowPages"
|
|
50
|
+
:key="p"
|
|
51
|
+
type="button"
|
|
52
|
+
:class="cn(BTN, p === clamped && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground')"
|
|
53
|
+
:aria-current="p === clamped ? 'page' : undefined"
|
|
54
|
+
@click="go(p)"
|
|
55
|
+
>{{ p }}</button>
|
|
56
|
+
</span>
|
|
57
|
+
|
|
58
|
+
<button type="button" :class="BTN" :disabled="!canNext" @click="go(clamped + 1)">다음</button>
|
|
59
|
+
</nav>
|
|
60
|
+
</template>
|
package/dist/uikit.d.ts
CHANGED
|
@@ -11,10 +11,10 @@ export interface UiKitScaffoldResult {
|
|
|
11
11
|
readonly created: string[];
|
|
12
12
|
readonly skipped: string[];
|
|
13
13
|
}
|
|
14
|
-
/** UI 킷 전체가 생성하는 파일
|
|
15
|
-
export declare function uiKitScaffoldFiles(
|
|
16
|
-
/** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일
|
|
17
|
-
export declare function authUiKitFiles(
|
|
14
|
+
/** UI 킷 전체가 생성하는 파일 목록(shared/ · 프로젝트당 한 벌). */
|
|
15
|
+
export declare function uiKitScaffoldFiles(_opts?: UiKitScaffoldOptions): UiKitScaffoldFile[];
|
|
16
|
+
/** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록(shared/). */
|
|
17
|
+
export declare function authUiKitFiles(_app?: string): UiKitScaffoldFile[];
|
|
18
18
|
/** 파일 목록을 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip(멱등 · 복사-소유). */
|
|
19
19
|
export declare function writeUiKitFiles(cwd: string, files: readonly UiKitScaffoldFile[]): UiKitScaffoldResult;
|
|
20
20
|
/** `gaon g ui-kit` — UI 킷 전체를 쓴다. */
|
package/dist/uikit.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @gaonjs/cli · `gaon g ui-kit` — shadcn 식 UI 킷 제너레이터 (결정 75)
|
|
2
|
+
* @gaonjs/cli · `gaon g ui-kit` — shadcn 식 UI 킷 제너레이터 (결정 75 · 결정 105 개정)
|
|
3
3
|
*
|
|
4
4
|
* shadcn 을 참조해 Vue 3 로 새로 쓴 UI 컴포넌트를 프로젝트에 **복사**한다
|
|
5
5
|
* (복사-소유 — 생성된 뒤엔 사용자 코드다). npm 의존이 아니라 파일로 심으므로
|
|
6
6
|
* 자유롭게 고칠 수 있고, 외부 런타임 의존이 전혀 없다(cn 은 자작 · reka-ui·
|
|
7
7
|
* clsx·cva 미도입 · 결정 75). Tailwind(결정 74)를 전제로 한다.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 배치(결정 105 — 결정 75 의 "앱마다 복사" 개정):
|
|
10
|
+
* shared/lib/utils.ts — cn() 헬퍼 (프로젝트당 한 벌)
|
|
11
|
+
* shared/components/ui/*.vue — Button·Input·Card·Form·Dialog … (전 앱 공용 순수 UI)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* shared 에 두는 이유(결정 105): UI 킷은 성격 중립 순수 UI 라 앱마다 복제하지
|
|
14
|
+
* 않고 프로젝트당 한 벌만 둔다 — 앱 컴포넌트는 이 킷을 조합·확장한다. 앱 페이지는
|
|
15
|
+
* `@shared/components/ui/…` alias 로 참조한다(vite.config.ts·tsconfig.json 에 배선).
|
|
16
|
+
* 킷 내부 import(cn·하위 컴포넌트)는 상대 경로(`../../lib/utils.js`·`./Label.vue`)라
|
|
17
|
+
* 위치가 apps/<app> 에서 shared 로 옮겨져도 그대로 유효하다.
|
|
18
|
+
*
|
|
19
|
+
* --app 은 이제 킷 위치가 아니라 대상 앱의 Tailwind 배선(style.css·main.ts)을
|
|
20
|
+
* 멱등 보정하는 데만 쓰인다 — 킷은 앱과 무관하게 shared 로 간다.
|
|
16
21
|
*/
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
23
|
import { dirname, join, resolve } from 'node:path';
|
|
19
24
|
import { fileURLToPath } from 'node:url';
|
|
20
25
|
import { appWiringFiles, readProjectName } from './scaffold/app-wiring.js';
|
|
@@ -39,6 +44,11 @@ const COMPONENTS = [
|
|
|
39
44
|
'FormMessage',
|
|
40
45
|
'Dialog',
|
|
41
46
|
'Sheet',
|
|
47
|
+
// 블록(결정 106) — 성격 중립 페이지 조각. 원자를 조합해 페이지 골격을 만든다.
|
|
48
|
+
'PageShell',
|
|
49
|
+
'PageHeader',
|
|
50
|
+
'EmptyState',
|
|
51
|
+
'Pagination',
|
|
42
52
|
];
|
|
43
53
|
/** `gaon g auth` 가 스캐폴드하는 로그인·가입·대시보드 페이지가 쓰는 최소 세트.
|
|
44
54
|
* auth 생성 시 이 컴포넌트가 없으면 페이지가 컴파일되지 않으므로 함께 보장한다. */
|
|
@@ -62,26 +72,26 @@ const AUTH_SUBSET = [
|
|
|
62
72
|
function read(name) {
|
|
63
73
|
return readFileSync(join(TEMPLATE_DIR, name), 'utf8');
|
|
64
74
|
}
|
|
65
|
-
/** 컴포넌트명 목록 → 생성 파일 목록(utils 포함). */
|
|
66
|
-
function filesFor(components
|
|
75
|
+
/** 컴포넌트명 목록 → 생성 파일 목록(utils 포함). 위치는 shared/ 고정(결정 105). */
|
|
76
|
+
function filesFor(components) {
|
|
67
77
|
const files = [
|
|
68
|
-
{ path: `
|
|
78
|
+
{ path: `shared/lib/utils.ts`, contents: read('utils.ts.tpl') },
|
|
69
79
|
];
|
|
70
80
|
for (const name of components) {
|
|
71
81
|
files.push({
|
|
72
|
-
path: `
|
|
82
|
+
path: `shared/components/ui/${name}.vue`,
|
|
73
83
|
contents: read(`${name}.vue.tpl`),
|
|
74
84
|
});
|
|
75
85
|
}
|
|
76
86
|
return files;
|
|
77
87
|
}
|
|
78
|
-
/** UI 킷 전체가 생성하는 파일
|
|
79
|
-
export function uiKitScaffoldFiles(
|
|
80
|
-
return filesFor(COMPONENTS
|
|
88
|
+
/** UI 킷 전체가 생성하는 파일 목록(shared/ · 프로젝트당 한 벌). */
|
|
89
|
+
export function uiKitScaffoldFiles(_opts = {}) {
|
|
90
|
+
return filesFor(COMPONENTS);
|
|
81
91
|
}
|
|
82
|
-
/** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일
|
|
83
|
-
export function authUiKitFiles(
|
|
84
|
-
return filesFor(AUTH_SUBSET
|
|
92
|
+
/** `gaon g auth` 가 필요로 하는 UI 킷 최소 세트 파일 목록(shared/). */
|
|
93
|
+
export function authUiKitFiles(_app = 'web') {
|
|
94
|
+
return filesFor(AUTH_SUBSET);
|
|
85
95
|
}
|
|
86
96
|
/** 파일 목록을 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip(멱등 · 복사-소유). */
|
|
87
97
|
export function writeUiKitFiles(cwd, files) {
|
|
@@ -118,11 +128,12 @@ export function runGenerateUiKitCommand(opts = {}) {
|
|
|
118
128
|
skipped: [...wiring.skipped, ...kit.skipped].sort(),
|
|
119
129
|
};
|
|
120
130
|
if (opts.json) {
|
|
121
|
-
|
|
131
|
+
const legacy = legacyUiKitApps(cwd);
|
|
132
|
+
process.stdout.write(JSON.stringify({ command: 'g ui-kit', app, ...result, legacyUiKitApps: legacy }, null, 2) + '\n');
|
|
122
133
|
return 0;
|
|
123
134
|
}
|
|
124
135
|
const lines = [''];
|
|
125
|
-
lines.push(` gaon g ui-kit — UI 킷 (
|
|
136
|
+
lines.push(` gaon g ui-kit — UI 킷 (shared/ · shadcn 참조 · 복사-소유)`);
|
|
126
137
|
lines.push('');
|
|
127
138
|
for (const f of result.created)
|
|
128
139
|
lines.push(` + ${f}`);
|
|
@@ -130,9 +141,32 @@ export function runGenerateUiKitCommand(opts = {}) {
|
|
|
130
141
|
lines.push(` · ${f} (이미 있음 — 건너뜀)`);
|
|
131
142
|
lines.push('');
|
|
132
143
|
lines.push(' Tailwind 배선(tailwind.config.ts·postcss.config.js·apps/' + app + '/style.css)이 함께 보정됩니다.');
|
|
133
|
-
lines.push(' 컴포넌트는 이제 여러분 코드입니다 —
|
|
134
|
-
lines.push(' 예) import Button from \'
|
|
144
|
+
lines.push(' 컴포넌트는 이제 여러분 코드입니다 — shared/components/ui/ 에서 자유롭게 고치세요.');
|
|
145
|
+
lines.push(' 예) import Button from \'@shared/components/ui/Button.vue\'');
|
|
146
|
+
const legacy = legacyUiKitApps(cwd);
|
|
147
|
+
if (legacy.length > 0) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(' ⚠ 구버전 UI 킷 사본이 남아 있습니다(결정 105 이전 · 앱별 배치):');
|
|
150
|
+
for (const app of legacy)
|
|
151
|
+
lines.push(` apps/${app}/components/ui/ · apps/${app}/lib/utils.ts`);
|
|
152
|
+
lines.push(' → shared/ 로 옮겼으니 앱 사본을 지우고 import 를 @shared 로 바꾸세요:');
|
|
153
|
+
lines.push(" import X from '../../components/ui/X.vue' → import X from '@shared/components/ui/X.vue'");
|
|
154
|
+
}
|
|
135
155
|
lines.push('');
|
|
136
156
|
process.stdout.write(lines.join('\n') + '\n');
|
|
137
157
|
return 0;
|
|
138
158
|
}
|
|
159
|
+
/** 결정 105 이전 배치(apps/<app>/components/ui)를 아직 가진 앱 목록 — 마이그레이션 안내용. */
|
|
160
|
+
function legacyUiKitApps(cwd) {
|
|
161
|
+
const appsDir = join(cwd, 'apps');
|
|
162
|
+
let apps;
|
|
163
|
+
try {
|
|
164
|
+
apps = readdirSync(appsDir, { withFileTypes: true })
|
|
165
|
+
.filter((e) => e.isDirectory())
|
|
166
|
+
.map((e) => e.name);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return apps.filter((app) => existsSync(join(appsDir, app, 'components', 'ui'))).sort();
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/web": "0.7.1",
|
|
30
|
+
"@gaonjs/data": "0.10.0",
|
|
31
|
+
"@gaonjs/config": "0.5.4",
|
|
33
32
|
"@gaonjs/core": "0.2.1",
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
33
|
+
"@gaonjs/mail": "0.1.3",
|
|
34
|
+
"@gaonjs/web": "0.7.2",
|
|
35
|
+
"@gaonjs/async": "0.6.1"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
|
|
2
|
-
import { createApp, type AppSessionOptions } from 'gaonjs/web'
|
|
3
|
-
import appRoutes from './routes.js'
|
|
4
|
-
import session from './controllers/session.js'
|
|
5
|
-
import registration from './controllers/registration.js'
|
|
6
|
-
import dashboard from './controllers/dashboard.js'
|
|
7
|
-
import { loadUser } from './auth.js'
|
|
8
|
-
|
|
9
|
-
export interface WebAppDeps {
|
|
10
|
-
/** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
|
|
11
|
-
readonly session: AppSessionOptions
|
|
12
|
-
/** 서명 쿠키/CSRF 용 비밀. */
|
|
13
|
-
readonly cookieSecret?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function createWebApp(deps: WebAppDeps) {
|
|
17
|
-
return createApp({
|
|
18
|
-
apps: [
|
|
19
|
-
{
|
|
20
|
-
name: '{{APP_NAME}}',
|
|
21
|
-
routes: appRoutes,
|
|
22
|
-
controllers: { session, registration, dashboard },
|
|
23
|
-
session: deps.session,
|
|
24
|
-
auth: { loadUser, loginRedirect: '/session/new' },
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
cookieSecret: deps.cookieSecret,
|
|
28
|
-
})
|
|
29
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
|
|
2
|
-
import { createWebApp } from './apps/{{APP_NAME}}/app.js'
|
|
3
|
-
|
|
4
|
-
const app = await createWebApp({
|
|
5
|
-
session: {
|
|
6
|
-
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
|
7
|
-
secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
|
|
8
|
-
},
|
|
9
|
-
cookieSecret: process.env.COOKIE_SECRET,
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const port = Number(process.env.PORT ?? 3000)
|
|
13
|
-
await app.listen({ port, host: '0.0.0.0' })
|
|
14
|
-
console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
|