memi-board 0.51.2 → 0.52.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 (51) hide show
  1. package/README.md +297 -297
  2. package/dist/{ar-Dzc31QaB.js → ar-BVwpedHS.js} +23 -14
  3. package/dist/ar-BVwpedHS.js.map +1 -0
  4. package/dist/{commentImages-CV0g3ARh.js → commentImages-CzXO42gE.js} +39 -23
  5. package/dist/commentImages-CzXO42gE.js.map +1 -0
  6. package/dist/composables/useMemiBoardAutoTranslate.d.ts +1 -0
  7. package/dist/composables/useMemiBoardWritingAssistant.d.ts +3 -0
  8. package/dist/data/versionHistory.d.ts +5 -0
  9. package/dist/{de-Wci_gqEw.js → de-K2gYwR8U.js} +23 -14
  10. package/dist/de-K2gYwR8U.js.map +1 -0
  11. package/dist/{en-Dhe-tFzi.js → en-CmPLbFcN.js} +30 -14
  12. package/dist/en-CmPLbFcN.js.map +1 -0
  13. package/dist/{es-BHJ1VeX-.js → es-1P1miLJH.js} +23 -14
  14. package/dist/es-1P1miLJH.js.map +1 -0
  15. package/dist/{fr-aSscFSwK.js → fr-cDQCmNSc.js} +23 -14
  16. package/dist/fr-cDQCmNSc.js.map +1 -0
  17. package/dist/{id-BuUIYpdS.js → id-BzDIqeZ4.js} +23 -14
  18. package/dist/id-BzDIqeZ4.js.map +1 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +671 -466
  21. package/dist/index.js.map +1 -1
  22. package/dist/{ja-owJ_AiPl.js → ja-B2wxHGCr.js} +23 -14
  23. package/dist/ja-B2wxHGCr.js.map +1 -0
  24. package/dist/{pt-B3Uxrkz2.js → pt-BCY3OODD.js} +23 -14
  25. package/dist/pt-BCY3OODD.js.map +1 -0
  26. package/dist/runtime/components/CategorySettings.vue +37 -9
  27. package/dist/runtime/components/Editor.vue +9 -6
  28. package/dist/runtime/components/Settings.vue +198 -9
  29. package/dist/runtime/components/VersionHistory.vue +9 -2
  30. package/dist/storage.js +1 -1
  31. package/dist/types.d.ts +5 -0
  32. package/dist/utils/localizedFields.d.ts +12 -2
  33. package/dist/{zh-B49hHRSL.js → zh-Y824rwR6.js} +23 -14
  34. package/dist/zh-Y824rwR6.js.map +1 -0
  35. package/docs/firebase-setup.md +85 -85
  36. package/docs/firestore.rules.example +58 -58
  37. package/docs/host-integration.md +80 -80
  38. package/docs/internationalization.md +76 -72
  39. package/docs/roles-migration.md +18 -18
  40. package/docs/storage.rules.example +18 -18
  41. package/package.json +2 -2
  42. package/dist/ar-Dzc31QaB.js.map +0 -1
  43. package/dist/commentImages-CV0g3ARh.js.map +0 -1
  44. package/dist/de-Wci_gqEw.js.map +0 -1
  45. package/dist/en-Dhe-tFzi.js.map +0 -1
  46. package/dist/es-BHJ1VeX-.js.map +0 -1
  47. package/dist/fr-aSscFSwK.js.map +0 -1
  48. package/dist/id-BuUIYpdS.js.map +0 -1
  49. package/dist/ja-owJ_AiPl.js.map +0 -1
  50. package/dist/pt-B3Uxrkz2.js.map +0 -1
  51. package/dist/zh-B49hHRSL.js.map +0 -1
package/README.md CHANGED
@@ -1,75 +1,75 @@
1
1
  # memi-board
2
2
 
3
- Nuxt 4 + Nuxt UI + Firebase 프로젝트에 게시판을 붙이는 npm 패키지.
3
+ An npm package that adds a board to Nuxt 4 + Nuxt UI + Firebase projects.
4
4
 
5
- - 게시글 CRUD · 댓글 · Storage 첨부 · 카테고리 · 조회수
6
- - 작성자/관리자 권한 (Firestore Rules)
7
- - 로컬 비속어 + Firebase AI Logic(Gemini) 검열 (선택)
8
- - **공유 미리보기(OG)** — 호스트 SEO API 없이 목록·상세 메타 (`useMemiBoard*Seo`)
5
+ - Post CRUD · comments · Storage attachments · categories · view counts
6
+ - Author/admin permissions (Firestore Rules)
7
+ - Local profanity filter + Firebase AI Logic (Gemini) moderation (optional)
8
+ - **Share previews (OG)** — list and detail meta without a host SEO API (`useMemiBoard*Seo`)
9
9
 
10
- **서버 / Firebase Admin 불필요** (권한은 Rules, 검열은 브라우저 — 우회 가능, 아래 한계 참고).
11
- 공개 목록·글 상세만 SSR로 OG를 채우고, 쓰기·설정은 CSR로 두면 된다.
10
+ **No server or Firebase Admin required** (permissions live in Rules, moderation runs in the browser — it can be bypassed; see the limitations below).
11
+ Only the public list and post detail pages need SSR to fill OG tags; writing and settings pages can stay CSR.
12
12
 
13
- 같은 스택(Nuxt 4 · `@nuxt/ui` · `nuxt-vuefire` · Firebase)이면 **다른 호스트 앱에도 그대로 재사용** 가능하다. Firebase 프로젝트는 호스트마다 달라도 되고, 같은 프로젝트에서는 `boardId`로 게시판 인스턴스를 나눈다 — `memiBoardPosts`/`memiBoardComments`/`memiBoardLikes`는 모두 최상위 flat 컬렉션이고 `boardId`는 각 문서의 필드다(경로 세그먼트가 아니다).
13
+ Any host app on the same stack (Nuxt 4 · `@nuxt/ui` · `nuxt-vuefire` · Firebase) **can reuse it as is**. Each host can use its own Firebase project, and within one project board instances are separated by `boardId` — `memiBoardPosts`/`memiBoardComments`/`memiBoardLikes` are all top-level flat collections, and `boardId` is a field on each document (not a path segment).
14
14
 
15
- ## 안내 문서
15
+ ## Guides
16
16
 
17
- | 필요한 작업 | 문서 |
17
+ | Task | Guide |
18
18
  |---|---|
19
- | 다른 사이트에 처음 설치 | [호스트 설치 가이드](docs/host-integration.md) — 최소 설정, 라우트, 첫 게시판 |
20
- | 한 언어만 사용하거나 다국어·번역 선택 | [국제화·콘텐츠 번역](docs/internationalization.md) — 모드별 설정과 저장 시점 |
21
- | Firebase 연결과 규칙·인덱스 배포 | [Firebase 준비](docs/firebase-setup.md) |
22
- | 매장·조직별 담당 권한, 기존 역할 이전 | [역할과 마이그레이션](docs/roles-migration.md) |
23
- | 설정·API·문제 해결 상세 | 이 README의 [호스트 설정](#부모호스트-앱-설정), [API](#제공하는-api), [문제 해결](#문제-해결) |
19
+ | First install on another site | [Host integration guide](docs/host-integration.md) — minimal setup, routes, first board |
20
+ | Single language, or choosing multilingual UI and translation | [Internationalization and content translation](docs/internationalization.md) — per-mode setup and when translations are saved |
21
+ | Connecting Firebase and deploying rules and indexes | [Firebase setup](docs/firebase-setup.md) |
22
+ | Per-store/organization assigned permissions, migrating existing roles | [Roles and migration](docs/roles-migration.md) |
23
+ | Configuration, API, and troubleshooting details | This README's [host setup](#host-app-setup), [API](#provided-api), [troubleshooting](#troubleshooting) |
24
24
 
25
- `i18n.enabled`·`translation.enabled`는 **0.47.0 이상**에서 지원한다. 해당 옵션을 사용할 호스트는 `pnpm add memi-board@^0.47.0`으로 설치하거나 업데이트한다.
25
+ `i18n.enabled` and `translation.enabled` are supported in **0.47.0 and later**. Hosts that use these options should install or update with `pnpm add memi-board@^0.47.0`.
26
26
 
27
- ## 플랫폼 요구사항
27
+ ## Platform requirements
28
28
 
29
- | 항목 | 요구 |
29
+ | Item | Requirement |
30
30
  |------|------|
31
- | 런타임 | Nuxt **4+** |
32
- | UI | `@nuxt/ui` **4+** (컴포넌트가 `UButton` 등 auto-import 전제) |
33
- | 데이터 | Firebase **Firestore + Auth + Storage** |
31
+ | Runtime | Nuxt **4+** |
32
+ | UI | `@nuxt/ui` **4+** (components assume auto-imports such as `UButton`) |
33
+ | Data | Firebase **Firestore + Auth + Storage** |
34
34
  | Vue | `vue` 3.5+, `vuefire` 3.2+, `firebase` 12+ |
35
- | 모듈 | `nuxt-vuefire` (호스트가 설치·설정) |
35
+ | Module | `nuxt-vuefire` (installed and configured by the host) |
36
36
 
37
- **의도적으로 하지 않는 일:** Firebase 초기화, 라우트/미들웨어 등록, App Check 초기화, 서버 검열.
37
+ **Intentionally out of scope:** Firebase initialization, registering routes/middleware, App Check initialization, server-side moderation.
38
38
 
39
- ## 구조
39
+ ## Structure
40
40
 
41
- | 엔트리 | 역할 |
41
+ | Entry | Role |
42
42
  |--------|------|
43
- | `memi-board` | **Nuxt 모듈** — 설정, SFC auto-import, peer dedupe |
44
- | `memi-board/runtime` | composables, types, 유틸리티 (**Vue 컴포넌트 export 없음**) |
43
+ | `memi-board` | **Nuxt module** — configuration, SFC auto-import, peer dedupe |
44
+ | `memi-board/runtime` | composables, types, utilities (**no Vue component exports**) |
45
45
 
46
- 일반 사용자는 `nuxt.config.ts`의 `memiBoard`만 설정한다. 별도 Nuxt 플러그인은 필요 없다.
46
+ Most users only configure `memiBoard` in `nuxt.config.ts`. No separate Nuxt plugin is needed.
47
47
 
48
48
  ```
49
- 호스트 Nuxt 앱
50
- ├── nuxt-vuefire + firebaseConfig ← 인증·Firestore
51
- ├── (선택) App Check 플러그인 ← AI Logic Enforce 시 필수에 가까움
52
- ├── memi-board ← Nuxt 모듈 + 런타임 설정
53
- ├── pages/board/* ← 라우트는 호스트 소유 (+ SEO 한 줄)
54
- └── firestore.rules + storage.rules ← docs 예시 병합 후 배포
49
+ Host Nuxt app
50
+ ├── nuxt-vuefire + firebaseConfig ← auth · Firestore
51
+ ├── (optional) App Check plugin ← practically required with AI Logic Enforce
52
+ ├── memi-board ← Nuxt module + runtime config
53
+ ├── pages/board/* ← routes are owned by the host (+ one SEO line)
54
+ └── firestore.rules + storage.rules ← merge the docs examples, then deploy
55
55
  ```
56
56
 
57
- ## 설치
57
+ ## Installation
58
58
 
59
- **사전 준비:** Node + pnpm, Firebase 프로젝트(Firestore·Storage·Auth). 처음부터면 → [docs/firebase-setup.md](docs/firebase-setup.md).
59
+ **Prerequisites:** Node + pnpm, a Firebase project (Firestore · Storage · Auth). Starting from scratch → [docs/firebase-setup.md](docs/firebase-setup.md).
60
60
 
61
61
  ```bash
62
62
  pnpm add memi-board @nuxt/ui nuxt-vuefire vuefire firebase
63
63
  ```
64
64
 
65
- `vuefire` / `firebase` / `nuxt-vuefire`는 **호스트에 직접 설치**한다. TipTap은 `@nuxt/ui`와 `memi-board`가 관리하므로 호스트에서 별도로 설치하거나 버전을 맞출 필요가 없다. 날짜 표시는 `Intl`을 사용하므로 `dayjs`는 필요 없다.
65
+ Install `vuefire` / `firebase` / `nuxt-vuefire` **directly in the host**. TipTap is managed by `@nuxt/ui` and `memi-board`, so the host does not need to install it or align its version. Dates are formatted with `Intl`, so `dayjs` is not needed.
66
66
 
67
67
  ---
68
68
 
69
- ## 부모(호스트) 앱 설정
69
+ ## Host app setup
70
70
 
71
- 호스트가 하는 일만 정리한다. **호스트 전용 SEO API·서버 라우트는 필요 없다.**
72
- Firestore `listed === true` 공개 글을 패키지가 직접 읽고 `useSeoMeta`로 OG를 채운다.
71
+ This covers only what the host does. **No host-specific SEO API or server routes are needed.**
72
+ The package reads public posts (`listed === true`) from Firestore directly and fills OG tags with `useSeoMeta`.
73
73
 
74
74
  ### 1. `nuxt.config.ts` — modules + `memiBoard` + vuefire + routeRules
75
75
 
@@ -83,11 +83,11 @@ export default defineNuxtConfig({
83
83
  ],
84
84
 
85
85
  memiBoard: {
86
- // 단일 언어 예시. 다국어 서비스는 아래 국제화 가이드 참고
86
+ // Single-language example. For multilingual services, see the internationalization guide below
87
87
  locale: 'ko',
88
88
  i18n: { enabled: false },
89
89
  // Firestore: memiBoardPosts / memiBoardComments / memiBoardLikes / memiBoardSettings / memiBoardUsers
90
- // (전부 flat 컬렉션 — boardId 는 각 문서의 필드. 컴포저블 호출 시 boardId 인자로 지정)
90
+ // (all flat collections — boardId is a field on each document. Pass it as the boardId argument when calling composables)
91
91
  auth: { providers: ['google', 'apple'] }, // + 'emailPassword'
92
92
  moderation: {
93
93
  enabled: true,
@@ -95,22 +95,22 @@ export default defineNuxtConfig({
95
95
  onError: 'block', // 'allow' | 'block'
96
96
  moderateImages: false,
97
97
  useLimitedUseAppCheckTokens: false,
98
- // localBlocklist: ['금칙어'],
98
+ // localBlocklist: ['banned-word'],
99
99
  // blockBanThreshold: 10,
100
100
  // blockBanDecayMs: 24 * 60 * 60 * 1000,
101
101
  },
102
- // 공유 미리보기(OG) — 호스트 API 없이 패키지 composable 사용
102
+ // Share previews (OG) — use the package composables, no host API
103
103
  seo: {
104
104
  enabled: true,
105
- siteName: 'My App', // og:site_name · title 접미사
105
+ siteName: 'My App', // og:site_name · title suffix
106
106
  siteUrl: process.env.NUXT_PUBLIC_SITE_URL
107
- || 'https://example.com', // 절대 URL origin (trailing slash 없음)
108
- defaultOgImage: '/images/og-default.jpg', // 글에 previewImage 없을 때
109
- basePath: '/board', // canonical 경로 prefix
107
+ || 'https://example.com', // absolute URL origin (no trailing slash)
108
+ defaultOgImage: '/images/og-default.jpg', // when the post has no previewImage
109
+ basePath: '/board', // canonical path prefix
110
110
  },
111
111
  },
112
112
 
113
- // (선택) 호스트 전역 siteUrl — SEO가 memiBoard.seo.siteUrl 다음으로 참고할 수 있음
113
+ // (optional) host-wide siteUrl — SEO may fall back to it after memiBoard.seo.siteUrl
114
114
  runtimeConfig: {
115
115
  public: {
116
116
  siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://example.com',
@@ -118,8 +118,8 @@ export default defineNuxtConfig({
118
118
  },
119
119
 
120
120
  vuefire: {
121
- // 콘솔 → 프로젝트 설정 → 내 앱 → 웹 앱 SDK snippet 과 동일해야 함
122
- // (appId 가 다른 웹 앱이면 App Check / AI Logic 이 실패한다)
121
+ // Must match Console → Project settings → Your apps → web app SDK snippet
122
+ // (if it is a web app with a different appId, App Check / AI Logic fails)
123
123
  config: {
124
124
  apiKey: '...',
125
125
  authDomain: '...',
@@ -132,8 +132,8 @@ export default defineNuxtConfig({
132
132
  },
133
133
 
134
134
  routeRules: {
135
- // 공개 목록·글 상세: SSR 유지(기본) → 카톡/OG 크롤러가 HTML 메타를 읽음
136
- // 쓰기·설정만 CSR
135
+ // Public list and post detail: keep SSR (default) → link preview / OG crawlers read the HTML meta
136
+ // Only writing and settings pages are CSR
137
137
  '/board/**/new': { ssr: false },
138
138
  '/board/**/**/edit': { ssr: false },
139
139
  '/board/version-history': { ssr: false },
@@ -143,54 +143,54 @@ export default defineNuxtConfig({
143
143
  })
144
144
  ```
145
145
 
146
- `memiBoard` 옵션은 Nuxt 모듈이 런타임 플러그인으로 자동 전달한다. **`app/plugins/memi-board.ts`를 만들지 않는다.**
146
+ The Nuxt module passes the `memiBoard` options to the runtime plugin automatically. **Do not create `app/plugins/memi-board.ts`.**
147
147
 
148
- #### `memiBoard` 옵션 요약
148
+ #### `memiBoard` options summary
149
149
 
150
- | 키 | 기본 | 설명 |
150
+ | Key | Default | Description |
151
151
  |----|------|------|
152
- | `postsCollection` | `'memiBoardPosts'` | 글 컬렉션 이름 |
153
- | `commentsCollection` | `'memiBoardComments'` | 댓글 컬렉션 이름 |
154
- | `likesCollection` | `'memiBoardLikes'` | 좋아요 컬렉션 이름 |
155
- | `reportsCollection` | `'memiBoardReports'` | 신고 컬렉션 이름 |
156
- | `settingsCollection` | `'memiBoardSettings'` | 보드 설정 컬렉션 이름 |
157
- | `usersCollection` | `'memiBoardUsers'` | 역할 문서 컬렉션 이름 |
158
- | `auth.providers` | `['google','apple']` | 로그인 공급자 |
159
- | `moderation.*` | (아래 AI 절) | 로컬·Gemini 검열 |
160
- | `seo.enabled` | `true` | `false`면 SEO composable no-op |
161
- | `seo.siteName` | `'Board'` | OG 사이트명·title 접미사 |
162
- | `seo.siteUrl` | `''` | 절대 URL origin. 비우면 `runtimeConfig.public.siteUrl` → 요청 origin 순 |
163
- | `seo.defaultOgImage` | `''` | 대표 이미지 없을 때 (상대경로 또는 `https://…`) |
152
+ | `postsCollection` | `'memiBoardPosts'` | Posts collection name |
153
+ | `commentsCollection` | `'memiBoardComments'` | Comments collection name |
154
+ | `likesCollection` | `'memiBoardLikes'` | Likes collection name |
155
+ | `reportsCollection` | `'memiBoardReports'` | Reports collection name |
156
+ | `settingsCollection` | `'memiBoardSettings'` | Board settings collection name |
157
+ | `usersCollection` | `'memiBoardUsers'` | Role documents collection name |
158
+ | `auth.providers` | `['google','apple']` | Sign-in providers |
159
+ | `moderation.*` | (AI section below) | Local · Gemini moderation |
160
+ | `seo.enabled` | `true` | When `false`, the SEO composables are no-ops |
161
+ | `seo.siteName` | `'Board'` | OG site name · title suffix |
162
+ | `seo.siteUrl` | `''` | Absolute URL origin. If empty, falls back to `runtimeConfig.public.siteUrl` → request origin |
163
+ | `seo.defaultOgImage` | `''` | Used when there is no preview image (relative path or `https://…`) |
164
164
  | `seo.basePath` | `'/board'` | canonical: `{base}`, `{base}/{category}`, `{base}/{category}/{slug}` |
165
- | `i18n.enabled` | `true` | `false`면 UI를 `locale`로 고정하고 언어 선택·콘텐츠 번역을 끈다 |
166
- | `translation.enabled` | `true` | `false`면 UI 언어 전환은 유지하고 콘텐츠 번역 생성·표시를 끈다 |
167
- | `locale` | `'ko'` | 국제화가 켜져 있으면 게시판·호스트 언어 다음의 기본값. 꺼져 있으면 고정 UI 언어 |
168
- | `roleLabels` | 언어별 기본 명칭 | 서비스별 역할 표시 이름. 권한과 담당 범위는 바꾸지 않음 |
165
+ | `i18n.enabled` | `true` | When `false`, fixes the UI to `locale` and disables language selection and content translation |
166
+ | `translation.enabled` | `true` | When `false`, keeps UI language switching but disables generating and showing content translations |
167
+ | `locale` | `'ko'` | With internationalization on, the fallback after the board and host languages. With it off, the fixed UI language |
168
+ | `roleLabels` | Default names per language | Per-service role display names. Does not change permissions or assignment scope |
169
169
 
170
- #### routeRules 원칙
170
+ #### routeRules principles
171
171
 
172
- | 경로 | SSR | 이유 |
172
+ | Path | SSR | Reason |
173
173
  |------|-----|------|
174
- | `/board`, `/board/{category}`, `/board/{category}/{slug}` | **on** (기본) | OG/카톡 미리보기 |
175
- | `/board/**/new`, `…/edit`, 설정·유저 관리 | **off** | 로그인·권한 UI, SEO 불필요 |
174
+ | `/board`, `/board/{category}`, `/board/{category}/{slug}` | **on** (default) | OG / link previews (e.g. KakaoTalk, Slack) |
175
+ | `/board/**/new`, `…/edit`, settings · user management | **off** | Sign-in and permission UI, no SEO needed |
176
176
 
177
- > 예전 가이드의 `/board/**: { ssr: false }` 전부 CSR은 **공유 미리보기가 비게 된다.** 공개 읽기 경로는 SSR을 켠다.
177
+ > The older guide's all-CSR `/board/**: { ssr: false }` **leaves share previews empty.** Keep SSR on for public read paths.
178
178
 
179
- ### 2. 페이지에서 SEO 한 줄 (auto-import)
179
+ ### 2. One SEO line per page (auto-import)
180
180
 
181
- 모듈이 `useMemiBoardListSeo` / `useMemiBoardPostSeo`를 auto-import한다.
182
- **호스트 `/server/api/board/**` SEO 엔드포인트는 만들지 않는다.**
181
+ The module auto-imports `useMemiBoardListSeo` / `useMemiBoardPostSeo`.
182
+ **Do not create host `/server/api/board/**` SEO endpoints.**
183
183
 
184
184
  ```vue
185
- <!-- app/pages/board/index.vue — 전체 목록 -->
185
+ <!-- app/pages/board/index.vue — full list -->
186
186
  <script setup lang="ts">
187
187
  await useMemiBoardListSeo({ path: '/board' })
188
- // … MemiBoardList 등
188
+ // … MemiBoardList, etc.
189
189
  </script>
190
190
  ```
191
191
 
192
192
  ```vue
193
- <!-- app/pages/board/[category]/index.vue — 카테고리 목록 -->
193
+ <!-- app/pages/board/[category]/index.vue — category list -->
194
194
  <script setup lang="ts">
195
195
  const route = useRoute()
196
196
  const categoryId = computed(() => String(route.params.category || ''))
@@ -203,7 +203,7 @@ await useMemiBoardListSeo({
203
203
  ```
204
204
 
205
205
  ```vue
206
- <!-- app/pages/board/[category]/[id]/index.vue — 글 상세 (id 자리 = slug) -->
206
+ <!-- app/pages/board/[category]/[id]/index.vue — post detail (the id slot = slug) -->
207
207
  <script setup lang="ts">
208
208
  const route = useRoute()
209
209
  const categoryId = computed(() => String(route.params.category || ''))
@@ -216,8 +216,8 @@ const { post: postSeo } = await useMemiBoardPostSeo({
216
216
  `/board/${encodeURIComponent(categoryId.value)}/${encodeURIComponent(id.value)}`,
217
217
  ),
218
218
  })
219
- // postSeo: id · title · previewImage 등 — SSR 첫 로드에서는 바로 채워진다.
220
- // 클라이언트 이동에서는 화면 전환을 막지 않으려고 기다리지 않으므로 처음엔 null 일 수 있다.
219
+ // postSeo: id · title · previewImage, etc. — filled immediately on the first SSR load.
220
+ // On client navigation it is not awaited so page transitions are not blocked, so it may be null at first.
221
221
 
222
222
  const { resolvePostIdBySlug } = useMemiBoardPosts(categoryId)
223
223
  const internalId = ref('')
@@ -227,43 +227,43 @@ watch([categoryId, id], async ([category, slug]) => {
227
227
  return
228
228
  }
229
229
  if (import.meta.server) return
230
- // id 만 조회 (본문은 MemiBoardDetail 이 구독). 방금 작성한 글은 조회 없이 바로 나온다.
230
+ // Look up the id only (MemiBoardDetail subscribes to the body). A just-written post resolves without a lookup.
231
231
  const result = await resolvePostIdBySlug(slug)
232
232
  internalId.value = result.status === 'ok' ? result.id : ''
233
233
  }, { immediate: true })
234
234
  </script>
235
235
  ```
236
236
 
237
- > 상세·수정 페이지에서 slug → id 는 `getPostBySlug`(본문까지 읽음) 대신 `resolvePostIdBySlug` 를 쓴다. 저장 직후 미리보기 진입이 Firestore 왕복 2회만큼 빨라진다.
237
+ > On detail and edit pages, resolve slug → id with `resolvePostIdBySlug` instead of `getPostBySlug` (which also reads the body). Opening the preview right after saving becomes faster by two Firestore round trips.
238
238
 
239
- | composable | 대상 | OG 요약 |
239
+ | composable | Target | OG summary |
240
240
  |------------|------|---------|
241
- | `useMemiBoardListSeo` | 목록 | 카테고리 label·description, 최근 공개 글 이미지 |
242
- | `useMemiBoardPostSeo` | 상세 | 제목·요약·작성자, **`previewImage` → og:image** |
241
+ | `useMemiBoardListSeo` | List | Category label · description, image from the latest public post |
242
+ | `useMemiBoardPostSeo` | Detail | Title · summary · author, **`previewImage` → og:image** |
243
243
 
244
- - 공개(`listed`) 글만 조회. **숨김 게시판**은 메타를 비우고 `noindex` 쪽에 가깝게 동작한다.
245
- - 글 상세 `og:type` = `article`, 목록 = `website`.
246
- - 로컬 확인:
244
+ - Only public (`listed`) posts are queried. **Hidden boards** leave the meta empty and behave closer to `noindex`.
245
+ - Post detail `og:type` = `article`, list = `website`.
246
+ - Local check:
247
247
  `curl -sL 'http://localhost:3000/board/…' | grep og:title`
248
- 또는 브라우저 **페이지 소스 보기** (`view-source:`). DevTools Elements만 보면 하이드레이션 이후와 헷갈릴 수 있다.
249
- - 카톡·슬랙 미리보기는 **배포 URL**로 확인 (localhost 크롤 불가).
248
+ or the browser's **View Page Source** (`view-source:`). Looking only at DevTools Elements can be confused with the post-hydration DOM.
249
+ - Check link previews (e.g. KakaoTalk, Slack) with the **deployed URL** (localhost cannot be crawled).
250
250
 
251
- ### 3. 부모 설정 체크리스트
251
+ ### 3. Host setup checklist
252
252
 
253
- - [ ] `modules`에 `@nuxt/ui`, `nuxt-vuefire`, `memi-board`
253
+ - [ ] `@nuxt/ui`, `nuxt-vuefire`, `memi-board` in `modules`
254
254
  - [ ] `memiBoard.auth` · `moderation` · **`seo`**
255
255
  - [ ] `seo.siteUrl` / `seo.siteName` / `seo.defaultOgImage` / `seo.basePath`
256
- - [ ] 공개 board 경로 **SSR on**, 쓰기·설정만 `ssr: false`
257
- - [ ] 목록·상세 페이지에 `await useMemiBoard*Seo(…)` 한 줄
258
- - [ ] 호스트 SEO API **만들지 않음**
259
- - [ ] CSS `@source` (아래)
260
- - [ ] (선택) 읽은 글 표시 CSS (아래)
261
- - [ ] Rules·indexes **deploy**
262
- - [ ] 최초 owner + 첫 게시판
256
+ - [ ] **SSR on** for public board paths, `ssr: false` only for writing and settings
257
+ - [ ] One `await useMemiBoard*Seo(…)` line on list and detail pages
258
+ - [ ] **No** host SEO API
259
+ - [ ] CSS `@source` (below)
260
+ - [ ] (optional) Read-post styling CSS (below)
261
+ - [ ] **Deploy** Rules and indexes
262
+ - [ ] Initial owner + first board
263
263
 
264
- ### 4. Tailwind `@source` (필수)
264
+ ### 4. Tailwind `@source` (required)
265
265
 
266
- 패키지 SFC 안의 클래스가 purge되지 않도록:
266
+ So classes inside the package SFCs are not purged:
267
267
 
268
268
  ```css
269
269
  /* app/assets/css/main.css */
@@ -272,25 +272,25 @@ watch([categoryId, id], async ([category, slug]) => {
272
272
  @source "../../../node_modules/memi-board/dist/runtime/components";
273
273
  ```
274
274
 
275
- ### 5. 국제화와 콘텐츠 번역 (선택)
275
+ ### 5. Internationalization and content translation (optional)
276
276
 
277
- [국제화·번역 가이드](docs/internationalization.md)에 모드별 복사 예제, 호스트 언어 연결, 번역 저장 시점, 오류 처리와 기존 데이터 유지 정책을 정리했다.
277
+ The [internationalization and translation guide](docs/internationalization.md) covers copy-paste examples per mode, connecting the host language, when translations are saved, error handling, and the policy for keeping existing data.
278
278
 
279
- | 목적 | `memiBoard` 설정 |
279
+ | Goal | `memiBoard` config |
280
280
  |---|---|
281
- | 한국어 전용 | `locale: 'ko', i18n: { enabled: false }` |
282
- | UI만 다국어 | `i18n: { enabled: true }, translation: { enabled: false }` |
283
- | UI·콘텐츠 모두 다국어 | `i18n: { enabled: true }, translation: { enabled: true }` |
281
+ | Korean only | `locale: 'ko', i18n: { enabled: false }` |
282
+ | Multilingual UI only | `i18n: { enabled: true }, translation: { enabled: false }` |
283
+ | Multilingual UI and content | `i18n: { enabled: true }, translation: { enabled: true }` |
284
284
 
285
- 설정 생략 시 두 옵션은 `true`이며, `i18n.enabled: false`이면 콘텐츠 번역도 꺼진다. UI는 `locale`로 고정하고 콘텐츠는 원문을 표시한다. 기존 번역 데이터는 유지한다. 호스트의 언어 전환을 연결할 때만 `@nuxtjs/i18n`을 추가하면 된다.
285
+ If omitted, both options are `true`; with `i18n.enabled: false`, content translation is also off. The UI is fixed to `locale` and content is shown in the original. Existing translation data is kept. Add `@nuxtjs/i18n` only when connecting the host's language switcher.
286
286
 
287
- 보드 설정의 **글 작성 시 자동 번역**(`autoTranslatePosts`)은 기본 꺼짐이다. 켜면 새 글의 제목·요약·본문을 번역해 함께 저장한다.
287
+ The board setting **Auto-translate posts on write** (`autoTranslatePosts`) is off by default. When on, the title, summary, and body of new posts are translated and saved together.
288
288
 
289
- 번역 버튼은 라벨·설명을 번역해 임시 반영하고 **저장**을 눌러 적용한다. 자동 번역 토글은 기본 꺼짐이며, 켜면 **설정 저장 시** 번역한 뒤 저장한다. 문구가 같고 번역이 모두 있으면 재번역을 건너뛴다. 서비스 전체의 국제화·번역이 꺼져 있으면 자동 번역도 실행하지 않는다.
289
+ The translate button translates labels and descriptions as a temporary change; press **Save** to apply. The auto-translate toggle is off by default; when on, it translates **when settings are saved** and then saves. Retranslation is skipped when the text is unchanged and all translations exist. If internationalization or translation is turned off service-wide, auto-translation does not run either.
290
290
 
291
- ### 6. 읽은 글 표시 (선택)
291
+ ### 6. Read-post styling (optional)
292
292
 
293
- 목록·미니 컴포넌트의 글 링크는 `.memi-board-post-link` 클래스가 붙어 있고, 제목 텍스트는 `text-inherit`로 링크 색을 따른다. 패키지는 색을 강제하지 않으므로, 방문한 글을 흐리게 보이고 싶으면 호스트 CSS에 `:visited` 스타일만 추가하면 된다.
293
+ Post links in the list and mini components carry the `.memi-board-post-link` class, and the title text uses `text-inherit` to follow the link color. The package does not force a color, so to dim visited posts, just add a `:visited` style to the host CSS.
294
294
 
295
295
  ```css
296
296
  /* app/assets/css/main.css */
@@ -303,57 +303,57 @@ a.memi-board-post-link:visited {
303
303
  }
304
304
  ```
305
305
 
306
- **`:visited`는 `color`만 허용하고, 그마저도 `var()`·알파값은 브라우저가 무시한다** (히스토리 스니핑 방지). 그래서 Nuxt UI 토큰(`var(--ui-text-muted)`)을 그대로 못 쓰고 라이트/다크용 리터럴 색상을 각각 지정해야 한다(위 값은 Tailwind `neutral-500`/`neutral-400`).
306
+ **`:visited` only allows `color`, and browsers even ignore `var()` and alpha values there** (to prevent history sniffing). So Nuxt UI tokens (`var(--ui-text-muted)`) cannot be used directly; specify literal colors for light and dark separately (the values above are Tailwind `neutral-500`/`neutral-400`).
307
307
 
308
- 브라우저 네이티브 `:visited` 라서 서버·DB 저장이 필요 없다. 단, SPA 클라이언트 라우팅(`NuxtLink`)으로만 이동한 경우 브라우저에 "실제 방문"으로 기록되지 않을 수 있어 완전히 신뢰할 수는 없다 — 정확한 읽음 추적이 필요하면 별도로 방문 글 ID를 저장하는 방식을 구현해야 한다.
308
+ Because it is the browser's native `:visited`, no server or DB storage is needed. However, navigation only via SPA client routing (`NuxtLink`) may not be recorded by the browser as a "real visit", so it is not fully reliable — if you need accurate read tracking, implement a separate mechanism that stores visited post IDs.
309
309
 
310
- ### 7. Security Rules + 인덱스 배포
310
+ ### 7. Deploying Security Rules + indexes
311
311
 
312
- 1. [docs/firestore.rules.example](docs/firestore.rules.example) → 호스트 `firestore.rules`에 병합
313
- (`memiBoardPosts`/`memiBoardComments`/`memiBoardLikes`/`memiBoardReports`/`memiBoardSettings`/`memiBoardUsers` — 전부 최상위 flat 컬렉션)
314
- 2. [docs/storage.rules.example](docs/storage.rules.example) → `storage.rules` 병합
315
- 3. `firebase.json`의 Storage `bucket`이 `vuefire.config.storageBucket`과 같은지 확인
316
- (`*.appspot.com` vs `*.firebasestorage.app` — [firebase-setup.md](docs/firebase-setup.md) 참고)
317
- 4. 목록/작성자별 쿼리에 필요한 복합 인덱스 배포 (아래)
318
- 5. 배포:
312
+ 1. Merge [docs/firestore.rules.example](docs/firestore.rules.example) → host `firestore.rules`
313
+ (`memiBoardPosts`/`memiBoardComments`/`memiBoardLikes`/`memiBoardReports`/`memiBoardSettings`/`memiBoardUsers` — all top-level flat collections)
314
+ 2. Merge [docs/storage.rules.example](docs/storage.rules.example) → `storage.rules`
315
+ 3. Check that the Storage `bucket` in `firebase.json` matches `vuefire.config.storageBucket`
316
+ (`*.appspot.com` vs `*.firebasestorage.app` — see [firebase-setup.md](docs/firebase-setup.md))
317
+ 4. Deploy the composite indexes needed for list and per-author queries (below)
318
+ 5. Deploy:
319
319
  ```bash
320
320
  firebase deploy --only firestore:rules,storage,firestore:indexes
321
321
  ```
322
- 6. Storage의 Firestore 조회 권한 설정: 배포 시 교차 서비스 IAM 역할 추가 안내를 승인하고, 호스트 Firebase 프로젝트의 Storage 서비스 계정에 `roles/firebaserules.firestoreServiceAgent`가 있는지 확인한다. [부모/호스트 설정 절차](docs/firebase-setup.md#storage-규칙의-firestore-조회-권한-부모호스트-프로젝트-필수-설정) 참고. CI 배포 성공만으로 이 권한 설정을 보장하지 않는다.
322
+ 6. Grant Storage permission to read Firestore: approve the prompt to add the cross-service IAM role during deploy, and check that the Storage service account of the host Firebase project has `roles/firebaserules.firestoreServiceAgent`. See the [host setup procedure](docs/firebase-setup.md#firestore-read-access-for-storage-rules-required-host-app-setup). A successful CI deploy alone does not guarantee this permission.
323
323
 
324
- 0.45.1로 갱신하는 호스트는 [새 규칙 적용·전환 절차](docs/firebase-setup.md#0451-규칙-변경과-기존-호스트-전환)를 함께 확인한다. 패키지 설치나 사이트 푸시만으로 Firebase 규칙은 배포되지 않는다.
324
+ Hosts updating to 0.45.1 should also check the [procedure for applying the new rules and migrating](docs/firebase-setup.md#0451-rule-changes-and-migrating-existing-hosts). Installing the package or pushing the site does not deploy Firebase rules.
325
325
 
326
- **Rules를 파일에만 넣고 배포 안 하면** 글쓰기에서 `permission-denied` — 가장 흔한 설치 실수.
327
- **Storage 규칙을 앱이 쓰는 버킷이 아닌 곳에 배포하면** 본문 이미지에서 `storage/unauthorized`.
326
+ **If Rules are only in the file and not deployed**, writing posts fails with `permission-denied` — the most common installation mistake.
327
+ **If Storage rules are deployed to a bucket other than the one the app uses**, body images fail with `storage/unauthorized`.
328
328
 
329
- #### 복합 인덱스
329
+ #### Composite indexes
330
330
 
331
- [docs/firestore.indexes.json.example](docs/firestore.indexes.json.example) 를 호스트 `firestore.indexes.json`에 병합한다. 컬렉션 이름을 커스터마이즈했다면(`memiBoard.postsCollection` 등) 인덱스의 `collectionGroup` 값도 그에 맞게 바꿔야 한다.
331
+ Merge [docs/firestore.indexes.json.example](docs/firestore.indexes.json.example) into the host `firestore.indexes.json`. If you customized collection names (`memiBoard.postsCollection`, etc.), change the indexes' `collectionGroup` values to match.
332
332
 
333
- > ⚠️ npm 패키지 tarball에도 `docs/` 가 포함된다(0.4.7+). 구버전이면 GitHub의 `docs/`를 보면 된다.
333
+ > ⚠️ The npm package tarball also includes `docs/` (0.4.7+). For older versions, see `docs/` on GitHub.
334
334
 
335
- ### 8. 최초 관리자와 카테고리 만들기
335
+ ### 8. Creating the initial admin and categories
336
336
 
337
- Rules는 일반 사용자가 스스로 관리자가 되는 것을 막는다. 따라서 최초 한 번은 다음 순서가 필요하다.
337
+ Rules prevent regular users from making themselves admins. So the first time, follow these steps:
338
338
 
339
- 1. 아래 게시판 페이지를 만든 뒤 Google 또는 Apple로 로그인한다.
340
- 2. Firebase Console → Firestore에서 `memiBoardUsers/{uid}` 문서를 찾는다.
341
- 3. 해당 문서의 `role`을 문자열 `owner`로 변경한다.
342
- - UID는 Firebase Console → Authentication → Users에서도 확인할 수 있다.
343
- 4. `/board-settings`에서 첫 카테고리를 만든다. 카테고리가 없으면 글을 작성할 수 없다.
344
- 5. 이후 역할 변경은 `/board-users`의 `MemiBoardUsers`에서 처리한다.
339
+ 1. After creating the board pages below, sign in with Google or Apple.
340
+ 2. In Firebase Console → Firestore, find the `memiBoardUsers/{uid}` document.
341
+ 3. Change that document's `role` to the string `owner`.
342
+ - You can also find the UID in Firebase Console → Authentication → Users.
343
+ 4. Create the first category at `/board-settings`. Posts cannot be written without a category.
344
+ 5. After that, change roles in `MemiBoardUsers` at `/board-users`.
345
345
 
346
- > 최초 관리자 지정은 신뢰할 수 있는 프로젝트 운영자가 Firebase Console에서만 수행한다. 클라이언트 코드에 관리자 UID나 우회 규칙을 넣지 않는다.
346
+ > Only a trusted project operator should assign the initial admin, and only in Firebase Console. Do not put admin UIDs or bypass rules in client code.
347
347
 
348
- ## 사용법
348
+ ## Usage
349
349
 
350
- 라우팅·미들웨어는 **호스트 소유**. 컴포넌트는 모듈 auto-import (`MemiBoardList` 등).
351
- `import { MemiBoardEditor } from 'memi-board/runtime'`는 **안 된다**. 컴포넌트는 모듈이 auto-import하고, composable과 type만 `memi-board/runtime`에서 명시적으로 가져올 수 있다.
350
+ Routing and middleware are **owned by the host**. Components are auto-imported by the module (`MemiBoardList`, etc.).
351
+ `import { MemiBoardEditor } from 'memi-board/runtime'` **does not work**. Components are auto-imported by the module; only composables and types can be imported explicitly from `memi-board/runtime`.
352
352
 
353
- 라우트별 완성 예제는 [호스트 설치 가이드](docs/host-integration.md#7-목록글쓰기상세-페이지-연결)에 있다. 목록·에디터·상세에는 `boardId`를 전달한다.
353
+ Complete per-route examples are in the [host integration guide](docs/host-integration.md#7-list-new-post-and-detail-pages). Pass `boardId` to the list, editor, and detail.
354
354
 
355
355
  ```vue
356
- <!-- free 게시판 목록: 게시판 설정에서 먼저 free를 만든다 -->
356
+ <!-- free board list: create "free" in the board settings first -->
357
357
  <template>
358
358
  <MemiBoardList
359
359
  board-id="free"
@@ -363,7 +363,7 @@ Rules는 일반 사용자가 스스로 관리자가 되는 것을 막는다. 따
363
363
  ```
364
364
 
365
365
  ```vue
366
- <!-- 새 글: saved는 새 글의 slug를 전달한다 -->
366
+ <!-- New post: saved emits the new post's slug -->
367
367
  <template>
368
368
  <MemiBoardEditor
369
369
  board-id="free"
@@ -372,11 +372,11 @@ Rules는 일반 사용자가 스스로 관리자가 되는 것을 막는다. 따
372
372
  </template>
373
373
  ```
374
374
 
375
- 상세에는 `<MemiBoardDetail :board-id="boardId" :post-id="postId" />`를 사용한다. URL의 slug는 `resolvePostIdBySlug()`로 Firestore 문서 ID로 바꾼다. 기존 글 수정의 `saved` 이벤트는 문서 ID를 전달하므로 현재 상세 URL의 slug를 유지한다. 공개 페이지의 SEO와 로그인·관리 페이지 가드까지 포함한 연결 순서는 설치 가이드를 따른다.
375
+ For the detail page, use `<MemiBoardDetail :board-id="boardId" :post-id="postId" />`. Convert the URL slug to the Firestore document ID with `resolvePostIdBySlug()`. When editing an existing post, the `saved` event emits the document ID, so keep the slug of the current detail URL. Follow the installation guide for the full wiring order, including SEO for public pages and guards for sign-in and admin pages.
376
376
 
377
- ### 관리 페이지
377
+ ### Admin pages
378
378
 
379
- 전체 설정·사용자 관리는 `owner` 또는 `admin` 역할을 사용한다. 기본 설치에서는 `authorized`를 전달하지 않는다. 관리자 역할 지정은 owner만 가능하고, owner 자체 변경은 신뢰할 수 있는 서버나 콘솔에서 한다. 담당 manager의 개별 게시판 설정은 `MemiBoardCategorySettings`와 `canConfigureBoard(boardId)`를 사용한다.
379
+ Global settings and user management use the `owner` or `admin` role. In a default install, do not pass `authorized`. Only the owner can assign the admin role, and changing the owner itself is done from a trusted server or the console. For per-board settings by an assigned manager, use `MemiBoardCategorySettings` and `canConfigureBoard(boardId)`.
380
380
 
381
381
  ```vue
382
382
  <!-- app/pages/board-settings.vue -->
@@ -385,9 +385,9 @@ Rules는 일반 사용자가 스스로 관리자가 되는 것을 막는다. 따
385
385
  </template>
386
386
  ```
387
387
 
388
- 전체 게시판 설정에는 관리자(`owner`/`admin`) 전용 **고아 파일·문서 정리**가 표시됩니다. **고아 항목 조회**를 누르면 전체 게시글·댓글·좋아요와 Storage를 읽고, 부모 글이 없는 댓글·파일, 삭제된 댓글의 이미지, 대상 글·댓글이 없는 좋아요를 표시합니다. 경로·유형·파일 크기를 확인한 뒤 개별 또는 전체 선택하여 삭제할 수 있습니다. 최근 24시간 내 갱신된 파일과 기존 본문·번역·첨부·댓글에서 참조하는 파일은 보존합니다. 신고 기록과 게시글 없는 `body` 하위 문서는 이 기능의 정리 대상이 아닙니다.
388
+ The global board settings show an admin-only (`owner`/`admin`) **orphaned file and document cleanup**. Pressing **Find orphaned items** reads all posts, comments, likes, and Storage, and lists comments and files whose parent post is missing, images of deleted comments, and likes whose target post or comment is missing. After reviewing the path, type, and file size, you can delete items individually or select all. Files updated within the last 24 hours and files referenced by existing bodies, translations, attachments, or comments are preserved. Report records and `body` subdocuments without a post are not cleaned up by this feature.
389
389
 
390
- 사용하려면 호스트 프로젝트에 [Firestore 규칙 예제](docs/firestore.rules.example)와 [Storage 규칙 예제](docs/storage.rules.example)의 관리자 조회·삭제 권한을 반영해야 합니다. 커스텀 컬렉션을 사용하면 규칙 경로도 맞춰야 합니다. 조회는 본문을 포함한 전체 데이터를 읽으므로 데이터 규모에 따라 읽기 비용과 시간이 발생합니다. 삭제 대상은 내부 스냅샷에 고정하고, 조회 후 계정·설정 또는 파일 generation이 바뀌면 삭제를 중단합니다. 삭제 직전에 서버에서 다시 조회하며, 문서는 트랜잭션으로 부모 존재 여부를 확인합니다. 파일 삭제와 Firestore 저장은 원자적으로 묶이지 않으므로 게시판 사용이 적은 시간에 실행하세요. 실패한 항목은 목록에 남아 재시도할 수 있습니다.
390
+ To use it, apply the admin read and delete permissions from the [Firestore rules example](docs/firestore.rules.example) and the [Storage rules example](docs/storage.rules.example) to the host project. If you use custom collections, update the rule paths as well. The scan reads all data including bodies, so it incurs read costs and time depending on data size. Deletion targets are pinned to an internal snapshot, and deletion stops if the account, settings, or file generation changes after the scan. Items are re-fetched from the server right before deletion, and documents check for the parent's existence in a transaction. File deletion and Firestore writes are not atomic, so run it when board usage is low. Failed items stay in the list and can be retried.
391
391
 
392
392
  ```vue
393
393
  <!-- app/pages/board-users.vue -->
@@ -396,116 +396,116 @@ Rules는 일반 사용자가 스스로 관리자가 되는 것을 막는다. 따
396
396
  </template>
397
397
  ```
398
398
 
399
- 호스트의 별도 사이트 관리자 체계를 연동할 때만 `authorized`를 사용할 수 있다. 이 prop은 UI 표시만 허용하며 Firestore Rules를 우회하지 않는다. 이 경우에도 해당 사용자를 게시판 admin/owner로 등록하거나 호스트 Rules를 같은 관리자 체계에 맞게 수정해야 한다. 관리 URL이 일반 사용자에게 노출되지 않도록 호스트 라우트 가드를 추가하는 것을 권장한다.
399
+ Use `authorized` only when integrating the host's own site-admin system. This prop only allows the UI to be shown and does not bypass Firestore Rules. Even then, you must register those users as board admin/owner or change the host Rules to match the same admin system. Adding host route guards is recommended so admin URLs are not exposed to regular users.
400
400
 
401
- ### 제공하는 API
401
+ ### Provided API
402
402
 
403
- **컴포넌트 (auto-import):**
403
+ **Components (auto-import):**
404
404
  `MemiBoardList`, `MemiBoardDetail`, `MemiBoardEditor`, `MemiBoardCommentList`, `MemiBoardCommentForm`, `MemiBoardAttachments`, `MemiBoardSignIn`, `MemiBoardSettings`, `MemiBoardUsers`, `MemiBoardVersionHistory`
405
405
 
406
- **List props:** `boardId`(필수), `pageSize`, `category`(이전 이름), `postLinkBase`, `getPostLink(post)`, `linkBase`(deprecated)
406
+ **List props:** `boardId` (required), `pageSize`, `category` (former name), `postLinkBase`, `getPostLink(post)`, `linkBase` (deprecated)
407
407
 
408
- **Editor props:** `boardId`, `postId`(수정), `fixedCategory`(path 고정 시 선택 UI 숨김)
409
- 본문은 Nuxt UI **`UEditor`**(TipTap, `content-type="html"`). 호스트 `@nuxt/ui` 4.x 에 Editor 포함 필요.
408
+ **Editor props:** `boardId`, `postId` (edit), `fixedCategory` (hides the selector when the path fixes it)
409
+ The body uses Nuxt UI **`UEditor`** (TipTap, `content-type="html"`). The host's `@nuxt/ui` 4.x must include Editor.
410
410
 
411
- **에디터 이미지:** 툴바에서 기기 파일 또는 외부 HTTPS 이미지 URL을 추가할 수 있고, 붙여넣기 / 드롭 파일은 Firebase Storage에 업로드한다. 외부 URL은 Storage에 복사하지 않으며 원본이 삭제되거나 핫링크가 차단되면 표시되지 않는다. 첫 본문 이미지는 업로드·외부 링크 구분 없이 목록과 공유 대표 이미지로 사용한다.
411
+ **Editor images:** From the toolbar you can add a file from the device or an external HTTPS image URL; pasted and dropped files are uploaded to Firebase Storage. External URLs are not copied to Storage and stop displaying if the original is deleted or hotlinking is blocked. The first body image, whether uploaded or external, is used as the list and share preview image.
412
412
 
413
- `memiBoardPosts/{postId}/images/*` + `.../images/thumbnails/*` (최적화 이미지+썸네일).
414
- 글 문서는 아직 없을 수 있고, 붙여넣기는 `image/png` 등이 온다. Storage 예시 규칙은 쓰기를 **로그인 + 5MiB 미만 + `image/*` + 소유권 검증**으로 제한한다. 기존 글은 작성자 또는 전역 admin·owner만 수정할 수 있다. 글 저장 전에는 파일의 `customMetadata.ownerUid`가 로그인 UID와 같아야 하며, 기존 파일을 덮어쓸 때는 기존 메타데이터도 같아야 한다. 이미지 업로드도 Firestore의 글 존재 여부·소유권을 조회한다. 규칙은 **앱 `storageBucket`과 같은 버킷**에 배포해야 한다.
413
+ `memiBoardPosts/{postId}/images/*` + `.../images/thumbnails/*` (optimized image + thumbnail).
414
+ The post document may not exist yet, and pastes arrive as `image/png` and similar. The example Storage rules limit writes to **signed-in + under 5MiB + `image/*` + ownership check**. Existing posts can only be modified by the author or a global admin/owner. Before the post is saved, the file's `customMetadata.ownerUid` must equal the signed-in UID, and when overwriting an existing file, the existing metadata must match too. Image uploads also check the post's existence and ownership in Firestore. The rules must be deployed to **the same bucket as the app's `storageBucket`**.
415
415
 
416
- 본문 이미지는 휴대폰 원본 기준 최대 25MB까지 선택할 수 있다. 5MB를 넘으면 브라우저에서 최대 2560px·JPEG 85%로 최적화하고, 결과가 여전히 5MB를 넘으면 2048px·75%로 한 번 더 줄인 뒤 업로드한다. 5MB 이하 이미지는 원본을 유지한다.
417
- 본문 HTML에는 원본 URL. 수정 중 버려진 파일은 호스트 스케줄러로 정리 (`extractEditorImageUrls` 로 본문 참조 비교).
416
+ Body images can be selected up to 25MB, based on phone originals. Over 5MB, the browser optimizes them to at most 2560px · JPEG 85%; if the result is still over 5MB, it shrinks once more to 2048px · 75% before uploading. Images of 5MB or less are kept as is.
417
+ The body HTML holds the original URL. Clean up files abandoned during editing with a host scheduler (compare body references with `extractEditorImageUrls`).
418
418
 
419
419
  **composables:**
420
420
  `useMemiBoardAuth`, `useMemiBoardPosts`, `useMemiBoardComments`, `useMemiBoardStorage`, `useMemiBoardModeration`, `useMemiBoardSettings`, `useMemiBoardUsers`, `useMemiBoardViews`
421
- **SEO (auto-import, 호스트 파이프라인):**
421
+ **SEO (auto-import, host pipeline):**
422
422
  `useMemiBoardListSeo`, `useMemiBoardPostSeo`
423
- **SEO fetch (필요 시):**
423
+ **SEO fetch (when needed):**
424
424
  `fetchPublicListForSeo`, `fetchPublicPostForSeo`, `resolvePublicSeoDb`
425
425
 
426
- 댓글은 시간순으로 10개씩 cursor 조회하며, 실제 `댓글 더보기` 버튼이 화면에 들어오면 1초 후 자동으로 다음 페이지를 읽는다. 기존 댓글을 모두 읽은 뒤에만 최신 댓글 1개를 실시간 구독한다. 대댓글은 5개씩 조회하고 모두 읽은 뒤 최신 답글 1개를 실시간 구독하며, 화면 깊이는 최대 2단계로 표시한다.
426
+ Comments are fetched chronologically, 10 at a time with a cursor; when the actual `More comments` button enters the viewport, the next page is loaded automatically after 1 second. Only after all existing comments are loaded does it subscribe in real time to the single latest comment. Replies are fetched 5 at a time, and after all are loaded it subscribes in real time to the single latest reply; nesting is displayed up to 2 levels deep.
427
427
 
428
- ### 보드 (구 카테고리)
428
+ ### Boards (formerly categories)
429
429
 
430
- - 문서: `memiBoardSettings/{boardId}` → `{ label, listView, editorType, writeRole, commentWriteRole, visibility, allowedStaffUids, allowedManagerUids, locale, autoTranslate, autoTranslatePosts, i18n, i18nSource, order, updatedAt }`
431
- - 설정이 없으면 빈 목록이며 기본 보드를 자동 생성하지 않음
432
- - `MemiBoardSettings`에서 관리자가 보드별 저장·삭제·순서·리스트뷰·글쓰기 최소 역할 관리
433
- - `boardId`는 문서 ID이자 각 글(`memiBoardPosts`)의 `boardId` 참조값이므로 생성 후 변경하지 않는 것을 권장
430
+ - Document: `memiBoardSettings/{boardId}` → `{ label, listView, editorType, writeRole, commentWriteRole, visibility, allowedStaffUids, allowedManagerUids, locale, autoTranslate, autoTranslatePosts, i18n, i18nSource, order, updatedAt }`
431
+ - With no settings, the list is empty; no default board is created automatically
432
+ - In `MemiBoardSettings`, admins save, delete, reorder boards and manage the list view and minimum write role per board
433
+ - `boardId` is both the document ID and the `boardId` reference on each post (`memiBoardPosts`), so changing it after creation is not recommended
434
434
 
435
- ### 로그인 공급자
435
+ ### Sign-in providers
436
436
 
437
- `auth.providers` 기본 `['google', 'apple']`, `'emailPassword'` 추가 가능.
437
+ `auth.providers` defaults to `['google', 'apple']`; `'emailPassword'` can be added.
438
438
 
439
- - **Google:** 콘솔 Sign-in method 활성화
440
- - **Apple:** Services ID·키 등 추가 설정 → [docs/firebase-setup.md#apple](docs/firebase-setup.md#apple)
439
+ - **Google:** enable it under Sign-in method in the console
440
+ - **Apple:** needs extra setup such as a Services ID and key → [docs/firebase-setup.md#apple](docs/firebase-setup.md#apple)
441
441
 
442
- ### 버전 히스토리
442
+ ### Version history
443
443
 
444
- `<MemiBoardVersionHistory />` 를 페이지에 두면 패키지 기능 변경을 방문자에게 보여줄 수 있다.
444
+ Place `<MemiBoardVersionHistory />` on a page to show package feature changes to visitors.
445
445
 
446
446
  ## Security Model
447
447
 
448
- 서버가 없으므로 **Rules가 전부**다.
448
+ With no server, **Rules are everything**.
449
449
 
450
- 권한 요약:
450
+ Permission summary:
451
451
 
452
- | 동작 | 조건 |
452
+ | Action | Condition |
453
453
  |------|------|
454
- | 글 읽기 | 공개(`listed`·게시됨) 또는 작성자·관리자·담당 스태프. 숨김 보드는 작성자·관리자·담당 스태프만 |
455
- | 댓글 읽기 | 공개(`listed`) 또는 작성자·관리자·담당 스태프·부모 글을 읽을 수 있는 사람 |
456
- | 글 작성 | 로그인 + 보드 `writeRole` 충족 + `moderationStatus == 'approved'` |
457
- | 댓글 작성 | 로그인 + 보드 `commentWriteRole` 충족. **숨김 보드는 담당 스태프만** (`commentWriteRole` 무시) |
458
- | 글 수정·삭제 | 작성자 또는 해당 보드 관리자·담당 스태프 |
459
- | 댓글 삭제 | 댓글 작성자 · 글 작성자 · 해당 보드 관리자·담당 스태프 |
460
- | 연관 좋아요 정리 | 좋아요 작성자 또는 부모 글 작성자·해당 보드 관리자·담당 스태프. 댓글 좋아요는 댓글 작성자도 정리 가능 |
461
- | 파일 업로드·삭제 | 기존 글은 글 작성자 또는 전역 admin·owner. 글 문서가 없으면 파일의 `ownerUid`로 검증 |
462
- | 역할 관리 | owner는 다른 admin 지정 가능. admin은 manager·staff·user 관리. 자기 역할과 owner 변경은 클라이언트에서 금지 |
454
+ | Read post | Public (`listed` · published), or author · admin · assigned staff. Hidden boards: author · admin · assigned staff only |
455
+ | Read comment | Public (`listed`), or author · admin · assigned staff · anyone who can read the parent post |
456
+ | Create post | Signed in + meets the board's `writeRole` + `moderationStatus == 'approved'` |
457
+ | Create comment | Signed in + meets the board's `commentWriteRole`. **Hidden boards: assigned staff only** (`commentWriteRole` ignored) |
458
+ | Edit/delete post | Author, or that board's admin · assigned staff |
459
+ | Delete comment | Comment author · post author · that board's admin · assigned staff |
460
+ | Clean up related likes | Like author, or parent post author · that board's admin · assigned staff. Comment likes can also be cleaned up by the comment author |
461
+ | Upload/delete file | For existing posts, the post author or a global admin/owner. If there is no post document, verified by the file's `ownerUid` |
462
+ | Role management | The owner can assign other admins. Admins manage manager · staff · user. Changing your own role or the owner is forbidden from the client |
463
463
 
464
- 글 수정 시 `boardId`·`authorUid`는 바꿀 수 없고 `listed`는 공개 범위 전환 규칙을 따른다. 공개 범위 변경은 `visibilityTransition`을 기록해 새 글·댓글 생성과 일반 글 수정을 잠근 뒤 완료한다. 실패 시 같은 목표 공개 범위로 다시 저장한다. 글과 본문을 한 batch에서 만들 수 있도록 본문 쓰기는 `getAfter()`로 부모 글을 검증한다. 글 삭제 후에도 신고 기록은 보존한다.
464
+ When editing a post, `boardId` and `authorUid` cannot change, and `listed` follows the visibility transition rules. A visibility change records `visibilityTransition`, which locks new post/comment creation and regular post edits, and then completes. On failure, save again with the same target visibility. So that the post and its body can be created in one batch, body writes verify the parent post with `getAfter()`. Report records are kept even after a post is deleted.
465
465
 
466
- Storage 예제의 파일 읽기·목록 조회는 공개다. 숨김 보드·비공개 초안의 Firestore 읽기 제한이 첨부파일에도 적용되는 것은 아니다. Storage의 admin·owner 권한은 보드 담당 여부와 무관한 전역 권한이다. 담당 staff/manager의 타인 파일 관리에는 별도 서버 API가 필요하다. 상세한 이유와 전환 방법은 [규칙 변경 안내](docs/firebase-setup.md#0451-규칙-변경과-기존-호스트-전환)를 참고한다.
466
+ File reads and listing in the Storage example are public. The Firestore read restrictions for hidden boards and private drafts do not extend to attachments. Storage admin/owner permissions are global, regardless of board assignment. Managing other users' files as assigned staff/manager requires a separate server API. For the detailed reasoning and how to migrate, see the [rule change notes](docs/firebase-setup.md#0451-rule-changes-and-migrating-existing-hosts).
467
467
 
468
- **알려진 한계:** AI 검열은 클라이언트 전용. SDK로 직접 write 하면 우회 가능. 중요 서비스면 Cloud Functions 검증을 호스트가 추가(패키지 범위 밖).
468
+ **Known limitation:** AI moderation is client-only. Writing directly via the SDK bypasses it. For critical services, the host should add Cloud Functions validation (outside the package's scope).
469
469
 
470
- ## Firebase AI Logic (검열)
470
+ ## Firebase AI Logic (moderation)
471
471
 
472
- 흐름: **로컬 금칙어 → Gemini 구조화 JSON** → 걸리면 저장 안 함.
472
+ Flow: **local blocklist → Gemini structured JSON** → if flagged, nothing is saved.
473
473
 
474
- | 옵션 | 기본 | 설명 |
474
+ | Option | Default | Description |
475
475
  |------|------|------|
476
- | `enabled` | `true` | 끄면 로컬만 / 또는 전부 통과에 가깝게 |
477
- | `model` | `gemini-3.5-flash-lite` | Firebase AI Logic 지원 모델명 |
478
- | `onError` | `'allow'` | AI 실패 시 `'block'` 이면 저장 거부 |
479
- | `localBlocklist` | `[]` | 기본 목록에 문자열 추가 |
480
- | `useLimitedUseAppCheckTokens` | `true` | `false`면 일반 App Check 세션 토큰 |
481
- | `blockBanThreshold` | `10` | 콘텐츠 차단(local/ai) 누적 이상이면 글·댓글 제한. `0` = off |
482
- | `blockBanDecayMs` | `86400000` | 경고 1회 차감 간격(기본 24h) |
476
+ | `enabled` | `true` | When off, local only / or close to letting everything through |
477
+ | `model` | `gemini-3.5-flash-lite` | A model name supported by Firebase AI Logic |
478
+ | `onError` | `'allow'` | On AI failure, `'block'` rejects the save |
479
+ | `localBlocklist` | `[]` | Strings added to the default list |
480
+ | `useLimitedUseAppCheckTokens` | `true` | When `false`, uses regular App Check session tokens |
481
+ | `blockBanThreshold` | `10` | Restricts posts and comments once accumulated content blocks (local/ai) reach this. `0` = off |
482
+ | `blockBanDecayMs` | `86400000` | Interval for removing one warning (default 24h) |
483
483
 
484
- 콘텐츠 차단이 누적되면 `memiBoardUsers/{uid}` 에 `moderationBlockCount` / `moderationBlockAt` 이 기록된다.
485
- 10회면 약 하루 동안 작성 불가(24h마다 1 차감). Rules 예시에서 본인은 **+1만** 가능하고 감소·리셋은 admin.
484
+ As content blocks accumulate, `moderationBlockCount` / `moderationBlockAt` are recorded in `memiBoardUsers/{uid}`.
485
+ At 10, the user cannot write for about a day (one is removed every 24h). In the example Rules, users can only **+1** their own count; decreasing or resetting is for admins.
486
486
 
487
- ### 관리자 검열 우회
487
+ ### Admin moderation bypass
488
488
 
489
- 글쓰기 폼(`Editor.vue`) 하단, 제출 버튼 위에 `isAdmin`인 사용자에게만 "비속어 필터 무시" 스위치가 보인다. 켜고 제출하면 로컬 금칙어·AI 검열을 모두 건너뛰고 저장한다(`via: 'admin-override'`, 경고 카운트도 쌓이지 않음). 댓글 폼(`CommentForm.vue`)에는 없다.
489
+ At the bottom of the post form (`Editor.vue`), above the submit button, a "Ignore profanity filter" switch is shown only to `isAdmin` users. Submitting with it on skips both the local blocklist and AI moderation and saves (`via: 'admin-override'`; the warning count does not increase either). The comment form (`CommentForm.vue`) does not have it.
490
490
 
491
- 직접 호출할 때는 `useMemiBoardModeration().checkText(text, { skipFilter: true })`로 우회한다. `skipFilter`는 호스트가 넘긴 값 그대로 신뢰하므로, admin 여부 판정은 호출부(호스트)의 책임이다.
491
+ When calling directly, bypass with `useMemiBoardModeration().checkText(text, { skipFilter: true })`. `skipFilter` trusts whatever value the host passes, so deciding whether the user is an admin is the caller's (host's) responsibility.
492
492
 
493
- ### AI 글쓰기 도우미 사용 제한
493
+ ### AI writing assistant usage limit
494
494
 
495
- 에디터의 AI 글쓰기 도우미는 로그인 사용자별 24시간 구간에 최대 **10회** 사용할 수 있다. 호출 전에는 `aiWritingPending` 예약만 잡고, 모델 호출이 성공하면 `aiWritingCount`로 확정한다. 호출이 실패하면 예약만 되돌린다. 확정된 횟수는 내려가지 않는다. 예약과 확정은 `memiBoardUsers/{uid}`를 Firestore 트랜잭션으로 갱신하므로 여러 탭이 동시에 실행해도 합이 10을 넘지 않는다.
495
+ The editor's AI writing assistant can be used up to **10 times** per signed-in user in each 24-hour window. Before the call, only an `aiWritingPending` reservation is made; when the model call succeeds, it is committed to `aiWritingCount`. If the call fails, only the reservation is rolled back. Committed counts never go down. Reservation and commit update `memiBoardUsers/{uid}` in a Firestore transaction, so the total cannot exceed 10 even when several tabs run at once.
496
496
 
497
- 호스트는 반드시 최신 `docs/firestore.rules.example`의 `memiBoardUsers/{uid}` 규칙을 실제 Firestore Rules에 병합·배포해야 한다. 0.47.1부터는 예약 필드가 추가됐다. 규칙을 배포하지 않으면 새 패키지의 글쓰기 AI 횟수 갱신이 거절되거나, 예전 규칙만 있는 동안 클라이언트가 카운터를 임의로 수정해 제한을 우회할 수 있다.
497
+ The host must merge and deploy the `memiBoardUsers/{uid}` rules from the latest `docs/firestore.rules.example` into its actual Firestore Rules. Reservation fields were added in 0.47.1. Without deploying the rules, the new package's AI writing count updates are rejected, or, while only the old rules are in place, clients can modify the counter arbitrarily and bypass the limit.
498
498
 
499
- 콘솔에서 **AI Logic(Gemini Developer API)** 활성화. 신규 프로젝트는 구형 `gemini-2.5-flash` 가 404 날 수 있음 → 3.x Flash / Flash-Lite 사용.
499
+ Enable **AI Logic (Gemini Developer API)** in the console. New projects may get 404 for the old `gemini-2.5-flash` → use 3.x Flash / Flash-Lite.
500
500
 
501
- ### App Check (AI Enforce 시 사실상 필수)
501
+ ### App Check (effectively required with AI Enforce)
502
502
 
503
- `firebaseml` / AI Logic 이 **Enforce** 되어 있으면 App Check 토큰 없이 `generateContent` 가 실패한다. memi-board는 App Check를 초기화하지 않는다. 기본값 `moderation.useLimitedUseAppCheckTokens: true` 는 **한 번만 쓰는 토큰**을 요구한다. reCAPTCHA v3의 `ReCaptchaV3Provider` 는 이 토큰을 만들지 않는다.
503
+ If `firebaseml` / AI Logic is **Enforced**, `generateContent` fails without an App Check token. memi-board does not initialize App Check. The default `moderation.useLimitedUseAppCheckTokens: true` requires **limited-use tokens**. reCAPTCHA v3's `ReCaptchaV3Provider` does not produce these tokens.
504
504
 
505
- 1. reCAPTCHA Enterprise에서 **점수형** 웹 키를 만든다. 체크박스 키와 v3 공급자는 쓰지 않는다. 도메인에는 실제 사이트 주소만 넣는다. 사이트 키는 공개 값이라 클라이언트 설정에 둔다. 시크릿 키는 클라이언트에 넣지 않는다. Firebase가 토큰을 교환한다.
506
- 2. Firebase 콘솔 → App Check → 웹 앱에 그 사이트 키를 등록한다. 등록하는 웹 앱의 `appId` 가 `vuefire.config.appId` 와 같아야 한다.
507
- 3. 브라우저 API 키의 API 제한에 **Firebase App Check API** 를 포함한다. 기존에 허용한 API는 빼지 않는다.
508
- 4. 호스트 플러그인에서 마운트 후에 Enterprise 토큰을 교환한다. SPA는 첫 페인트 전에 `document.body` 가 비어 있어 `ReCaptchaEnterpriseProvider` 의 숨은 위젯이 `appCheck/recaptcha-error` 로 실패한다.
505
+ 1. Create a **score-based** web key in reCAPTCHA Enterprise. Do not use checkbox keys or the v3 provider. Add only the real site addresses as domains. The site key is a public value, so keep it in client config. Never put the secret key in the client. Firebase exchanges the token.
506
+ 2. Firebase console → App Check → register that site key on the web app. The registered web app's `appId` must equal `vuefire.config.appId`.
507
+ 3. Include **Firebase App Check API** in the browser API key's API restrictions. Do not remove previously allowed APIs.
508
+ 4. Exchange the Enterprise token in a host plugin after mount. In an SPA, `document.body` is empty before the first paint, so the hidden widget of `ReCaptchaEnterpriseProvider` fails with `appCheck/recaptcha-error`.
509
509
 
510
510
  ```ts
511
511
  // app/plugins/app-check.client.ts
@@ -523,7 +523,7 @@ export default defineNuxtPlugin((nuxtApp) => {
523
523
  provider: new CustomProvider({
524
524
  getToken: () => exchangeEnterpriseToken(app, siteKey),
525
525
  }),
526
- // 한 번용 토큰은 세션으로 갱신하지 않는다. AI SDK가 호출마다 getToken 한다.
526
+ // Limited-use tokens are not refreshed as a session. The AI SDK calls getToken per request.
527
527
  isTokenAutoRefreshEnabled: false,
528
528
  })
529
529
  })
@@ -547,7 +547,7 @@ function loadEnterprise(siteKey: string) {
547
547
  const ready = () => {
548
548
  const enterprise = window.grecaptcha?.enterprise
549
549
  if (!enterprise) {
550
- reject(new Error('reCAPTCHA Enterprise가 로드되지 않았습니다.'))
550
+ reject(new Error('reCAPTCHA Enterprise failed to load.'))
551
551
  return
552
552
  }
553
553
  enterprise.ready(() => resolve())
@@ -560,7 +560,7 @@ function loadEnterprise(siteKey: string) {
560
560
  script.src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`
561
561
  script.async = true
562
562
  script.onload = ready
563
- script.onerror = () => reject(new Error('reCAPTCHA Enterprise 스크립트를 불러오지 못했습니다.'))
563
+ script.onerror = () => reject(new Error('Failed to load the reCAPTCHA Enterprise script.'))
564
564
  document.head.appendChild(script)
565
565
  })
566
566
  return enterpriseReady
@@ -569,7 +569,7 @@ function loadEnterprise(siteKey: string) {
569
569
  async function exchangeEnterpriseToken(app: FirebaseApp, siteKey: string) {
570
570
  await loadEnterprise(siteKey)
571
571
  const enterprise = window.grecaptcha?.enterprise
572
- if (!enterprise) throw new Error('reCAPTCHA Enterprise가 로드되지 않았습니다.')
572
+ if (!enterprise) throw new Error('reCAPTCHA Enterprise failed to load.')
573
573
  const recaptchaToken = await enterprise.execute(siteKey, { action: 'fire_app_check' })
574
574
  const { projectId, appId, apiKey } = app.options
575
575
  const response = await fetch(
@@ -590,68 +590,68 @@ async function exchangeEnterpriseToken(app: FirebaseApp, siteKey: string) {
590
590
  }
591
591
  ```
592
592
 
593
- 일반 세션 토큰을 쓰려면 `useLimitedUseAppCheckTokens: false` 로 바꾸고, 교환 본문의 `limited_use` 도 빼야 한다.
593
+ To use regular session tokens, change to `useLimitedUseAppCheckTokens: false` and remove `limited_use` from the exchange body.
594
594
 
595
- 5. App Check 적용은 **Firebase AI Logic만** 켠다. Cloud Storage와 Cloud Firestore는 모니터링으로 둔다. 한 번용 토큰은 AI Logic만 받는다. Storage나 Firestore에 적용을 켜면 이미지 업로드와 글 저장이 `storage/unauthorized` 또는 권한 오류로 거절된다.
596
- 6. 로컬은 `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true` 로 나온 UUID를 콘솔 디버그 토큰에 등록한다. 프로덕션 빌드에서는 디버그 토큰을 켜지 않는다.
595
+ 5. Enable App Check enforcement **only for Firebase AI Logic**. Leave Cloud Storage and Cloud Firestore on monitoring. Only AI Logic accepts limited-use tokens. If you enforce it on Storage or Firestore, image uploads and post saves are rejected with `storage/unauthorized` or permission errors.
596
+ 6. Locally, register the UUID printed by `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true` as a debug token in the console. Do not enable the debug token in production builds.
597
597
 
598
- ## 다른 프로젝트에 붙일 때 체크리스트
598
+ ## Checklist for adding it to another project
599
599
 
600
- - [ ] 동일 스택: Nuxt 4 + `@nuxt/ui` 4 + `nuxt-vuefire` + Firebase
600
+ - [ ] Same stack: Nuxt 4 + `@nuxt/ui` 4 + `nuxt-vuefire` + Firebase
601
601
  - [ ] `pnpm add memi-board @nuxt/ui nuxt-vuefire vuefire firebase`
602
- - [ ] `modules`에 `@nuxt/ui`, `nuxt-vuefire`, `memi-board`
602
+ - [ ] `@nuxt/ui`, `nuxt-vuefire`, `memi-board` in `modules`
603
603
  - [ ] `memiBoard` (`boardId` · `auth` · `moderation` · **`seo`**)
604
- - [ ] 공개 목록·상세 **SSR on**, 쓰기·설정만 `ssr: false` (전 경로 CSR 금지)
605
- - [ ] 목록·상세 페이지에 `await useMemiBoardListSeo` / `useMemiBoardPostSeo`
606
- - [ ] 호스트 SEO API 없음
604
+ - [ ] **SSR on** for public list and detail, `ssr: false` only for writing and settings (no all-CSR routes)
605
+ - [ ] `await useMemiBoardListSeo` / `useMemiBoardPostSeo` on list and detail pages
606
+ - [ ] No host SEO API
607
607
  - [ ] CSS `@source` …/memi-board/dist/runtime/components
608
- - [ ] (선택) `.memi-board-post-link:visited` 읽은 글 표시 CSS
609
- - [ ] Rules 예시 병합 + **deploy**
610
- - [ ] Storage 서비스 계정의 Firestore 조회 IAM 역할 확인 ([호스트 설정](docs/firebase-setup.md#storage-규칙의-firestore-조회-권한-부모호스트-프로젝트-필수-설정))
611
- - [ ] 카테고리·listed 쿼리 사용 시 indexes deploy
612
- - [ ] 페이지·미들웨어 직접 작성 (또는 playground 복사)
613
- - [ ] 최초 로그인 후 Firebase Console에서 `memiBoardUsers/{uid}.role = 'owner'`
614
- - [ ] 설정 페이지에서 첫 카테고리 생성
615
- - [ ] (검열) AI Logic 활성화 + 권장 모델 + App Check
616
- - [ ] `pnpm why vuefire` 로 이중 인스턴스 없는지 확인
608
+ - [ ] (optional) `.memi-board-post-link:visited` read-post styling CSS
609
+ - [ ] Merge the Rules examples + **deploy**
610
+ - [ ] Check the Firestore read IAM role on the Storage service account ([host setup](docs/firebase-setup.md#firestore-read-access-for-storage-rules-required-host-app-setup))
611
+ - [ ] Deploy indexes when using category / listed queries
612
+ - [ ] Write pages and middleware yourself (or copy from the playground)
613
+ - [ ] After the first sign-in, set `memiBoardUsers/{uid}.role = 'owner'` in Firebase Console
614
+ - [ ] Create the first category on the settings page
615
+ - [ ] (moderation) Enable AI Logic + recommended model + App Check
616
+ - [ ] Check for duplicate instances with `pnpm why vuefire`
617
617
 
618
- 같은 Firebase 프로젝트에 호스트 앱 유저 컬렉션이 있어도 무방 — 게시판 역할은 **`memiBoardUsers` 전용**이다.
618
+ It is fine if the same Firebase project has a host app user collection — board roles are **exclusive to `memiBoardUsers`**.
619
619
 
620
- ## 문제 해결
620
+ ## Troubleshooting
621
621
 
622
622
  ### `useCurrentUser() called before the VueFireAuth module...`
623
623
 
624
- `vuefire.auth.enabled` 미설정, 또는 `vuefire`/`firebase` 이중 설치. `pnpm why vuefire` → 하나로. 모듈이 `vite.resolve.dedupe`에 vue/vuefire/firebase를 넣지만, **호스트 peer 설치**가 우선이다.
624
+ `vuefire.auth.enabled` is not set, or `vuefire`/`firebase` is installed twice. `pnpm why vuefire` → reduce to one. The module adds vue/vuefire/firebase to `vite.resolve.dedupe`, but **the host's peer installation** takes precedence.
625
625
 
626
- ### 카테고리 필터 쿼리 에러 / 인덱스 링크
626
+ ### Category filter query error / index link
627
627
 
628
- 콘솔이 안내하는 복합 인덱스 생성, 또는 위 `firestore.indexes.json` 배포.
628
+ Create the composite index the console suggests, or deploy the `firestore.indexes.json` above.
629
629
 
630
- ### AI 검열 실패 / 글 안 올라감 (`onError: 'block'`)
630
+ ### AI moderation fails / post not published (`onError: 'block'`)
631
631
 
632
- App Check 토큰, `appId` 일치, 모델명(3.x), AI Logic 활성화, 네트워크. 브라우저 콘솔의 `moderation AI failed` warn 확인.
632
+ Check the App Check token, matching `appId`, model name (3.x), AI Logic enabled, and network. Look for the `moderation AI failed` warning in the browser console.
633
633
 
634
- ### 에디터 이미지 `storage/unauthorized`
634
+ ### Editor images `storage/unauthorized`
635
635
 
636
- 본문 붙여넣기·업로드가 `User does not have permission to access 'memiBoardPosts/…/images/…'` 로 실패하면:
636
+ If body pastes or uploads fail with `User does not have permission to access 'memiBoardPosts/…/images/…'`:
637
637
 
638
- 1. 로그인 상태인지 확인
639
- 2. `vuefire.config.storageBucket`과 `firebase.json` `storage.bucket`이 **같은 이름**인지 확인. 구 프로젝트는 `*.appspot.com`(앱)과 `*.firebasestorage.app`(CLI 기본)이 갈라질 수 있다 — 앱이 쓰는 쪽에 규칙을 배포한다.
640
- 3. 호스트 `storage.rules`에 `docs/storage.rules.example`의 `memiBoardPosts/{postId}/images` 규칙이 있는지 확인. 쓰기는 `image/*` 이어야 한다(붙여넣기 PNG).
641
- 4. 저장 전 파일에 `customMetadata.ownerUid`가 있는지 확인한다. 0.45.1 이상 패키지는 이를 자동으로 넣는다. 기존 글은 작성자·전역 admin·owner인지 확인한다.
642
- 5. 이미지 규칙도 Firestore를 조회하므로 [교차 서비스 IAM 역할](docs/firebase-setup.md#storage-규칙의-firestore-조회-권한-부모호스트-프로젝트-필수-설정)을 확인하고, `firebase deploy --only storage` 후 다시 올려 본다.
638
+ 1. Check that you are signed in
639
+ 2. Check that `vuefire.config.storageBucket` and `firebase.json` `storage.bucket` have **the same name**. Older projects may split between `*.appspot.com` (app) and `*.firebasestorage.app` (CLI default) — deploy the rules to the one the app uses.
640
+ 3. Check that the host `storage.rules` has the `memiBoardPosts/{postId}/images` rules from `docs/storage.rules.example`. Writes must be `image/*` (pasted PNG).
641
+ 4. Check that the file has `customMetadata.ownerUid` before the post is saved. Packages 0.45.1 and later add it automatically. For existing posts, check that the user is the author or a global admin/owner.
642
+ 5. Image rules also query Firestore, so check the [cross-service IAM role](docs/firebase-setup.md#firestore-read-access-for-storage-rules-required-host-app-setup), run `firebase deploy --only storage`, and try uploading again.
643
643
 
644
- ### ZIP 등 첨부파일 `storage/unauthorized`
644
+ ### `storage/unauthorized` on attachments such as ZIP
645
645
 
646
- `memiBoardPosts/{postId}/attachments/...`에서 실패하면 로그인 상태, 앱이 쓰는 버킷에 배포된 첨부 규칙, **파일당 20MiB 미만** 조건을 먼저 확인한다. 예시 규칙은 ZIP의 MIME 형식이나 `.exe.zip` 이름을 차단하지 않는다.
646
+ If `memiBoardPosts/{postId}/attachments/...` fails, first check the sign-in state, that the attachment rules are deployed to the bucket the app uses, and the **under 20MiB per file** condition. The example rules do not block ZIP MIME types or `.exe.zip` names.
647
647
 
648
- 조건을 충족해도 실패하면 **호스트 Firebase 프로젝트의 Storage 서비스 계정에 Firestore 조회 역할이 있는지** 확인한다. 이미지·첨부 규칙은 `canWritePostFile`에서 `postExists`·`isPostOwner`·`isBoardStaff`를 통해 Firestore를 조회한다. 조회 권한이 없으면 새 글의 문서 존재 여부 확인부터 실패할 수 있다. [부모/호스트 설정 절차](docs/firebase-setup.md#storage-규칙의-firestore-조회-권한-부모호스트-프로젝트-필수-설정)에 따라 `roles/firebaserules.firestoreServiceAgent`를 설정한다.
648
+ If it still fails with those conditions met, check that **the Storage service account of the host Firebase project has the Firestore read role**. Image and attachment rules query Firestore in `canWritePostFile` via `postExists` · `isPostOwner` · `isBoardStaff`. Without read permission, even the existence check for a new post's document can fail. Set `roles/firebaserules.firestoreServiceAgent` following the [host setup procedure](docs/firebase-setup.md#firestore-read-access-for-storage-rules-required-host-app-setup).
649
649
 
650
- ### 글 삭제 `storage/unauthorized`
650
+ ### Post deletion `storage/unauthorized`
651
651
 
652
- 작성자 본인 글 삭제가 `User does not have permission to access 'memiBoardPosts/{postId}'` 로 실패하면, 파일 삭제가 아니라 **폴더 목록(`listAll`)** 이 막힌 것이다.
652
+ If an author deleting their own post fails with `User does not have permission to access 'memiBoardPosts/{postId}'`, it is not the file deletion but **folder listing (`listAll`)** that is blocked.
653
653
 
654
- 요청은 `prefix=memiBoardPosts/{postId}/`(끝 슬래시)다. `match /memiBoardPosts/{postId} { allow list }` 만 있으면 이 접두사와 안 맞는다. 호스트 `storage.rules`에 예시와 같은 블록이 있어야 한다.
654
+ The request is `prefix=memiBoardPosts/{postId}/` (trailing slash). `match /memiBoardPosts/{postId} { allow list }` alone does not match this prefix. The host `storage.rules` needs a block like the example:
655
655
 
656
656
  ```
657
657
  match /memiBoardPosts/{postId}/{allPaths=**} {
@@ -660,69 +660,69 @@ match /memiBoardPosts/{postId}/{allPaths=**} {
660
660
  }
661
661
  ```
662
662
 
663
- `canDeletePostFile`과 관련 함수도 최신 `docs/storage.rules.example`에서 함께 병합한다. 글 문서가 없으면 로그인만으로 삭제를 허용하던 예전 조건(`!postExists`)은 사용하지 않는다.
663
+ Also merge `canDeletePostFile` and related functions from the latest `docs/storage.rules.example`. Do not use the old condition (`!postExists`) that allowed deletion with just a sign-in when the post document is missing.
664
664
 
665
- `write` 는 이 블록에 넣지 않는다(용량·`image/*` 제한은 `images`/`attachments`/`comment-images` match 가 담당). `firebase deploy --only storage` 후 다시 삭제한다. 패키지는 `images/`·`attachments/`·`comment-images/` 만 목록으로 비운다. 댓글 이미지는 글 문서가 남아 있는 동안 지운다. 글 문서가 먼저 사라지면 댓글 작성자가 아닌 파일은 글 작성자가 지우지 못한다.
665
+ Do not put `write` in this block (size and `image/*` limits are handled by the `images`/`attachments`/`comment-images` matches). Run `firebase deploy --only storage` and delete again. The package empties only `images/` · `attachments/` · `comment-images/` by listing. Comment images are deleted while the post document still exists. If the post document disappears first, the post author can no longer delete comment files uploaded by other commenters.
666
666
 
667
- 목록 조회는 성공하지만 **개별 파일 삭제**가 거부되면, 위 [첨부파일 오류](#zip-등-첨부파일-storageunauthorized)와 같은 Firestore 조회 IAM 역할도 확인한다. 삭제 규칙 역시 글 소유권·사용자 역할을 Firestore에서 읽는다.
667
+ If listing succeeds but **individual file deletion** is denied, also check the same Firestore read IAM role as in the [attachment error](#storageunauthorized-on-attachments-such-as-zip) above. Deletion rules also read post ownership and user roles from Firestore.
668
668
 
669
- ### monorepo `link:` / `file:` 로 개발할 때
669
+ ### Developing with monorepo `link:` / `file:`
670
670
 
671
- 링크하기 전에 패키지 의존성과 `dist`를 준비한다.
671
+ Prepare the package dependencies and `dist` before linking.
672
672
 
673
673
  ```bash
674
- # memi-board 저장소
674
+ # memi-board repository
675
675
  pnpm install
676
676
  pnpm build
677
677
 
678
- # 호스트 저장소
678
+ # host repository
679
679
  pnpm add link:../memi-board
680
680
  ```
681
681
 
682
- | 수정 대상 | 반영 |
682
+ | What you change | When it takes effect |
683
683
  |-----------|------|
684
- | `src/components/*` | 모듈이 링크 소스 직독 → **즉시** (dev HMR) |
685
- | `useMemiBoard*Seo` (`src/composables/useMemiBoardSeo.ts`) | 호스트 Nuxt가 소스 컴파일 → **즉시** (dev 재시작이 필요할 수 있음) |
686
- | `src/module.ts` · 그 외 core composable (`dist`) | **`pnpm build`** 후 호스트 dev 재시작 |
684
+ | `src/components/*` | The module reads the linked source directly → **immediately** (dev HMR) |
685
+ | `useMemiBoard*Seo` (`src/composables/useMemiBoardSeo.ts`) | The host Nuxt compiles the source → **immediately** (may need a dev restart) |
686
+ | `src/module.ts` · other core composables (`dist`) | After **`pnpm build`**, restart the host dev server |
687
687
 
688
- SEO composable은 `#imports`로 호스트 Nuxt 인스턴스를 쓴다. `dist/index.js`에 `nuxt/app`을 넣지 않으므로 link 시 Nuxt 이중 로딩(E1001)이 나지 않는다.
688
+ The SEO composables use the host Nuxt instance via `#imports`. `nuxt/app` is not bundled into `dist/index.js`, so linking does not cause duplicate Nuxt loading (E1001).
689
689
 
690
- 모듈이 Vue·Vue Router·VueFire·Firebase·TipTap dedupe를 자동 적용한다. 배포 전에는 의존성을 **npm 버전**으로 되돌린다.
690
+ The module automatically dedupes Vue · Vue Router · VueFire · Firebase · TipTap. Before deploying, switch dependencies back to **npm versions**.
691
691
 
692
- 링크 상태에서 새 Tailwind 클래스를 바로 시험하려면 호스트 CSS에 소스 경로를 하나 더 둔다.
692
+ To try new Tailwind classes right away while linked, add one more source path to the host CSS.
693
693
 
694
694
  ```css
695
695
  @source "../../../node_modules/memi-board/src/components";
696
696
  @source "../../../node_modules/memi-board/dist/runtime/components";
697
697
  ```
698
698
 
699
- ### OG 메타가 비어 있거나 카톡 미리보기가 안 될 때
699
+ ### OG meta is empty or link previews do not work
700
700
 
701
- 1. 공개 경로가 `ssr: false`로 꺼져 있지 않은지 (`routeRules`)
702
- 2. 페이지에 `await useMemiBoardListSeo` / `useMemiBoardPostSeo` 호출 여부
701
+ 1. Check that public paths are not turned off with `ssr: false` (`routeRules`)
702
+ 2. Check that the page calls `await useMemiBoardListSeo` / `useMemiBoardPostSeo`
703
703
  3. `memiBoard.seo.siteUrl` · `siteName` · `defaultOgImage`
704
- 4. `curl` / view-source 에 `og:title` 이 있는지 (Elements만 보지 말 것)
705
- 5. 글이 `listed` / 숨김 게시판이 아닌지 (숨김은 noindex)
706
- 6. 배포 URL로 재스크랩 (카톡·FB 캐시)
704
+ 4. Check that `og:title` is in `curl` / view-source (don't look only at Elements)
705
+ 5. Check that the post is `listed` / not on a hidden board (hidden boards are noindex)
706
+ 6. Re-scrape with the deployed URL (KakaoTalk / Facebook caches)
707
707
 
708
- ## 로컬 개발 (이 리포)
708
+ ## Local development (this repo)
709
709
 
710
710
  ```bash
711
711
  pnpm install
712
712
  pnpm build
713
713
  pnpm dev:prepare
714
714
  pnpm dev # playground
715
- pnpm build:watch # src 변경 시 dist 갱신
715
+ pnpm build:watch # rebuild dist when src changes
716
716
  ```
717
717
 
718
- 에뮬레이터:
718
+ Emulators:
719
719
 
720
720
  ```bash
721
721
  firebase emulators:start --project demo-memi-board
722
722
  NUXT_PUBLIC_USE_EMULATORS=1 pnpm dev
723
723
  ```
724
724
 
725
- 정리 기능의 권한 회귀 테스트(Java 21+와 Firebase CLI 필요):
725
+ Permission regression tests for the cleanup feature (requires Java 21+ and the Firebase CLI):
726
726
 
727
727
  ```bash
728
728
  pnpm build
@@ -730,28 +730,28 @@ pnpm test:cleanup
730
730
  firebase emulators:exec --project demo-memi-cleanup-security --only auth,firestore,storage 'node --test tests/cleanup-rules.test.mjs'
731
731
  ```
732
732
 
733
- 공지(pinned) 권한 회귀 테스트:
733
+ Notice (pinned) permission regression tests:
734
734
 
735
735
  ```bash
736
736
  firebase emulators:exec --project demo-memi-notice-rules --only auth,firestore 'node --test tests/notice-rules.test.mjs'
737
737
  ```
738
738
 
739
- ## npm 배포
739
+ ## Publishing to npm
740
740
 
741
- npm Trusted Publishing(OIDC)을 사용한다. 장기 `NPM_TOKEN`이나 로컬 OTP는 저장하지 않는다.
741
+ npm Trusted Publishing (OIDC) is used. No long-lived `NPM_TOKEN` or local OTP is stored.
742
742
 
743
- 1. `package.json`, `CHANGELOG.md`, 인앱 `versionHistory`의 버전을 갱신하고 `main`에 푸시한다.
744
- 2. `main` 푸시로 `.github/workflows/publish.yml`이 실행된다.
745
- 3. 이미 발행된 버전인지 확인한 뒤 빌드·타입 검사·회귀 테스트·Firebase 보안 규칙 테스트·패키지 dry-run을 거쳐 npm에 배포한다.
743
+ 1. Update the version in `package.json`, `CHANGELOG.md`, and the in-app `versionHistory`, then push to `main`.
744
+ 2. Pushing to `main` runs `.github/workflows/publish.yml`.
745
+ 3. After checking whether the version is already published, it builds, type-checks, runs regression tests, Firebase security rules tests, and a package dry-run, then publishes to npm.
746
746
 
747
- npm 패키지 설정의 Trusted Publisher는 GitHub 저장소 `fkkmemi/memi-board`, workflow `publish.yml`로 등록한다. GitHub Environment `npm-publish`에 승인자를 지정하면 워크플로 실행 뒤 실제 npm 배포 전에 한 번 더 승인할 수 있다.
747
+ In the npm package settings, register the Trusted Publisher as GitHub repository `fkkmemi/memi-board`, workflow `publish.yml`. If you assign approvers to the GitHub Environment `npm-publish`, you can approve once more after the workflow runs and before the actual npm publish.
748
748
 
749
- ## 버전 · 라이선스
749
+ ## Version · License
750
750
 
751
751
  - UI: `<MemiBoardVersionHistory />`
752
- - 상세: [CHANGELOG.md](CHANGELOG.md)
752
+ - Details: [CHANGELOG.md](CHANGELOG.md)
753
753
  - **MIT**
754
754
 
755
- ### 0.46 역할 및 서비스별 명칭
755
+ ### 0.46 roles and per-service names
756
756
 
757
- 5개 역할과 게시판별 매니저 범위, `roleLabels`, 호스트 마이그레이션 절차는 [역할 마이그레이션](docs/roles-migration.md)을 참고한다. 설정 접근에는 `canConfigureBoard()`를 사용한다.
757
+ For the 5 roles, per-board manager scope, `roleLabels`, and the host migration procedure, see [role migration](docs/roles-migration.md). Use `canConfigureBoard()` for settings access.