@gaonjs/cli 0.63.1 → 0.65.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/commands/check.js +34 -18
- package/dist/commands/g.d.ts +1 -0
- package/dist/commands/g.js +11 -2
- package/dist/commands/gen.js +1 -1
- package/dist/dev.d.ts +13 -7
- package/dist/dev.js +30 -22
- package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
- package/dist/doctor/fixers/i18n-layout.js +119 -25
- package/dist/doctor/fixers/index.js +7 -2
- package/dist/doctor/fixers/types.d.ts +17 -2
- package/dist/doctor/i18n-app-scope.d.ts +3 -0
- package/dist/doctor/i18n-app-scope.js +94 -65
- package/dist/doctor/i18n-layout.d.ts +1 -1
- package/dist/doctor/i18n-layout.js +157 -32
- package/dist/doctor/i18n-server-scope.d.ts +2 -0
- package/dist/doctor/i18n-server-scope.js +124 -0
- package/dist/doctor/locale-parity.js +29 -68
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +36 -10
- package/dist/i18n-config.d.ts +9 -9
- package/dist/i18n-config.js +8 -15
- package/dist/messages-gen.d.ts +10 -10
- package/dist/messages-gen.js +42 -54
- package/dist/scaffold/app.d.ts +10 -1
- package/dist/scaffold/app.js +12 -2
- package/dist/templates/project/AGENTS.md.tpl +7 -6
- package/dist/templates/project/agents/async.md.tpl +9 -9
- package/dist/templates/project/agents/data.md.tpl +37 -12
- package/dist/templates/project/agents/frontend.md.tpl +14 -9
- package/dist/templates/project/agents/i18n.md.tpl +185 -74
- package/dist/templates/project/agents/mail.md.tpl +3 -1
- package/dist/templates/project/agents/realtime.md.tpl +54 -31
- package/dist/templates/project/agents/seal.md.tpl +22 -4
- package/dist/templates/project/agents/security.md.tpl +1 -1
- package/dist/templates/project/agents/storage.md.tpl +23 -15
- package/dist/templates/project/agents/testing.md.tpl +5 -5
- package/dist/templates/project/agents/web.md.tpl +24 -11
- package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
- package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +6 -5
- package/package.json +7 -7
- package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
- package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
- /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
- /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
|
@@ -6,43 +6,55 @@
|
|
|
6
6
|
|
|
7
7
|
## 한 장 요약
|
|
8
8
|
|
|
9
|
+
문구의 **소유자**가 파일 위치를 정한다 — 화면이면 **그 앱**, 화면 밖이면 **domain**.
|
|
10
|
+
|
|
9
11
|
| 문구가 어디에 보이나 | 카탈로그 파일 | 부르는 곳 |
|
|
10
12
|
|---|---|---|
|
|
11
|
-
| **화면**(.vue 페이지·컴포넌트·레이아웃) | `locales/<로케일>/frontend.json`
|
|
12
|
-
|
|
|
13
|
+
| **화면**(.vue 페이지·컴포넌트·레이아웃) | `apps/<앱>/locales/<로케일>/frontend.json` **(앱마다 필수)** | **`import { t } from 'gaonjs/vue'`** |
|
|
14
|
+
| 그 앱 **컨트롤러**의 서버 문구(flash·리다이렉트 안내) | `apps/<앱>/locales/<로케일>/backend.json` (선택) | `import { t } from 'gaonjs/i18n'` |
|
|
15
|
+
| 메일·잡·크론·검증 문안·로그 등 **도메인 공통** | `domain/locales/<로케일>/backend.json` | `import { t } from 'gaonjs/i18n'` |
|
|
13
16
|
|
|
14
|
-
한 문장: **"화면에 보이는 문구는 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
|
|
17
|
+
한 문장: **"화면에 보이는 문구는 그 앱의 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
|
|
15
18
|
backend + 서버 `t()`."** 텍스트는 카탈로그에 **한 번만** 쓴다 — 컨트롤러가 키를 다시
|
|
16
19
|
열거하거나 타입을 손으로 선언하지 않는다.
|
|
17
20
|
|
|
21
|
+
**루트 공용 `locales/` 는 없다**(결정 459). 여러 앱이 함께 쓰는 UI 문구는 **각 앱에
|
|
22
|
+
같은 키를 둔다**(§10) — doctor 가 전 앱 존재를 강제하므로 손으로 맞출 일은 없다.
|
|
23
|
+
|
|
18
24
|
## 정본 규칙
|
|
19
25
|
|
|
20
|
-
### 1. 카탈로그 저작 레이아웃 (결정
|
|
26
|
+
### 1. 카탈로그 저작 레이아웃 (결정 459)
|
|
21
27
|
|
|
22
28
|
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
backend.json # 서버 전용 — 메일·잡·검증(validation.*)·알림.
|
|
26
|
-
|
|
27
|
-
en/
|
|
28
|
-
backend.json
|
|
29
|
-
frontend.json
|
|
29
|
+
domain/
|
|
30
|
+
locales/
|
|
31
|
+
ko/backend.json # 도메인 공통 서버 전용 — 메일·잡·검증(validation.*)·알림.
|
|
32
|
+
en/backend.json # 화면 문구(frontend.json)는 여기 둘 수 없다 — 부팅 에러.
|
|
30
33
|
apps/
|
|
31
|
-
web/
|
|
32
|
-
|
|
34
|
+
web/
|
|
35
|
+
locales/
|
|
36
|
+
ko/frontend.json # web 화면 문구 — web 번들 청크에만 실린다. (필수)
|
|
37
|
+
ko/backend.json # web 컨트롤러 전용 서버 문구. (선택)
|
|
38
|
+
en/frontend.json
|
|
39
|
+
admin/
|
|
40
|
+
locales/ko/frontend.json …
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
- **키 이름은
|
|
43
|
+
- **키 이름은 위치와 무관하다** — `t('nav.home')` 은 파일이 어디에 있든 그대로다.
|
|
36
44
|
파일 위치가 곧 "누가 이 문구를 볼 수 있나"를 정한다.
|
|
37
|
-
- **backend 는 클라 번들에 물리적으로 없다** — 앱 카탈로그 청크의 입력이
|
|
38
|
-
서버 문구가 `/assets/*` 로
|
|
39
|
-
참조하면 **컴파일 에러**다.
|
|
40
|
-
- **한 키를 두 스코프에 두면 부팅 에러**
|
|
45
|
+
- **backend 는 클라 번들에 물리적으로 없다** — 앱 카탈로그 청크의 입력이 그 앱
|
|
46
|
+
`frontend.json` **하나**뿐이라 서버 문구가 `/assets/*` 로 샐 길이 없다. 타입 축도 같은
|
|
47
|
+
경계를 지켜 클라에서 backend 키를 참조하면 **컴파일 에러**다.
|
|
48
|
+
- **한 키를 두 스코프에 두면 부팅 에러** — 같은 앱의 frontend↔backend 도, 앱↔도메인도
|
|
49
|
+
마찬가지다(어느 번역이 나갈지가 병합 순서·요청 컨텍스트에 좌우되면 안 된다).
|
|
41
50
|
- 카탈로그는 **순수 JSON**(주석·트레일링 콤마 금지). 로케일 등록 = 폴더 존재.
|
|
51
|
+
- **위치는 관례로 고정**이라 `i18n.dir` 설정이 없다(결정 459 — 있으면 doctor 가 error).
|
|
42
52
|
- `gaon.config.ts` 의 `i18n.fallbackLng` 가 **기준 로케일**이다(키 유니온의 출처 · 결정 352).
|
|
43
53
|
|
|
44
54
|
```ts
|
|
45
55
|
// gaon.config.ts
|
|
56
|
+
import { defineConfig } from 'gaonjs/config'
|
|
57
|
+
|
|
46
58
|
export default defineConfig({
|
|
47
59
|
i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
|
|
48
60
|
})
|
|
@@ -78,21 +90,43 @@ const props = pageProps<'web:posts#index'>()
|
|
|
78
90
|
|
|
79
91
|
```ts
|
|
80
92
|
// apps/web/main.ts
|
|
93
|
+
import { createGaonApp } from 'gaonjs/vue'
|
|
94
|
+
import { routes } from './.gaon/routes.manifest.js'
|
|
81
95
|
import { catalogs, fallbackLng } from './.gaon/messages.catalog.js'
|
|
96
|
+
|
|
97
|
+
const pages = import.meta.glob('./pages/**/*.vue')
|
|
98
|
+
const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
|
|
99
|
+
|
|
82
100
|
void createGaonApp({ pages, layouts, routes, messages: { catalogs, fallbackLng } })
|
|
83
101
|
```
|
|
84
102
|
|
|
85
|
-
### 3. 서버 문구 = 서버 `t()` (
|
|
103
|
+
### 3. 서버 문구 = 서버 `t()` (결정 459 · 스코프 2개)
|
|
104
|
+
|
|
105
|
+
서버 `t()` 의 해석 범위는 **호출이 일어난 컨텍스트**가 정한다:
|
|
106
|
+
|
|
107
|
+
| 어디서 불렀나 | 보이는 카탈로그 | 우선순위 |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `domain/**`(mails·jobs·services·listeners·schedule) | domain-backend **만** | — (단일) |
|
|
110
|
+
| `apps/<앱>/**`(컨트롤러 등 서버 코드) | 그 앱 backend ∪ 그 앱 frontend ∪ domain-backend | **앱 먼저**, 없으면 domain |
|
|
111
|
+
| 워커(`gaon work`)·크론·부팅 스크립트 | domain-backend **만**(앱 컨텍스트가 없다) | — (단일) |
|
|
86
112
|
|
|
87
113
|
```ts
|
|
88
114
|
// domain/mails/welcome.ts — 수신자 로케일로 렌더된다(결정 160)
|
|
115
|
+
import { mail } from 'gaonjs/mail'
|
|
89
116
|
import { t } from 'gaonjs/i18n'
|
|
90
|
-
|
|
117
|
+
|
|
118
|
+
export const Welcome = mail<{ name: string; email: string }>((u) => ({
|
|
119
|
+
to: u.email,
|
|
120
|
+
subject: t('mail.welcome.subject', { name: u.name }),
|
|
121
|
+
html: `<h1>${t('mail.welcome.body')}</h1>`,
|
|
122
|
+
}))
|
|
91
123
|
```
|
|
92
124
|
|
|
93
125
|
```ts
|
|
94
126
|
// domain/jobs/sendDigest.ts — 잡은 요청 밖이라 로케일을 페이로드로 싣는다
|
|
127
|
+
import { job } from 'gaonjs/async'
|
|
95
128
|
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
129
|
+
|
|
96
130
|
export const SendDigest = job(async ({ locale }: { locale: string }) => {
|
|
97
131
|
const line = runWithLanguage(locale, () => t('mail.digest.body'))
|
|
98
132
|
})
|
|
@@ -100,28 +134,47 @@ export const SendDigest = job(async ({ locale }: { locale: string }) => {
|
|
|
100
134
|
|
|
101
135
|
| 표면 | 시그니처 | 쓰는 곳 |
|
|
102
136
|
|---|---|---|
|
|
103
|
-
| 번역 | `t(key, params?)` | 요청 로케일(ALS) 자동 추종 |
|
|
137
|
+
| 번역 | `t(key, params?)` | 요청 로케일(ALS) + 앱 스코프 자동 추종 |
|
|
104
138
|
| 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
|
|
139
|
+
| 현재 앱 스코프 | `currentApp(): string \| undefined` | 진단용(요청 밖이면 undefined) |
|
|
105
140
|
| 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs |
|
|
106
141
|
| 로케일 고정 | `runWithLanguage(lng, fn)` | 잡·크론·스크립트(요청 밖) |
|
|
107
142
|
| 고정 번역기 | `translator(lng)` | 테스트·비요청 경로 |
|
|
108
143
|
|
|
109
|
-
서버 `t()`
|
|
110
|
-
문구가 필요할 수 있다). 반대 방향만 막힌다 —
|
|
144
|
+
앱 스코프에서는 서버 `t()` 가 그 앱의 backend ∪ frontend 를 **모두** 볼 수 있다(SSR·로그·
|
|
145
|
+
관리 스크립트에서 화면 문구가 필요할 수 있다 · 결정 459 O5). 반대 방향만 막힌다 —
|
|
146
|
+
클라는 frontend 만.
|
|
147
|
+
|
|
148
|
+
**⚠️ 도메인 코드는 앱 키에 기대면 안 된다.** 앱 요청이 부른 도메인 서비스는 같은 요청
|
|
149
|
+
컨텍스트라 앱 네임스페이스가 **우연히 보인다**. 그런데 같은 코드가 워커·크론에서 불리면
|
|
150
|
+
앱 스코프가 없어 그 키가 빈다 — "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 는 가장 나쁜
|
|
151
|
+
형태다. `gaon doctor` 의 `i18n-server-scope` 가 정적으로 막는다(§9).
|
|
111
152
|
|
|
112
|
-
### 4. 키·파라미터 타입 — `.gaon/messages.d.ts` 두 갈래 (결정 454)
|
|
153
|
+
### 4. 키·파라미터 타입 — `.gaon/messages.d.ts` 두 갈래 (결정 454 · 459)
|
|
113
154
|
|
|
114
155
|
`gaon gen`/`dev`/`check` 가 카탈로그를 읽어 **두 유니온**을 생성한다:
|
|
115
|
-
서버(`gaonjs/i18n`) = backend ∪
|
|
156
|
+
서버(`gaonjs/i18n`) = domain-backend ∪ 전 앱 backend ∪ 전 앱 frontend /
|
|
157
|
+
클라(`gaonjs/vue`) = 전 앱 frontend 만.
|
|
116
158
|
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
t('
|
|
122
|
-
|
|
159
|
+
```vue expect-error
|
|
160
|
+
<!-- apps/web/pages/Posts/Bad.vue — 아래 세 줄은 **전부 컴파일 에러**다 -->
|
|
161
|
+
<script setup lang="ts">
|
|
162
|
+
import { t } from 'gaonjs/vue'
|
|
163
|
+
const a = t('mail.welcome.subject', { name: '가온' }) // ❌ 클라에서 backend 키
|
|
164
|
+
const b = t('greeting') // ❌ {{name}} 보간 파라미터 누락
|
|
165
|
+
const c = t('greeting', { nam: '가온' }) // ❌ 파라미터 이름 오타
|
|
166
|
+
</script>
|
|
123
167
|
```
|
|
124
168
|
|
|
169
|
+
**유니온은 앱별로 못 나눈다** — 두 앱이 `GaonMessages.keys` 를 서로 다른 유니온으로
|
|
170
|
+
augment 하면 **TS2717**(Subsequent property declarations must have the same type)이고,
|
|
171
|
+
`gaon check` 는 단일 tsc 프로그램이라 앱마다 다른 프로그램을 돌릴 수 없다(결정 454 실측 ·
|
|
172
|
+
결정 459 §5.1 로 유효). **파일은 앱별인데 타입은 프로젝트 전역**인 이 비대칭이 함정이다 —
|
|
173
|
+
그래서 앱 경계는 ① 번들 물리 경계 ② doctor 가 강제한다(§9).
|
|
174
|
+
|
|
175
|
+
산출은 **루트 `.gaon/messages.d.ts` 하나**다(앱별로 쪼개면 "앱별 타입" 이라는 오해를
|
|
176
|
+
만든다 · 결정 459 O7). 앱별 `.gaon/` 에는 **값 모듈**(`messages.catalog.ts`)만 나간다.
|
|
177
|
+
|
|
125
178
|
카탈로그가 없거나 frontend 스코프가 없으면 키가 `string` 으로 열린다(무회귀). 이 파일은
|
|
126
179
|
자동 생성이니 직접 수정하지 않는다.
|
|
127
180
|
|
|
@@ -132,11 +185,11 @@ t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에
|
|
|
132
185
|
**같은 문자열**을 낸다(85 케이스 동치 게이트로 고정).
|
|
133
186
|
|
|
134
187
|
```json
|
|
135
|
-
// locales/en/frontend.json — 영어는 단수/복수 구분
|
|
188
|
+
// apps/web/locales/en/frontend.json — 영어는 단수/복수 구분
|
|
136
189
|
{ "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
|
|
137
190
|
```
|
|
138
191
|
```json
|
|
139
|
-
// locales/ko/frontend.json — 한국어는 _other 만
|
|
192
|
+
// apps/web/locales/ko/frontend.json — 한국어는 _other 만
|
|
140
193
|
{ "cart": { "items_other": "상품 {{count}}개" } }
|
|
141
194
|
```
|
|
142
195
|
|
|
@@ -152,7 +205,7 @@ t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에
|
|
|
152
205
|
`gaon.config.ts` 에 `i18n` 이 있으면 매 요청 **세션 > 쿠키 > Accept-Language** 로 협상된다.
|
|
153
206
|
전환은 서버가 권위를 갖는다 — 클라에 `setLocale` 은 **없다**.
|
|
154
207
|
|
|
155
|
-
```ts
|
|
208
|
+
```ts controller-action
|
|
156
209
|
// 컨트롤러 — 언어 전환 라우트
|
|
157
210
|
async switchLocale() {
|
|
158
211
|
const { lng } = this.params({ _row: {} as { lng: string } })
|
|
@@ -177,7 +230,7 @@ async switchLocale() {
|
|
|
177
230
|
런타임까지 오는 것은 **동적 키**(`t(변수)`)와 진짜 카탈로그 누락뿐이라, 그 둘만 우아하게
|
|
178
231
|
degrade 한다. 즉 **빌드 = 차단 / 런타임 = 키 렌더 + 경고**의 2단이다.
|
|
179
232
|
|
|
180
|
-
```ts
|
|
233
|
+
```ts fragment
|
|
181
234
|
const key = `posts.${kind}` // 동적 키 — 컴파일 게이트가 못 본다
|
|
182
235
|
t(key as never) // 없으면 'posts.abc' 가 화면에 뜨고 경고 1줄
|
|
183
236
|
```
|
|
@@ -186,30 +239,52 @@ t(key as never) // 없으면 'posts.abc' 가 화면에
|
|
|
186
239
|
녹는다(이 계약이 막는 것이 정확히 그 침묵이다).
|
|
187
240
|
- 런타임까지 **실패로 만들고 싶다면**(엄격 모드) 지금은 지원하지 않는다 — 필요하면 요청하라.
|
|
188
241
|
|
|
189
|
-
### 9.
|
|
242
|
+
### 9. 정적 검사 3종 (`gaon doctor`)
|
|
243
|
+
|
|
244
|
+
| 검사 | 무엇을 잡나 | 등급 |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `locale-parity` | 같은 스코프 안에서 **로케일 간** 키 부분 누락(§9.1) | 경고 |
|
|
247
|
+
| `i18n-app-scope` | 클라 `t()` 가 **그 앱에 없는** 키 참조 · shared 키가 일부 앱에만 존재 | 오류 |
|
|
248
|
+
| `i18n-server-scope` | 서버 `t()` 가 **소유자 경계**를 넘는 키 참조(도메인 → 앱 키 등) | 오류 |
|
|
249
|
+
|
|
250
|
+
#### 9.1 로케일 커버리지 — `locale-parity` (결정 216 · 456 · 459)
|
|
190
251
|
|
|
191
252
|
키 유니온은 기준 로케일 하나에서 나오므로, 어떤 키를 `ko` 에만 넣고 `ja` 에 빠뜨리면
|
|
192
|
-
컴파일은 통과하고 일본어 사용자만 조용히 fallback 을 본다.
|
|
193
|
-
|
|
253
|
+
컴파일은 통과하고 일본어 사용자만 조용히 fallback(서버) 또는 키 문자열(클라)을 본다.
|
|
254
|
+
비교 단위는 **3종**이다: ① domain-backend ② 앱별 frontend ③ 앱별 backend.
|
|
255
|
+
**스코프·소유자를 가로지르는 비교는 하지 않는다** — 앱 전용 키가 전량 "도메인에 없음"
|
|
256
|
+
으로 뜨는 오탐이 되기 때문이다. 복수형 접미사는 base 로 정규화해 비교한다(en `_one`/`_other`
|
|
257
|
+
와 ko `_other` 의 정당한 차이를 누락으로 세지 않는다).
|
|
194
258
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
### 10. 여러 앱이 함께 쓰는 문구 · `shared/` 컴포넌트 (결정 459 O1)
|
|
260
|
+
|
|
261
|
+
공용 카탈로그가 없으므로, 여러 앱이 쓰는 UI 원자 문구(`ui.close`·`ui.prev`·`ui.save` …)는
|
|
262
|
+
**각 앱의 `frontend.json` 에 같은 키를 둔다**. 복제가 비용처럼 보이지만 대안이 더 나쁘다 —
|
|
263
|
+
문구를 props 로 끌어올리면(`closeLabel`·`prevLabel`) 원자 컴포넌트마다 prop 이 생기고
|
|
264
|
+
사용처마다 문구를 다시 적어야 해서, 결정 454 가 없앤 "선언 지점 3곳" 통증이 되살아난다.
|
|
265
|
+
|
|
266
|
+
**`shared/` 컴포넌트가 쓰는 키는 모든 앱에 있어야 한다** — shared 는 어느 앱 번들에도
|
|
267
|
+
실릴 수 있어서, 키가 한 앱에만 있으면 **다른 앱 화면에서만** 키 문자열이 뜨는 조용한
|
|
268
|
+
실패가 된다. `i18n-app-scope` 가 누락된 앱 이름을 짚어 준다:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
shared 컴포넌트가 쓰는 'ui.close' 가 일부 앱 카탈로그에 없습니다(누락: admin)
|
|
272
|
+
→ apps/admin/locales/<로케일>/frontend.json 에 같은 키를 추가하고 gaon gen
|
|
273
|
+
```
|
|
199
274
|
|
|
200
275
|
## 정본 예시
|
|
201
276
|
|
|
202
|
-
### (a) 새 화면 문구 추가 — 앱 전용
|
|
277
|
+
### (a) 새 화면 문구 추가 — 그 앱 전용
|
|
203
278
|
|
|
204
|
-
```
|
|
205
|
-
// apps/web/locales/ko.json
|
|
279
|
+
```json
|
|
280
|
+
// apps/web/locales/ko/frontend.json
|
|
206
281
|
{ "posts": { "heading": "게시글 목록", "empty": "아직 글이 없습니다." } }
|
|
207
282
|
```
|
|
208
|
-
```
|
|
209
|
-
// apps/web/locales/en.json ← 같은 키를 나란히(locale-parity)
|
|
283
|
+
```json
|
|
284
|
+
// apps/web/locales/en/frontend.json ← 같은 키를 나란히(locale-parity)
|
|
210
285
|
{ "posts": { "heading": "Posts", "empty": "No posts yet." } }
|
|
211
286
|
```
|
|
212
|
-
```vue
|
|
287
|
+
```vue fragment
|
|
213
288
|
<script setup lang="ts">
|
|
214
289
|
import { t } from 'gaonjs/vue'
|
|
215
290
|
</script>
|
|
@@ -219,10 +294,14 @@ import { t } from 'gaonjs/vue'
|
|
|
219
294
|
gaon gen # .gaon/messages.d.ts(두 갈래) + apps/web/.gaon/messages.catalog.ts 재생성
|
|
220
295
|
```
|
|
221
296
|
|
|
222
|
-
### (b)
|
|
297
|
+
### (b) 여러 앱이 쓰는 chrome 문구 — 각 앱에 복제
|
|
223
298
|
|
|
224
|
-
```
|
|
225
|
-
// locales/ko/frontend.json
|
|
299
|
+
```json
|
|
300
|
+
// apps/web/locales/ko/frontend.json
|
|
301
|
+
{ "nav": { "home": "홈", "posts": "게시판" } }
|
|
302
|
+
```
|
|
303
|
+
```json
|
|
304
|
+
// apps/admin/locales/ko/frontend.json ← 같은 키를 admin 에도(전 앱 존재를 doctor 가 강제)
|
|
226
305
|
{ "nav": { "home": "홈", "posts": "게시판" } }
|
|
227
306
|
```
|
|
228
307
|
```vue
|
|
@@ -238,33 +317,64 @@ import { t, Link } from 'gaonjs/vue'
|
|
|
238
317
|
</template>
|
|
239
318
|
```
|
|
240
319
|
|
|
241
|
-
### (c) 서버
|
|
320
|
+
### (c) 도메인 공통 서버 문구(메일·검증)
|
|
242
321
|
|
|
243
|
-
```
|
|
244
|
-
// locales/ko/backend.json
|
|
245
|
-
{
|
|
322
|
+
```json
|
|
323
|
+
// domain/locales/ko/backend.json
|
|
324
|
+
{
|
|
325
|
+
"mail": { "welcome": { "subject": "{{name}}님, 환영합니다" } },
|
|
326
|
+
"validation": { "required": "필수 입력 항목입니다." }
|
|
327
|
+
}
|
|
246
328
|
```
|
|
247
|
-
```ts
|
|
329
|
+
```ts fragment
|
|
248
330
|
import { t } from 'gaonjs/i18n'
|
|
249
331
|
const subject = t('mail.welcome.subject', { name: user.name })
|
|
250
332
|
```
|
|
251
333
|
|
|
252
|
-
### (d)
|
|
334
|
+
### (d) 그 앱 컨트롤러 전용 서버 문구
|
|
335
|
+
|
|
336
|
+
```json
|
|
337
|
+
// apps/web/locales/ko/backend.json
|
|
338
|
+
{ "flash": { "saved": "저장했습니다." } }
|
|
339
|
+
```
|
|
340
|
+
```ts controller-action
|
|
341
|
+
async update() {
|
|
342
|
+
this.flash('notice', t('flash.saved')) // web 스코프에서만 보이는 키
|
|
343
|
+
return this.redirect('/posts')
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### (e) 옛 배치에서 이관 (결정 459 · breaking)
|
|
253
348
|
|
|
254
349
|
```bash
|
|
255
|
-
gaon doctor # i18n-layout
|
|
256
|
-
gaon doctor --fix
|
|
257
|
-
#
|
|
258
|
-
|
|
350
|
+
gaon doctor # i18n-layout — 루트 locales/ · 앱 평면 파일 · i18n.dir 을 전부 지목
|
|
351
|
+
gaon doctor --fix --yes
|
|
352
|
+
# locales/<로케일>/backend.json → domain/locales/<로케일>/backend.json (이동)
|
|
353
|
+
# locales/<로케일>.json → domain/locales/<로케일>/backend.json (이동 · 미분류는 서버 전용)
|
|
354
|
+
# apps/<앱>/locales/<로케일>.json → apps/<앱>/locales/<로케일>/frontend.json (이동)
|
|
355
|
+
# locales/<로케일>/frontend.json → 전 앱 frontend.json 에 복제 후 원본 제거
|
|
356
|
+
# 그 뒤 각 앱이 안 쓰는 키는 사람이 지운다(전 앱 복제는 되돌리기 쉬운 방향의 자동화)
|
|
357
|
+
gaon gen && gaon check
|
|
259
358
|
```
|
|
260
359
|
|
|
261
360
|
## 알려진 함정
|
|
262
361
|
|
|
362
|
+
- **루트 `locales/` 는 더 이상 읽히지 않는다**(결정 459) — 그대로 두면 전 화면·전 메일이
|
|
363
|
+
키 문자열로 degrade 한다. `gaon doctor --fix` 로 이관한다.
|
|
364
|
+
- **도메인 코드에서 앱 키를 쓰지 말 것** — 요청 안에서는 우연히 해석되고 워커·크론에서만
|
|
365
|
+
빈다. `i18n-server-scope` 가 잡는다(§3 ⚠️).
|
|
366
|
+
- **`shared/` 가 쓰는 키는 전 앱에 둘 것** — 한 앱에만 있으면 다른 앱에서만 키가 뜬다(§10).
|
|
263
367
|
- **클라에서 backend 키를 쓰지 말 것** — 컴파일 에러로 막힌다. 그 문구가 화면에도 필요하면
|
|
264
368
|
`frontend.json` 으로 **옮긴다**(양쪽에 복사하면 부팅 에러).
|
|
265
369
|
- **다른 앱 전용 키를 쓰지 말 것** — 타입은 프로젝트 전체 frontend 합집합이라 통과하지만
|
|
266
370
|
(앱별 유니온은 TS2717 로 구조적으로 불가), 그 앱 번들에는 없어서 화면에 키 문자열이
|
|
267
|
-
그대로 뜬다. `
|
|
371
|
+
그대로 뜬다. `i18n-app-scope` 가 잡는다.
|
|
372
|
+
- **파일은 앱별인데 타입은 전역이다** — 카탈로그를 앱 폴더로 나눠도 키 유니온은 프로젝트
|
|
373
|
+
전체다(§4). "앱을 나눴으니 타입도 갈렸겠지" 가 이 배치의 가장 큰 오해다.
|
|
374
|
+
- **`domain/locales/<로케일>/frontend.json` 은 만들 수 없다** — 도메인은 화면을 소유하지
|
|
375
|
+
않는다. 부팅에서 실패하고 doctor 가 error 로 짚는다.
|
|
376
|
+
- **`i18n.dir` 은 제거됐다**(결정 459) — 위치가 관례로 고정이라 설정이 없다. 남아 있으면
|
|
377
|
+
doctor error(효과 없는 설정을 조용히 두면 "설정한 폴더를 읽겠지" 라는 오해가 된다).
|
|
268
378
|
- **`.vue` 에서 서버 `t`(`gaonjs/i18n`)를 import 하지 말 것** — ALS 기반 서버 전용이다.
|
|
269
379
|
클라는 `gaonjs/vue` 의 `t`.
|
|
270
380
|
- **`v-html` + `t()` + 사용자 입력 조합 금지** — 템플릿 텍스트 보간(`{{ t(...) }}`)은 Vue 가
|
|
@@ -276,28 +386,29 @@ gaon gen
|
|
|
276
386
|
로케일인지 짚어 준다.
|
|
277
387
|
- **잡·크론은 로케일을 페이로드에 싣는다** — 요청 밖이라 ALS 가 없다(`runWithLanguage`).
|
|
278
388
|
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })`(결정 160).
|
|
279
|
-
- **`
|
|
280
|
-
|
|
389
|
+
- **`fallbackLng` 는 문자열 리터럴로**(결정 412) — 변수·env 표현식은 타입 축과 doctor 가
|
|
390
|
+
정적으로 못 읽는다.
|
|
281
391
|
- **`supportedLngs: []`(빈 배열)은 "미지정" 과 같다**(결정 414).
|
|
282
|
-
- **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가
|
|
283
|
-
가 지목한 카탈로그가 없으면 check 가 실패한다.
|
|
284
|
-
- **검증 실패 문안은 backend** — 예약 namespace `validation.<code>` 를
|
|
285
|
-
두면 서버가 요청 로케일로 번역한다(결정 183).
|
|
392
|
+
- **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 하나도 없거나
|
|
393
|
+
`fallbackLng` 가 지목한 카탈로그가 없으면 check 가 실패한다.
|
|
394
|
+
- **검증 실패 문안은 도메인 backend** — 예약 namespace `validation.<code>` 를
|
|
395
|
+
`domain/locales/<로케일>/backend.json` 에 두면 서버가 요청 로케일로 번역한다(결정 183).
|
|
286
396
|
|
|
287
397
|
## 관련 결정 번호
|
|
288
398
|
|
|
289
399
|
| 결정 | 요지 |
|
|
290
400
|
|---|---|
|
|
291
|
-
| §7 (v0.15) | i18n 배터리 ·
|
|
401
|
+
| §7 (v0.15) | i18n 배터리 · 카탈로그 · t() |
|
|
292
402
|
| 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
|
|
293
403
|
| 결정 159 (13차 W1) | 요청별 로케일 자동 협상 · `this.setLocale` · detect 설정 |
|
|
294
404
|
| 결정 181 | 복수형 base 키 노출(`t('cart.items', { count })`) |
|
|
295
405
|
| 결정 213 | (구) Vue 소비 = 서버 주도 render props/sharedProps 만 — **결정 454 로 부분 번복**(§6 탈출구로 존속) |
|
|
296
406
|
| 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 협상 로케일 자동 추종 |
|
|
297
|
-
| 결정 216 (13차 W4) | `locale-parity` 경고 — 결정 454 로 **스코프별 비교** |
|
|
298
|
-
| 결정 352 | 기준 로케일 = `fallbackLng`
|
|
407
|
+
| 결정 216 (13차 W4) | `locale-parity` 경고 — 결정 454·459 로 **스코프별 비교** |
|
|
408
|
+
| 결정 352 | 기준 로케일 = `fallbackLng` |
|
|
299
409
|
| 결정 353 · 413 | 빈 카탈로그·지목 로케일 부재 = 부팅 fail-loud + check 사전 검사 |
|
|
300
410
|
| 결정 412 · 414 | i18n config 정적 분석 범위·경고 · 생성 키 이스케이프 · `supportedLngs: []` |
|
|
301
|
-
|
|
|
302
|
-
|
|
|
303
|
-
|
|
|
411
|
+
| 결정 455 | 런타임 없는키 = **키 렌더 + 경고**(서버·클라 동일) · 요청/화면을 죽이지 않는다 · 정적 키는 컴파일에서 차단(2단) |
|
|
412
|
+
| 결정 454 | 클라 `t()`(`gaonjs/vue`) · backend/frontend 스코프 분리 · 두 갈래 타입 · 앱별 카탈로그 청크(immutable·seal 면제) · 공유 prop `locale` |
|
|
413
|
+
| 결정 456 | `locale-parity` 앱 스코프 확장 — 앱 내부에서 비교(루트 교차 비교는 오탐이라 기각) |
|
|
414
|
+
| **결정 459** | **소유자 2개로 재편**(결정 454 부분 반전) — 루트 공용 `locales/` **폐지** · 화면 문구 = `apps/<앱>/locales/<로케일>/frontend.json`(필수) · 앱 서버 문구 = 같은 폴더 `backend.json`(선택) · 도메인 공통 = `domain/locales/<로케일>/backend.json` · 서버 `t()` 는 `app:<앱>` → `domain` 네임스페이스 폴백(앱 ALS) · 소유자 간 키 충돌 = 부팅 fail-loud · `i18n.dir` 제거 · `domain/locales/*/frontend.json` = error · shared 키는 전 앱 복제 + doctor 강제 · 신규 검사 `i18n-server-scope` · 타입 유니온은 **여전히 프로젝트 전역**(TS2717) |
|
|
@@ -39,7 +39,7 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
|
39
39
|
로케일은 앱이 `recipient.locale` 로 넘긴다(프레임웍이 모델 구조를 알지 않는다).
|
|
40
40
|
`locale` 생략 시 현재 요청 로케일(없으면 fallback).
|
|
41
41
|
|
|
42
|
-
```ts
|
|
42
|
+
```ts fragment
|
|
43
43
|
await WelcomeMail.deliver({ name: user.name, email: user.email }, { locale: user.locale })
|
|
44
44
|
// to 옵션으로 수신자를 데이터 밖에서 덮어쓸 수도 있다:
|
|
45
45
|
await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
@@ -71,6 +71,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
73
|
// gaon.config.ts
|
|
74
|
+
import { defineConfig } from 'gaonjs/config'
|
|
75
|
+
|
|
74
76
|
export default defineConfig({
|
|
75
77
|
mail: process.env.SMTP_HOST
|
|
76
78
|
? {
|
|
@@ -132,6 +132,7 @@ export default channel({
|
|
|
132
132
|
// apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
|
|
133
133
|
import { controller } from 'gaonjs/web'
|
|
134
134
|
import { broadcast } from 'gaonjs/async'
|
|
135
|
+
import { Post } from '../../../domain/models/Post.js'
|
|
135
136
|
|
|
136
137
|
export default controller({
|
|
137
138
|
async create() {
|
|
@@ -244,16 +245,16 @@ export default channel({
|
|
|
244
245
|
})
|
|
245
246
|
```
|
|
246
247
|
|
|
247
|
-
```ts
|
|
248
|
+
```ts fragment
|
|
248
249
|
// 클라이언트 — 인스턴스 키를 지정해 구독한다(경로: /gaon/ws/match/42)
|
|
249
250
|
const game = useChannel('match', { instance: matchId }) // 숫자는 자동 문자열화
|
|
250
|
-
const comments = useChannel('thread', { instance: postId })
|
|
251
|
+
const comments = useChannel('thread', { instance: String(postId) }) // bigint PK 는 String() (결정 37)
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
```ts
|
|
254
|
+
```ts fragment
|
|
254
255
|
// 서버 발화·조회 — { instance } 옵션으로 특정 인스턴스에만 스코프한다
|
|
255
256
|
broadcast('match', { round: 2 }, { instance: '42' }) // match:42 전원
|
|
256
|
-
await sendToUsers('match', userId, { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
|
|
257
|
+
await sendToUsers('match', String(userId), { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
|
|
257
258
|
const players = await presenceList('match', { instance: '42' }) // match:42 로스터
|
|
258
259
|
const rooms = await presenceOf(`user:${userId}`) // 역방향: 이 유저가 지금 있는 곳
|
|
259
260
|
// rooms = [{ channel: 'match', instance: '42' }, { channel: 'lobby' }, …]
|
|
@@ -406,10 +407,16 @@ export default channel({})
|
|
|
406
407
|
동적 정원이 된다:
|
|
407
408
|
|
|
408
409
|
```ts
|
|
409
|
-
//
|
|
410
|
+
// domain/channels/chat.ts — 채팅방: 고정 인원 제한
|
|
411
|
+
import { channel } from 'gaonjs/async'
|
|
412
|
+
|
|
410
413
|
export default channel({ instance: true, maxMembers: 100 })
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
// domain/channels/match.ts — 게임 매치: 방별 정원(메타·DB 조회)
|
|
418
|
+
import { channel, instanceMeta } from 'gaonjs/async'
|
|
411
419
|
|
|
412
|
-
// 게임 매치 — 방별 정원(메타·DB 조회)
|
|
413
420
|
export default channel({
|
|
414
421
|
instance: true,
|
|
415
422
|
async maxMembers(ctx) {
|
|
@@ -426,9 +433,9 @@ export default channel({
|
|
|
426
433
|
present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가는 호출
|
|
427
434
|
지점(컨트롤러·서비스)의 앱 인가가 정한다.**
|
|
428
435
|
|
|
429
|
-
```ts
|
|
436
|
+
```ts fragment
|
|
430
437
|
// 채팅 모더레이터 강퇴 / 게임 안티치트 축출 — 같은 한 줄
|
|
431
|
-
await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
|
|
438
|
+
await kick('room', `user:${targetId}`, { instance: String(roomId), reason: '규정 위반' })
|
|
432
439
|
```
|
|
433
440
|
|
|
434
441
|
**버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
|
|
@@ -437,10 +444,10 @@ await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위
|
|
|
437
444
|
(`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
|
|
438
445
|
전부 같은 경로**다:
|
|
439
446
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
r.post('/rooms/:key/kick', 'rooms#kick')
|
|
447
|
+
라우트는 한 줄이다 — `apps/web/routes.ts` 의 `routes((r) => { … })` 안에
|
|
448
|
+
`r.post('/rooms/:key/kick', 'rooms#kick')` 을 더한다.
|
|
443
449
|
|
|
450
|
+
```ts controller-action
|
|
444
451
|
// apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
|
|
445
452
|
async kick() {
|
|
446
453
|
this.requireAuth()
|
|
@@ -454,8 +461,8 @@ async kick() {
|
|
|
454
461
|
}
|
|
455
462
|
```
|
|
456
463
|
|
|
457
|
-
```ts
|
|
458
|
-
// apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
|
|
464
|
+
```ts fragment
|
|
465
|
+
// apps/web/pages/Rooms/Show.vue 의 <script setup> — CSRF 는 api() 가 자동 부착(결정 166·341)
|
|
459
466
|
import { api, isApiError } from 'gaonjs/vue'
|
|
460
467
|
await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
|
|
461
468
|
```
|
|
@@ -470,23 +477,34 @@ await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자
|
|
|
470
477
|
도메인마다 달라 프레임웍이 정하지 않는다 — DB(정본) + authorize(보증) +
|
|
471
478
|
kick(즉시성):
|
|
472
479
|
|
|
473
|
-
```ts
|
|
474
|
-
// ① 도메인 기록(정본 = DB) ② 즉시 축출
|
|
480
|
+
```ts fragment
|
|
481
|
+
// ① 도메인 기록(정본 = DB) ② 즉시 축출
|
|
475
482
|
await RoomBan.create({ roomId, userId, reason })
|
|
476
|
-
await kick('room', `user:${userId}`, { instance: roomId, reason })
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
483
|
+
await kick('room', `user:${userId}`, { instance: String(roomId), reason })
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
③ 재입장 차단(4401)은 `authorize` 가 보증 층이다 — kick 유실·재접속을 막는 백스톱:
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
// domain/channels/room.ts — 공유 채널 정의는 domain/channels/ 가 정본(결정 456)
|
|
490
|
+
import { channel } from 'gaonjs/async'
|
|
491
|
+
import { RoomBan } from '../models/Room.js'
|
|
492
|
+
|
|
493
|
+
export default channel({
|
|
494
|
+
instance: true,
|
|
495
|
+
async authorize(ctx) {
|
|
496
|
+
const u = ctx.user as { id: bigint } | null
|
|
497
|
+
if (!u) return false
|
|
498
|
+
let roomId: bigint
|
|
499
|
+
try {
|
|
500
|
+
roomId = BigInt(ctx.instance)
|
|
501
|
+
} catch {
|
|
502
|
+
return false
|
|
503
|
+
}
|
|
504
|
+
if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
|
|
505
|
+
return !(await RoomBan.where('roomId', '=', roomId).where('userId', '=', u.id).exists())
|
|
506
|
+
},
|
|
507
|
+
})
|
|
490
508
|
```
|
|
491
509
|
|
|
492
510
|
익명 멤버(`conn:<uuid>`)는 신원 영속이 없어 ban 이 불가능하다(kick 만
|
|
@@ -499,13 +517,18 @@ DB + authorize** 고, 메타의 존재 이유는 닫힘 시 자동 소멸이다(
|
|
|
499
517
|
소멸 앵커가 없어 메타 미지원 · 4400):
|
|
500
518
|
|
|
501
519
|
```ts
|
|
520
|
+
// domain/channels/match.ts — 열림 시 1회 기록되는 인스턴스 메타
|
|
521
|
+
import { channel } from 'gaonjs/async'
|
|
522
|
+
|
|
502
523
|
export default channel({
|
|
503
524
|
instance: true,
|
|
504
525
|
instanceMeta(ctx) { // 열림(첫 점유) 시 1회 기록 — 허브가 open 앵커에서만
|
|
505
526
|
return { mode: ctx.query.mode ?? 'ranked', capacity: 4 }
|
|
506
527
|
},
|
|
507
528
|
})
|
|
529
|
+
```
|
|
508
530
|
|
|
531
|
+
```ts fragment
|
|
509
532
|
await setInstanceMeta('match', { mode: 'casual' }, { instance: '42' }) // 인가된 액션 갱신(LWW)
|
|
510
533
|
const meta = await instanceMeta('match', { instance: '42' }) // 읽기(미설정·닫힘 = null)
|
|
511
534
|
const rooms = await instancesOf('match', { meta: true }) // 로비: [{ instance, meta }]
|
|
@@ -523,7 +546,7 @@ const rooms = await instancesOf('match', { meta: true }) // 로
|
|
|
523
546
|
로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
|
|
524
547
|
같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
|
|
525
548
|
|
|
526
|
-
```ts
|
|
549
|
+
```ts fragment
|
|
527
550
|
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
528
551
|
// roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
|
|
529
552
|
// 빈 배열 = 마지막 이탈(InstanceClosed 도 발행되지만 컨슈머 축이 달라
|
|
@@ -575,7 +598,7 @@ export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
|
575
598
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
576
599
|
허브(KV) 이므로, 웹서버가 여러 대여도 같은 목록을 본다.
|
|
577
600
|
|
|
578
|
-
```ts
|
|
601
|
+
```ts fragment
|
|
579
602
|
const members = await ctx.presence()
|
|
580
603
|
// [{ id: 'user:1', info: { name: '가온' } }, …]
|
|
581
604
|
```
|