@roottale/cms-mcp 0.25.0 → 0.33.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5dc4916: ADR-0061 P4 — 사이트 지식 projection. 고객 AI 에이전트가 사이트의 브랜드 보이스(어조·톤·화자)와 용어 규칙(금지어·교정어)을 읽을 수 있는 `getSiteKnowledge` MCP 도구 + 공개 API `GET /v1/cms/public/site-knowledge` 추가. 운영 메모·내부 SEO 임계값·내부 출처는 노출하지 않는다(platform 고정 projection 정책). docs(theme-and-settings·api-reference) 갱신.
8
+
9
+ ### Patch Changes
10
+
11
+ - 2e6cbd0: ADR-0060 Amendment 1 (Option A) — 글의 섹션을 명시 필드로
12
+
13
+ `resolvePostCollection` 이 글의 명시 `collectionKey` 를 카테고리 slug 파생보다
14
+ 우선 사용한다. `RoutablePost`·`CmsPostContent` 에 `collectionKey` 추가, 공개 API
15
+ post 페이로드에 `collection_key` 포함. 미설정/구 서버면 기존 카테고리 파생으로
16
+ fallback(완전 하위호환). sitemap/feed/가드/아카이브가 명시 섹션을 따른다.
17
+
18
+ ## 0.32.0
19
+
20
+ ### Minor Changes
21
+
22
+ - dfe7814: docs: 콘텐츠 유형(Collections) 전용 문서 추가 (ADR-0060)
23
+
24
+ 신규 `docs/collections.md` — 공지·블로그 다중 스트림의 URL 구조, 어드민 "콘텐츠 유형"
25
+ 설정, 연동 코드(코드 상수 vs `fetchCollections` 동적 로드), 가드·catch-all·slug 301·동적
26
+ OG·동적 basePath·트러블슈팅을 한 곳에 정리. `docs/seo.md` 의 collections 섹션은 이 문서로
27
+ 포인터 처리(드리프트 방지). 공개 엔드포인트 `GET /v1/cms/public/collections` 명시.
28
+
29
+ MCP `listDocs()` 는 docs/ 자동 발견이라 새 문서가 바로 노출되고, roottale-web 문서 사이트는
30
+ `PREFERRED_ORDER` 에 `collections` 추가로 노출된다.
31
+
3
32
  ## 0.25.0
4
33
 
5
34
  ### Minor Changes
package/dist/index.js CHANGED
@@ -105,6 +105,7 @@ var ListPostsInput = z.object({
105
105
  cursor: z.string().optional()
106
106
  }).strict();
107
107
  var GetPostInput = z.object({ slugOrId: z.string().min(1) }).strict();
108
+ var GetSiteKnowledgeInput = z.object({ siteId: z.string().optional() }).strict();
108
109
  var TOOL_DEFINITIONS = [
109
110
  {
110
111
  name: "listRootTaleDocs",
@@ -164,6 +165,20 @@ var TOOL_DEFINITIONS = [
164
165
  required: ["slugOrId"],
165
166
  additionalProperties: false
166
167
  }
168
+ },
169
+ {
170
+ name: "getSiteKnowledge",
171
+ description: "\uC774 RootTale \uC0AC\uC774\uD2B8\uC758 \uBE0C\uB79C\uB4DC \uBCF4\uC774\uC2A4(\uC5B4\uC870\xB7\uD1A4\xB7\uD654\uC790)\uC640 \uC6A9\uC5B4 \uADDC\uCE59(\uAE08\uC9C0\uC5B4\xB7\uAD50\uC815\uC5B4)\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4 (GET /v1/cms/public/site-knowledge). \uC774 \uC0AC\uC774\uD2B8\uC5D0 \uB9DE\uB294 \uAE00\uC744 \uC4F0\uAE30 \uC804\uC5D0 \uD638\uCD9C\uD574 \uD1A4\xB7\uD45C\uD604\uC744 \uB9DE\uCD94\uC138\uC694. \uAE08\uC9C0\uC5B4\uB294 \uC4F0\uC9C0 \uB9D0\uACE0, \uAD50\uC815\uC5B4\uB294 \uAD8C\uC7A5 \uD45C\uD604\uC73C\uB85C \uBC14\uAFD4 \uC4F0\uC138\uC694. \uD658\uACBD\uBCC0\uC218 ROOTTALE_API_KEY(rtlk_cust_*)\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ siteId: {
176
+ type: "string",
177
+ description: "\uC0AC\uC774\uD2B8 ID (\uC120\uD0DD, \uBBF8\uC124\uC815 \uC2DC \uAE30\uBCF8 \uC0AC\uC774\uD2B8)"
178
+ }
179
+ },
180
+ additionalProperties: false
181
+ }
167
182
  }
168
183
  ];
169
184
  function requireApiKey() {
@@ -241,6 +256,14 @@ function registerTools(server2) {
241
256
  )
242
257
  );
243
258
  }
259
+ case "getSiteKnowledge": {
260
+ const input = GetSiteKnowledgeInput.parse(args);
261
+ const params = {};
262
+ if (input.siteId) params.site_id = input.siteId;
263
+ return jsonResponse(
264
+ await fetchPublicApi("/v1/cms/public/site-knowledge", params)
265
+ );
266
+ }
244
267
  default:
245
268
  throw new McpError(
246
269
  ErrorCode.InvalidParams,
@@ -256,7 +279,7 @@ function registerTools(server2) {
256
279
  }
257
280
 
258
281
  // src/server.ts
259
- var VERSION = true ? "0.25.0" : "dev";
282
+ var VERSION = true ? "0.33.0" : "dev";
260
283
  var SERVER_INSTRUCTIONS = `
261
284
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
262
285
  \uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/server.ts","../src/tools.ts","../src/docs.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\n\nimport { createServer } from \"./server.js\";\n\nconst server = createServer();\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n","import { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\n\nimport { registerTools } from \"./tools.js\";\n\n// tsup define 으로 빌드타임 주입 — 비번들 실행(vitest 등)에서는 \"dev\".\ndeclare const __CMS_MCP_VERSION__: string | undefined;\nconst VERSION =\n typeof __CMS_MCP_VERSION__ === \"string\" ? __CMS_MCP_VERSION__ : \"dev\";\n\nexport const SERVER_INSTRUCTIONS = `\nroottale-cms-mcp는 RootTale CMS를 외부 사이트(주로 Next.js)에 연동하기 위한\n통합 문서·예시 코드·공개 API 조회 tool을 제공합니다.\n\n지켜야 할 규칙:\n- RootTale 연동 관련 작업은 가장 먼저 listRootTaleDocs를 호출해 문서 목록을 파악하세요.\n- 연동 코드를 작성하기 전 반드시 readRootTaleNextjsExampleCode를 호출해 예시 코드를\n 참고한 후 그 패턴을 따라 작성하세요.\n- API 키(rtlk_cust_*)는 서버 전용입니다. NEXT_PUBLIC_* 등 브라우저로 노출되는\n 환경변수에 절대 넣지 마세요.\n- 이미 학습된 내용이더라도 본 서버의 문서로 더블체크 후 작업하세요.\n`.trim();\n\nexport function createServer(): Server {\n const server = new Server(\n { name: \"roottale-cms\", version: VERSION },\n { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },\n );\n registerTools(server);\n return server;\n}\n","import type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport {\n CallToolRequestSchema,\n ErrorCode,\n ListToolsRequestSchema,\n McpError,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\n\nimport { listDocs, readDoc, readNextjsExampleCode, searchDocs } from \"./docs.js\";\n\nconst DEFAULT_API_BASE = \"https://api.roottale.com\";\n\nconst ReadDocInput = z.object({ path: z.string().min(1) }).strict();\nconst SearchDocsInput = z.object({ query: z.string().min(1) }).strict();\nconst ListPostsInput = z\n .object({\n limit: z.number().int().min(1).max(100).optional(),\n type: z.enum([\"post\", \"page\"]).optional(),\n cursor: z.string().optional(),\n })\n .strict();\nconst GetPostInput = z.object({ slugOrId: z.string().min(1) }).strict();\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"listRootTaleDocs\",\n description:\n \"RootTale CMS 통합 문서 전체 목록(경로·제목·설명)을 반환합니다. \" +\n \"RootTale 연동 작업을 시작하기 전 가장 먼저 호출하세요.\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"readRootTaleDoc\",\n description:\n \"RootTale CMS 통합 문서 1개를 경로로 읽습니다. \" +\n \"경로는 listRootTaleDocs 가 반환한 path 값을 사용하세요.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"문서 경로 (예: blog.md)\" },\n },\n required: [\"path\"],\n additionalProperties: false,\n },\n },\n {\n name: \"searchRootTaleDocs\",\n description:\n \"RootTale CMS 통합 문서 전체에서 정규식으로 검색합니다 (대소문자 무시). \" +\n \"매치된 파일 경로·라인 번호·라인 내용을 반환합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: { type: \"string\", description: \"정규식 패턴 (예: revalidate|webhook)\" },\n },\n required: [\"query\"],\n additionalProperties: false,\n },\n },\n {\n name: \"readRootTaleNextjsExampleCode\",\n description:\n \"RootTale CMS를 외부 Next.js(App Router) 사이트에 연동하는 완전한 예시 코드 세트를 반환합니다 \" +\n \"(블로그 목록/상세, 웹훅 revalidate 라우트, RSS/사이트맵, 상담문의 서버 액션 등). \" +\n \"연동 코드를 작성하기 전 반드시 호출해 패턴을 따라 작성하세요.\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"listPublishedPosts\",\n description:\n \"연동 검증용 — RootTale 공개 CMS API(GET /v1/cms/public/posts)로 발행된 글 목록을 조회합니다. \" +\n \"환경변수 ROOTTALE_API_KEY(rtlk_cust_*)가 필요합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n limit: { type: \"number\", description: \"1-100, 기본 20\" },\n type: { type: \"string\", enum: [\"post\", \"page\"] },\n cursor: { type: \"string\", description: \"이전 응답의 next_cursor\" },\n },\n additionalProperties: false,\n },\n },\n {\n name: \"getPublishedPost\",\n description:\n \"연동 검증용 — RootTale 공개 CMS API(GET /v1/cms/public/posts/:identifier)로 발행된 글 1개를 \" +\n \"slug 또는 UUID로 조회합니다. 환경변수 ROOTTALE_API_KEY(rtlk_cust_*)가 필요합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n slugOrId: { type: \"string\", description: \"글 slug 또는 UUID\" },\n },\n required: [\"slugOrId\"],\n additionalProperties: false,\n },\n },\n] as const;\n\nfunction requireApiKey(): { apiKey: string; baseUrl: string } {\n const apiKey = process.env.ROOTTALE_API_KEY;\n if (!apiKey) {\n throw new McpError(\n ErrorCode.InvalidRequest,\n \"ROOTTALE_API_KEY 환경변수가 없습니다. MCP 서버 설정의 env에 \" +\n \"rtlk_cust_* 키를 추가하세요 (발급: 어드민 설정 > 사이트 연결 키).\",\n );\n }\n const baseUrl = (process.env.ROOTTALE_API_BASE ?? DEFAULT_API_BASE).replace(/\\/+$/, \"\");\n return { apiKey, baseUrl };\n}\n\nasync function fetchPublicApi(pathname: string, params?: Record<string, string>): Promise<unknown> {\n const { apiKey, baseUrl } = requireApiKey();\n const url = new URL(`${baseUrl}${pathname}`);\n for (const [key, value] of Object.entries(params ?? {})) {\n url.searchParams.set(key, value);\n }\n const response = await fetch(url, {\n headers: { authorization: `Bearer ${apiKey}` },\n });\n const body = await response.text();\n if (!response.ok) {\n throw new Error(`RootTale API ${response.status}: ${body.slice(0, 500)}`);\n }\n return JSON.parse(body);\n}\n\nfunction textResponse(text: string) {\n return { content: [{ type: \"text\" as const, text }] };\n}\n\nfunction jsonResponse(value: unknown) {\n return textResponse(JSON.stringify(value, null, 2));\n}\n\nexport function registerTools(server: Server): void {\n server.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: TOOL_DEFINITIONS.map((t) => ({ ...t })),\n }));\n\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const args = (request.params.arguments ?? {}) as Record<string, unknown>;\n try {\n switch (request.params.name) {\n case \"listRootTaleDocs\":\n return jsonResponse(await listDocs());\n case \"readRootTaleDoc\": {\n const input = ReadDocInput.parse(args);\n const content = await readDoc(input.path);\n if (content === null) {\n throw new McpError(\n ErrorCode.InvalidParams,\n `문서를 찾을 수 없습니다: ${input.path} — listRootTaleDocs로 경로를 확인하세요.`,\n );\n }\n return textResponse(content);\n }\n case \"searchRootTaleDocs\": {\n const input = SearchDocsInput.parse(args);\n return jsonResponse(await searchDocs(input.query));\n }\n case \"readRootTaleNextjsExampleCode\":\n return textResponse(await readNextjsExampleCode());\n case \"listPublishedPosts\": {\n const input = ListPostsInput.parse(args);\n const params: Record<string, string> = {};\n if (input.limit) params.limit = String(input.limit);\n if (input.type) params.type = input.type;\n if (input.cursor) params.cursor = input.cursor;\n return jsonResponse(await fetchPublicApi(\"/v1/cms/public/posts\", params));\n }\n case \"getPublishedPost\": {\n const input = GetPostInput.parse(args);\n return jsonResponse(\n await fetchPublicApi(\n `/v1/cms/public/posts/${encodeURIComponent(input.slugOrId)}`,\n ),\n );\n }\n default:\n throw new McpError(\n ErrorCode.InvalidParams,\n `Unknown RootTale tool: ${request.params.name}`,\n );\n }\n } catch (error) {\n if (error instanceof McpError) throw error;\n const message = error instanceof Error ? error.message : String(error);\n return { isError: true, content: [{ type: \"text\" as const, text: message }] };\n }\n });\n}\n","/**\n * 패키지에 번들된 통합 문서(`docs/`)·예시 코드(`examples/`) 로더.\n *\n * dist/index.js 와 src/docs.ts 모두 패키지 루트에서 한 단계 아래이므로\n * `..` 상대 경로 해석이 빌드 전후 동일하다. npm publish 시 `files` 에\n * docs/, examples/ 가 포함되어 npx 환경에서도 같은 구조로 설치된다.\n */\nimport { readdir, readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PKG_ROOT = fileURLToPath(new URL(\"..\", import.meta.url));\nconst DOCS_ROOT = path.join(PKG_ROOT, \"docs\");\nconst EXAMPLES_ROOT = path.join(PKG_ROOT, \"examples\");\n\nexport interface DocEntry {\n /** docs/ 기준 상대 경로 (예: \"blog.md\"). */\n path: string;\n title: string;\n description: string;\n}\n\ninterface Frontmatter {\n title: string;\n description: string;\n}\n\n/** `---\\ntitle: ...\\ndescription: ...\\n---` 형태의 단순 frontmatter 파서. */\nexport function parseFrontmatter(markdown: string): Frontmatter {\n const result: Frontmatter = { title: \"\", description: \"\" };\n const match = markdown.match(/^---\\n([\\s\\S]*?)\\n---/);\n if (!match) return result;\n for (const line of match[1].split(\"\\n\")) {\n const idx = line.indexOf(\":\");\n if (idx === -1) continue;\n const key = line.slice(0, idx).trim();\n const value = line.slice(idx + 1).trim();\n if (key === \"title\") result.title = value;\n if (key === \"description\") result.description = value;\n }\n return result;\n}\n\nasync function walkFiles(root: string, dir = root): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n const files: string[] = [];\n for (const entry of entries) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n files.push(...(await walkFiles(root, full)));\n } else {\n files.push(path.relative(root, full));\n }\n }\n return files.sort();\n}\n\nexport async function listDocs(): Promise<DocEntry[]> {\n const files = (await walkFiles(DOCS_ROOT)).filter((f) => f.endsWith(\".md\"));\n return Promise.all(\n files.map(async (rel) => {\n const content = await readFile(path.join(DOCS_ROOT, rel), \"utf8\");\n const fm = parseFrontmatter(content);\n return { path: rel, title: fm.title || rel, description: fm.description };\n }),\n );\n}\n\n/** 경로 탈출(../) 방지 후 단일 문서 읽기. 없으면 null. */\nexport async function readDoc(relPath: string): Promise<string | null> {\n const resolved = path.resolve(DOCS_ROOT, relPath);\n if (!resolved.startsWith(DOCS_ROOT + path.sep)) return null;\n try {\n return await readFile(resolved, \"utf8\");\n } catch {\n return null;\n }\n}\n\nexport interface SearchMatch {\n path: string;\n line: number;\n text: string;\n}\n\n/** 모든 문서에 대해 정규식 검색 (대소문자 무시). 매치 라인을 반환. */\nexport async function searchDocs(query: string): Promise<SearchMatch[]> {\n const regex = new RegExp(query, \"i\");\n const matches: SearchMatch[] = [];\n for (const doc of await listDocs()) {\n const content = await readDoc(doc.path);\n if (!content) continue;\n content.split(\"\\n\").forEach((text, idx) => {\n if (regex.test(text)) {\n matches.push({ path: doc.path, line: idx + 1, text: text.trim() });\n }\n });\n }\n return matches;\n}\n\n/**\n * Next.js 통합 예시 코드 전체를 `--- FILE: <path> ---` 헤더로 이어붙여 반환.\n * 파일 수가 적어(통합 1세트) 한 번에 주는 쪽이 에이전트 왕복을 줄인다.\n */\nexport async function readNextjsExampleCode(): Promise<string> {\n const root = path.join(EXAMPLES_ROOT, \"nextjs\");\n const files = await walkFiles(root);\n const sections = await Promise.all(\n files.map(async (rel) => {\n const content = await readFile(path.join(root, rel), \"utf8\");\n return `--- FILE: ${rel} ---\\n${content}`;\n }),\n );\n return sections.join(\"\\n\\n\");\n}\n"],"mappings":"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","names":["server","server"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/server.ts","../src/tools.ts","../src/docs.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\n\nimport { createServer } from \"./server.js\";\n\nconst server = createServer();\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n","import { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\n\nimport { registerTools } from \"./tools.js\";\n\n// tsup define 으로 빌드타임 주입 — 비번들 실행(vitest 등)에서는 \"dev\".\ndeclare const __CMS_MCP_VERSION__: string | undefined;\nconst VERSION =\n typeof __CMS_MCP_VERSION__ === \"string\" ? __CMS_MCP_VERSION__ : \"dev\";\n\nexport const SERVER_INSTRUCTIONS = `\nroottale-cms-mcp는 RootTale CMS를 외부 사이트(주로 Next.js)에 연동하기 위한\n통합 문서·예시 코드·공개 API 조회 tool을 제공합니다.\n\n지켜야 할 규칙:\n- RootTale 연동 관련 작업은 가장 먼저 listRootTaleDocs를 호출해 문서 목록을 파악하세요.\n- 연동 코드를 작성하기 전 반드시 readRootTaleNextjsExampleCode를 호출해 예시 코드를\n 참고한 후 그 패턴을 따라 작성하세요.\n- API 키(rtlk_cust_*)는 서버 전용입니다. NEXT_PUBLIC_* 등 브라우저로 노출되는\n 환경변수에 절대 넣지 마세요.\n- 이미 학습된 내용이더라도 본 서버의 문서로 더블체크 후 작업하세요.\n`.trim();\n\nexport function createServer(): Server {\n const server = new Server(\n { name: \"roottale-cms\", version: VERSION },\n { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },\n );\n registerTools(server);\n return server;\n}\n","import type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport {\n CallToolRequestSchema,\n ErrorCode,\n ListToolsRequestSchema,\n McpError,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\n\nimport { listDocs, readDoc, readNextjsExampleCode, searchDocs } from \"./docs.js\";\n\nconst DEFAULT_API_BASE = \"https://api.roottale.com\";\n\nconst ReadDocInput = z.object({ path: z.string().min(1) }).strict();\nconst SearchDocsInput = z.object({ query: z.string().min(1) }).strict();\nconst ListPostsInput = z\n .object({\n limit: z.number().int().min(1).max(100).optional(),\n type: z.enum([\"post\", \"page\"]).optional(),\n cursor: z.string().optional(),\n })\n .strict();\nconst GetPostInput = z.object({ slugOrId: z.string().min(1) }).strict();\nconst GetSiteKnowledgeInput = z\n .object({ siteId: z.string().optional() })\n .strict();\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"listRootTaleDocs\",\n description:\n \"RootTale CMS 통합 문서 전체 목록(경로·제목·설명)을 반환합니다. \" +\n \"RootTale 연동 작업을 시작하기 전 가장 먼저 호출하세요.\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"readRootTaleDoc\",\n description:\n \"RootTale CMS 통합 문서 1개를 경로로 읽습니다. \" +\n \"경로는 listRootTaleDocs 가 반환한 path 값을 사용하세요.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"문서 경로 (예: blog.md)\" },\n },\n required: [\"path\"],\n additionalProperties: false,\n },\n },\n {\n name: \"searchRootTaleDocs\",\n description:\n \"RootTale CMS 통합 문서 전체에서 정규식으로 검색합니다 (대소문자 무시). \" +\n \"매치된 파일 경로·라인 번호·라인 내용을 반환합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: { type: \"string\", description: \"정규식 패턴 (예: revalidate|webhook)\" },\n },\n required: [\"query\"],\n additionalProperties: false,\n },\n },\n {\n name: \"readRootTaleNextjsExampleCode\",\n description:\n \"RootTale CMS를 외부 Next.js(App Router) 사이트에 연동하는 완전한 예시 코드 세트를 반환합니다 \" +\n \"(블로그 목록/상세, 웹훅 revalidate 라우트, RSS/사이트맵, 상담문의 서버 액션 등). \" +\n \"연동 코드를 작성하기 전 반드시 호출해 패턴을 따라 작성하세요.\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"listPublishedPosts\",\n description:\n \"연동 검증용 — RootTale 공개 CMS API(GET /v1/cms/public/posts)로 발행된 글 목록을 조회합니다. \" +\n \"환경변수 ROOTTALE_API_KEY(rtlk_cust_*)가 필요합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n limit: { type: \"number\", description: \"1-100, 기본 20\" },\n type: { type: \"string\", enum: [\"post\", \"page\"] },\n cursor: { type: \"string\", description: \"이전 응답의 next_cursor\" },\n },\n additionalProperties: false,\n },\n },\n {\n name: \"getPublishedPost\",\n description:\n \"연동 검증용 — RootTale 공개 CMS API(GET /v1/cms/public/posts/:identifier)로 발행된 글 1개를 \" +\n \"slug 또는 UUID로 조회합니다. 환경변수 ROOTTALE_API_KEY(rtlk_cust_*)가 필요합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n slugOrId: { type: \"string\", description: \"글 slug 또는 UUID\" },\n },\n required: [\"slugOrId\"],\n additionalProperties: false,\n },\n },\n {\n name: \"getSiteKnowledge\",\n description:\n \"이 RootTale 사이트의 브랜드 보이스(어조·톤·화자)와 용어 규칙(금지어·교정어)을 조회합니다 \" +\n \"(GET /v1/cms/public/site-knowledge). 이 사이트에 맞는 글을 쓰기 전에 호출해 톤·표현을 맞추세요. \" +\n \"금지어는 쓰지 말고, 교정어는 권장 표현으로 바꿔 쓰세요. 환경변수 ROOTTALE_API_KEY(rtlk_cust_*)가 필요합니다.\",\n inputSchema: {\n type: \"object\",\n properties: {\n siteId: {\n type: \"string\",\n description: \"사이트 ID (선택, 미설정 시 기본 사이트)\",\n },\n },\n additionalProperties: false,\n },\n },\n] as const;\n\nfunction requireApiKey(): { apiKey: string; baseUrl: string } {\n const apiKey = process.env.ROOTTALE_API_KEY;\n if (!apiKey) {\n throw new McpError(\n ErrorCode.InvalidRequest,\n \"ROOTTALE_API_KEY 환경변수가 없습니다. MCP 서버 설정의 env에 \" +\n \"rtlk_cust_* 키를 추가하세요 (발급: 어드민 설정 > 사이트 연결 키).\",\n );\n }\n const baseUrl = (process.env.ROOTTALE_API_BASE ?? DEFAULT_API_BASE).replace(/\\/+$/, \"\");\n return { apiKey, baseUrl };\n}\n\nasync function fetchPublicApi(pathname: string, params?: Record<string, string>): Promise<unknown> {\n const { apiKey, baseUrl } = requireApiKey();\n const url = new URL(`${baseUrl}${pathname}`);\n for (const [key, value] of Object.entries(params ?? {})) {\n url.searchParams.set(key, value);\n }\n const response = await fetch(url, {\n headers: { authorization: `Bearer ${apiKey}` },\n });\n const body = await response.text();\n if (!response.ok) {\n throw new Error(`RootTale API ${response.status}: ${body.slice(0, 500)}`);\n }\n return JSON.parse(body);\n}\n\nfunction textResponse(text: string) {\n return { content: [{ type: \"text\" as const, text }] };\n}\n\nfunction jsonResponse(value: unknown) {\n return textResponse(JSON.stringify(value, null, 2));\n}\n\nexport function registerTools(server: Server): void {\n server.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: TOOL_DEFINITIONS.map((t) => ({ ...t })),\n }));\n\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const args = (request.params.arguments ?? {}) as Record<string, unknown>;\n try {\n switch (request.params.name) {\n case \"listRootTaleDocs\":\n return jsonResponse(await listDocs());\n case \"readRootTaleDoc\": {\n const input = ReadDocInput.parse(args);\n const content = await readDoc(input.path);\n if (content === null) {\n throw new McpError(\n ErrorCode.InvalidParams,\n `문서를 찾을 수 없습니다: ${input.path} — listRootTaleDocs로 경로를 확인하세요.`,\n );\n }\n return textResponse(content);\n }\n case \"searchRootTaleDocs\": {\n const input = SearchDocsInput.parse(args);\n return jsonResponse(await searchDocs(input.query));\n }\n case \"readRootTaleNextjsExampleCode\":\n return textResponse(await readNextjsExampleCode());\n case \"listPublishedPosts\": {\n const input = ListPostsInput.parse(args);\n const params: Record<string, string> = {};\n if (input.limit) params.limit = String(input.limit);\n if (input.type) params.type = input.type;\n if (input.cursor) params.cursor = input.cursor;\n return jsonResponse(await fetchPublicApi(\"/v1/cms/public/posts\", params));\n }\n case \"getPublishedPost\": {\n const input = GetPostInput.parse(args);\n return jsonResponse(\n await fetchPublicApi(\n `/v1/cms/public/posts/${encodeURIComponent(input.slugOrId)}`,\n ),\n );\n }\n case \"getSiteKnowledge\": {\n const input = GetSiteKnowledgeInput.parse(args);\n const params: Record<string, string> = {};\n if (input.siteId) params.site_id = input.siteId;\n return jsonResponse(\n await fetchPublicApi(\"/v1/cms/public/site-knowledge\", params),\n );\n }\n default:\n throw new McpError(\n ErrorCode.InvalidParams,\n `Unknown RootTale tool: ${request.params.name}`,\n );\n }\n } catch (error) {\n if (error instanceof McpError) throw error;\n const message = error instanceof Error ? error.message : String(error);\n return { isError: true, content: [{ type: \"text\" as const, text: message }] };\n }\n });\n}\n","/**\n * 패키지에 번들된 통합 문서(`docs/`)·예시 코드(`examples/`) 로더.\n *\n * dist/index.js 와 src/docs.ts 모두 패키지 루트에서 한 단계 아래이므로\n * `..` 상대 경로 해석이 빌드 전후 동일하다. npm publish 시 `files` 에\n * docs/, examples/ 가 포함되어 npx 환경에서도 같은 구조로 설치된다.\n */\nimport { readdir, readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PKG_ROOT = fileURLToPath(new URL(\"..\", import.meta.url));\nconst DOCS_ROOT = path.join(PKG_ROOT, \"docs\");\nconst EXAMPLES_ROOT = path.join(PKG_ROOT, \"examples\");\n\nexport interface DocEntry {\n /** docs/ 기준 상대 경로 (예: \"blog.md\"). */\n path: string;\n title: string;\n description: string;\n}\n\ninterface Frontmatter {\n title: string;\n description: string;\n}\n\n/** `---\\ntitle: ...\\ndescription: ...\\n---` 형태의 단순 frontmatter 파서. */\nexport function parseFrontmatter(markdown: string): Frontmatter {\n const result: Frontmatter = { title: \"\", description: \"\" };\n const match = markdown.match(/^---\\n([\\s\\S]*?)\\n---/);\n if (!match) return result;\n for (const line of match[1].split(\"\\n\")) {\n const idx = line.indexOf(\":\");\n if (idx === -1) continue;\n const key = line.slice(0, idx).trim();\n const value = line.slice(idx + 1).trim();\n if (key === \"title\") result.title = value;\n if (key === \"description\") result.description = value;\n }\n return result;\n}\n\nasync function walkFiles(root: string, dir = root): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n const files: string[] = [];\n for (const entry of entries) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n files.push(...(await walkFiles(root, full)));\n } else {\n files.push(path.relative(root, full));\n }\n }\n return files.sort();\n}\n\nexport async function listDocs(): Promise<DocEntry[]> {\n const files = (await walkFiles(DOCS_ROOT)).filter((f) => f.endsWith(\".md\"));\n return Promise.all(\n files.map(async (rel) => {\n const content = await readFile(path.join(DOCS_ROOT, rel), \"utf8\");\n const fm = parseFrontmatter(content);\n return { path: rel, title: fm.title || rel, description: fm.description };\n }),\n );\n}\n\n/** 경로 탈출(../) 방지 후 단일 문서 읽기. 없으면 null. */\nexport async function readDoc(relPath: string): Promise<string | null> {\n const resolved = path.resolve(DOCS_ROOT, relPath);\n if (!resolved.startsWith(DOCS_ROOT + path.sep)) return null;\n try {\n return await readFile(resolved, \"utf8\");\n } catch {\n return null;\n }\n}\n\nexport interface SearchMatch {\n path: string;\n line: number;\n text: string;\n}\n\n/** 모든 문서에 대해 정규식 검색 (대소문자 무시). 매치 라인을 반환. */\nexport async function searchDocs(query: string): Promise<SearchMatch[]> {\n const regex = new RegExp(query, \"i\");\n const matches: SearchMatch[] = [];\n for (const doc of await listDocs()) {\n const content = await readDoc(doc.path);\n if (!content) continue;\n content.split(\"\\n\").forEach((text, idx) => {\n if (regex.test(text)) {\n matches.push({ path: doc.path, line: idx + 1, text: text.trim() });\n }\n });\n }\n return matches;\n}\n\n/**\n * Next.js 통합 예시 코드 전체를 `--- FILE: <path> ---` 헤더로 이어붙여 반환.\n * 파일 수가 적어(통합 1세트) 한 번에 주는 쪽이 에이전트 왕복을 줄인다.\n */\nexport async function readNextjsExampleCode(): Promise<string> {\n const root = path.join(EXAMPLES_ROOT, \"nextjs\");\n const files = await walkFiles(root);\n const sections = await Promise.all(\n files.map(async (rel) => {\n const content = await readFile(path.join(root, rel), \"utf8\");\n return `--- FILE: ${rel} ---\\n${content}`;\n }),\n );\n return sections.join(\"\\n\\n\");\n}\n"],"mappings":"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","names":["server","server"]}
@@ -99,6 +99,19 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
99
99
  "updated_at": null }
100
100
  ```
101
101
 
102
+ ## GET /v1/cms/public/site-knowledge
103
+
104
+ 사이트 지식 — 브랜드 보이스(어조·톤·화자) + 용어 규칙(금지어·교정어). AI
105
+ 에이전트가 이 사이트에 맞는 글을 쓸 때 참고. 운영 메모·내부 SEO 임계값·내부
106
+ 출처는 노출 안 함 (theme-and-settings.md 참고).
107
+
108
+ ```json
109
+ { "tenant_id": "…", "site_id": "…",
110
+ "brand": { "voice": "", "tone": "", "persona": "" },
111
+ "lexicon": { "forbidden_words": [], "preferred_terms": [{ "from": "유저", "to": "사용자" }] },
112
+ "updated_at": null }
113
+ ```
114
+
102
115
  ## GET /v1/cms/public/business-profile
103
116
 
104
117
  비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 저장값.
package/docs/blog.md CHANGED
@@ -67,6 +67,18 @@ export default async function PostPage({
67
67
  목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
68
68
  (`theme-and-settings.md` 참고).
69
69
 
70
+ #### 목차 블록 (본문 임의 위치)
71
+
72
+ `showTableOfContents` 는 본문 **상단**에 목차를 자동으로 붙입니다. 글 안의
73
+ 원하는 위치(예: 인트로 문단 다음)에 목차를 넣고 싶다면, 어드민 에디터에서
74
+ 슬래시 메뉴 `/목차` 로 **목차 블록**(`roottale/table-of-contents`)을 삽입하세요.
75
+ 렌더러가 그 위치에 문서의 h2–h4 제목으로 목차(`<nav class="rt-cms-toc">`)를
76
+ 생성하고 각 제목에 앵커 id 를 부여합니다 — 상단 자동 ToC 와 동일 마크업·클래스라
77
+ 스타일은 그대로 적용됩니다. 헤딩이 하나도 없으면 아무것도 렌더되지 않습니다.
78
+
79
+ 블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
80
+ `showTableOfContents` 를 생략(기본 `false`)하는 것을 권장합니다.
81
+
70
82
  ### 고정 페이지 (회사소개 등)
71
83
 
72
84
  어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
@@ -0,0 +1,232 @@
1
+ ---
2
+ title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
3
+ description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 스트림으로 나누는 법. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
4
+ ---
5
+
6
+ # 콘텐츠 유형 (Collections)
7
+
8
+ 하나의 글 목록을 **여러 스트림**으로 나눠 서로 다른 URL·레이아웃으로 보여주는 기능입니다.
9
+ 가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다. 어느 글이 어느
10
+ 스트림에 속할지는 그 글의 **카테고리**로 정해집니다.
11
+
12
+ ## URL이 어떻게 정해지나
13
+
14
+ > **글의 URL = 그 글이 속한 스트림의 `basePath` + `/{slug}`**
15
+
16
+ | 글의 카테고리 | 속하는 스트림 | URL |
17
+ |---|---|---|
18
+ | `notice` | 공지 (basePath `/notice`) | `/notice/{slug}` |
19
+ | `column` · `news` | 블로그 (basePath `/blog`) | `/blog/{slug}` |
20
+
21
+ 예: `notice` 카테고리 글 "개강안내" → `https://내사이트/notice/개강안내`
22
+ `column` 카테고리 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
23
+
24
+ 스트림별로 함께 만들어지는 경로:
25
+
26
+ | 경로 | 설명 |
27
+ |---|---|
28
+ | `{basePath}` | 스트림 목록 (예: `/notice`, `/blog`) |
29
+ | `{basePath}/{slug}` | 글 상세 |
30
+ | `{basePath}/categories/{slug}` | 카테고리 아카이브 (`archives` 켠 스트림만) |
31
+ | `/feed.xml` | RSS — `feed` 켠 스트림들의 통합 피드 |
32
+ | `/sitemap.xml` | 글마다 **소속 스트림 basePath로** 정확히 매핑 |
33
+ | `{basePath}/{slug}/opengraph-image` | 글별 동적 OG 카드 (배선 시) |
34
+
35
+ 규칙:
36
+
37
+ - **slug은 한글 그대로** 됩니다(예: `/blog/비문학독해`). 내부적으로 percent-encoding.
38
+ - **같은 글이 두 스트림에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
39
+ - **글이 어느 스트림에도 안 속하면**(분류 전용 카테고리 등) sitemap·feed에서 제외됩니다.
40
+ - 한 글이 여러 스트림 카테고리를 동시에 가지면 **선언 순서가 빠른 스트림**이 이깁니다(first-wins).
41
+
42
+ ## 어드민에서 설정 (`mysite.roottale.com`)
43
+
44
+ **설정 > 콘텐츠 유형** 에서 스트림을 정의합니다. 각 스트림은:
45
+
46
+ | 항목 | 의미 |
47
+ |---|---|
48
+ | key | 안정 식별자 (`notice`, `blog`) |
49
+ | 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
50
+ | basePath | URL 앞부분 (`/notice`, `/blog`) |
51
+ | 카테고리 | 이 스트림에 속하는 카테고리 slug들. **비우면 catch-all**(나머지 전부) |
52
+ | feed / archives / og | RSS 포함 / 카테고리 아카이브 / 동적 OG |
53
+ | 순서 | 위에서부터 우선순위 |
54
+
55
+ **비우면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
56
+
57
+ ### ⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
58
+
59
+ basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
60
+ **단 basePath에 해당하는 페이지 파일이 사이트에 있어야** 실제로 열립니다:
61
+
62
+ - `/notice`·`/blog`처럼 **이미 라우트가 있는 경로**는 어드민만으로 자유롭게 편집 → 동작.
63
+ - basePath를 **완전히 새 경로**(예: `/news`)로 바꾸면 sitemap엔 `/news/{slug}`가 나가지만
64
+ 사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 이 경우 개발자가 라우트를 먼저 추가해야
65
+ 합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트를 쓰면 됩니다 — 아래.)
66
+
67
+ ### 카테고리 만들기
68
+
69
+ 각 스트림의 카테고리(`notice`, `column`, `news` 등)는 **설정 > 카테고리**(taxonomy)에서
70
+ 만들고, 글 작성 화면에서 글에 붙입니다. 작성 화면에는 *"이 글은 → /notice 에 게시됩니다"*
71
+ 표시가 떠서 어느 스트림으로 가는지 바로 확인됩니다.
72
+
73
+ ## 사이트 연동 코드
74
+
75
+ 스트림 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
76
+ 방식이 있습니다.
77
+
78
+ ### 방식 A — 코드 상수 (간단, 고정)
79
+
80
+ ```ts
81
+ import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
82
+
83
+ export const COLLECTIONS: RouteCollection[] = [
84
+ { key: "notice", basePath: "/notice", categories: ["notice"] },
85
+ {
86
+ key: "blog",
87
+ basePath: "/blog",
88
+ categories: ["column", "news"], // 또는 [] = catch-all(공지 외 전부)
89
+ feed: true,
90
+ archives: true,
91
+ },
92
+ ];
93
+ ```
94
+
95
+ ```ts
96
+ // app/sitemap.ts
97
+ import { createSitemap } from "@roottale/cms-renderer-next/routes";
98
+ export default createSitemap({ apiKey, siteUrl, title, collections: COLLECTIONS }, [
99
+ /* 정적 경로 */
100
+ ]);
101
+
102
+ // app/feed.xml/route.ts
103
+ export const dynamic = "force-dynamic";
104
+ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLECTIONS });
105
+ ```
106
+
107
+ ### 방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
108
+
109
+ 상수 대신 **어드민 "콘텐츠 유형"** 값을 `fetchCollections()`로 가져와 팩토리에 **resolver
110
+ 함수**로 넘깁니다. 운영자가 어드민에서 스트림을 바꾸면 사이트가 따라갑니다.
111
+
112
+ ```ts
113
+ import { fetchCollections } from "@roottale/cms-client/server";
114
+
115
+ const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
116
+
117
+ async function getCollections(): Promise<RouteCollection[]> {
118
+ try {
119
+ const c = await fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! });
120
+ return c.length ? c : DEFAULT;
121
+ } catch {
122
+ return DEFAULT; // API 미설정/실패 시 기본값
123
+ }
124
+ }
125
+
126
+ export default createSitemap({ apiKey, siteUrl, title, collections: getCollections }, [ ]);
127
+ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
128
+ ```
129
+
130
+ 공개 엔드포인트: `GET /v1/cms/public/collections` (블로그 조회와 같은 API 키).
131
+ 응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
132
+ 사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
133
+
134
+ ### 상세 페이지 가드 (스트림 누출 차단)
135
+
136
+ 상세 라우트는 글이 그 스트림 소속인지 확인해 다른 스트림 글이 새는 것을 막습니다.
137
+
138
+ ```ts
139
+ import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
140
+ // app/blog/[slug]/page.tsx
141
+ const post = await getPost(slug);
142
+ if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
143
+ ```
144
+
145
+ ### revalidate
146
+
147
+ ```ts
148
+ import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
149
+ export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
150
+ ```
151
+
152
+ 각 스트림 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
153
+ 자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
154
+ 응답에 `warning`을 노출합니다.
155
+
156
+ ### 동적 OG 이미지
157
+
158
+ ```tsx
159
+ // app/blog/[slug]/opengraph-image.tsx
160
+ import { ImageResponse } from "next/og";
161
+ import {
162
+ createPostOgImage, OG_IMAGE_SIZE, OG_IMAGE_CONTENT_TYPE,
163
+ type ImageResponseLike,
164
+ } from "@roottale/cms-renderer-next/routes";
165
+
166
+ export const size = OG_IMAGE_SIZE;
167
+ export const contentType = OG_IMAGE_CONTENT_TYPE;
168
+ export default createPostOgImage(
169
+ { apiKey, siteUrl, title, brandLabel: "내 사이트" },
170
+ { ImageResponse: ImageResponse as unknown as ImageResponseLike }, // Next 16 타입 cast
171
+ );
172
+ ```
173
+
174
+ > Astro 사이트는 `@roottale/cms-renderer-astro`에서 동일한 `resolvePostCollection`/
175
+ > `resolvePostPath`/`RouteCollection`을 import 하고 `renderBlogList({ collections })`로
176
+ > 링크를 스트림별로 라우팅합니다(동등 surface).
177
+
178
+ ## catch-all 스트림
179
+
180
+ 블로그 카테고리가 계속 늘어나는 사이트는, 블로그를 **`categories: []`(빈 배열) = catch-all**로
181
+ 두면 됩니다 — 공지로 분류되지 않은 글을 전부 흡수합니다. catch-all은 **맨 뒤**에 두세요
182
+ (선언 순서가 우선순위라, 뒤에 둔 스트림은 도달 못 합니다).
183
+
184
+ ```ts
185
+ [
186
+ { key: "notice", basePath: "/notice", categories: ["notice"] },
187
+ { key: "blog", basePath: "/blog", categories: [], feed: true }, // 나머지 전부
188
+ ]
189
+ ```
190
+
191
+ ## 명시 섹션 — `collection_key` (권장)
192
+
193
+ 공개 API의 글(post) 응답에는 글이 속한 섹션을 직접 가리키는 **`collection_key`**
194
+ 필드가 포함됩니다. 어드민 글쓰기에서 "어디에 올릴까요?"로 고른 섹션이 이 값으로
195
+ 저장됩니다. `cms-client`의 `CmsPostContent.collectionKey`로 받습니다.
196
+
197
+ `resolvePostCollection(post, collections)`는 **`collectionKey`가 있으면 그것을
198
+ 우선** 사용하고(일치하는 섹션이 있을 때), 없으면 기존처럼 글의 카테고리 slug로
199
+ 섹션을 파생합니다. 즉:
200
+
201
+ - 신규 글: `collectionKey`로 섹션이 명확히 결정됩니다(카테고리는 순수 "주제"로만 쓰임).
202
+ - 구 글/구 서버: `collection_key`가 없으므로 카테고리 파생으로 **그대로 동작**(하위호환).
203
+
204
+ ```ts
205
+ // 소비자 코드는 동일 — resolver가 collectionKey 를 우선 사용.
206
+ const c = resolvePostCollection(post, collections);
207
+ ```
208
+
209
+ > 마이그레이션 중에는 두 방식이 공존합니다. `collection_key`가 채워진 글은 카테고리와
210
+ > 무관하게 그 섹션으로 라우팅되고, 비어 있는 글은 카테고리로 판정됩니다.
211
+
212
+ ## slug 변경과 301
213
+
214
+ 글의 slug(`/{slug}` 부분)는 글 편집 화면에서 바꿉니다. 바꾼 뒤 옛 slug로 들어오면 `postRedirectPath`로
215
+ **301 리다이렉트**되어 새 slug로 넘어갑니다(검색 순위 보존).
216
+
217
+ ## 동적 basePath (advanced)
218
+
219
+ basePath를 **코드 수정 없이 어드민에서 자유롭게** 바꾸고 싶으면, 정적 `app/notice/[slug]`
220
+ 대신 **catch-all 동적 라우트** `app/[stream]/[slug]/page.tsx`(또는 `app/[...path]`)를 두고,
221
+ 그 안에서 collections를 읽어 요청 경로가 어떤 스트림 basePath인지 판정해 렌더합니다. 그러면
222
+ 어드민에서 basePath를 `/news`로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
223
+ 안전성이 떨어지므로, 스트림 구조가 자주 바뀌는 사이트에만 권장합니다.
224
+
225
+ ## 자주 막히는 곳
226
+
227
+ - **글이 안 보여요** → 그 글에 스트림 카테고리(`notice`/`column`/`news` 등)가 붙어 있는지 확인.
228
+ 어드민 콘텐츠 유형이 비어 있으면 사이트는 코드 기본값으로만 동작합니다.
229
+ - **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 스트림
230
+ basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인.
231
+ - **sitemap에 글이 빠졌어요** → 그 글이 어느 스트림에도 안 속하면(상세 라우트 없는 분류 전용)
232
+ 의도적으로 제외됩니다.
package/docs/seo.md CHANGED
@@ -51,6 +51,13 @@ export default createSitemap(
51
51
  );
52
52
  ```
53
53
 
54
+ ## 다중 스트림 (collections) — 공지·블로그 분리
55
+
56
+ 같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 스트림으로 나눠 서로 다른
57
+ URL·레이아웃으로 보여줄 수 있습니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
58
+ fetch)·가드·catch-all·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
59
+ 에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 스트림별로 파생됩니다.
60
+
54
61
  ## robots.txt
55
62
 
56
63
  크롤링 제어의 기본. sitemap 위치를 알려주고, 크롤링이 무의미한 경로만
@@ -137,6 +144,7 @@ import {
137
144
  createPostOgImage,
138
145
  OG_IMAGE_SIZE,
139
146
  OG_IMAGE_CONTENT_TYPE,
147
+ type ImageResponseLike,
140
148
  } from "@roottale/cms-renderer-next/routes";
141
149
 
142
150
  export const size = OG_IMAGE_SIZE; // { width: 1200, height: 630 }
@@ -150,7 +158,9 @@ export default createPostOgImage(
150
158
  // 선택 — 브랜드 색 커스텀:
151
159
  // backgroundColor: "#10172a", accentColor: "#38bdf8", brandLabel: "예시",
152
160
  },
153
- { ImageResponse },
161
+ // Next 16 의 ImageResponse 타입은 패키지 ImageResponseLike 와 미묘하게 달라
162
+ // cast 가 필요하다(패키지는 next 비의존이라 구조적 타입만 안다).
163
+ { ImageResponse: ImageResponse as unknown as ImageResponseLike },
154
164
  );
155
165
  ```
156
166
 
@@ -87,3 +87,37 @@ const config = await fetchAnalyticsConfig({
87
87
 
88
88
  `enabled: true`인 태그만 렌더링하세요. 태그 ID는 어드민에서 변경될 수
89
89
  있으므로 하드코딩하지 말고 본 API로 조회하는 것을 권장합니다.
90
+
91
+ ## 사이트 지식 — 브랜드 보이스 (AI 에이전트용)
92
+
93
+ 이 사이트의 **브랜드 보이스**(어조·톤·화자)와 **용어 규칙**(금지어·교정어)을
94
+ 반환합니다. 고객 측 AI 에이전트가 이 사이트에 맞는 글을 쓰기 전에 조회해
95
+ 톤·표현을 맞추는 용도입니다. RootTale 어드민의 **설정 > 블로그 > 지식 규칙**에서
96
+ 저장한 값이며, 운영 메모·내부 SEO 임계값·내부 출처 목록은 노출되지 않습니다.
97
+
98
+ MCP 도구 `getSiteKnowledge` 로 조회하거나 공개 API 를 직접 호출합니다.
99
+
100
+ ```http
101
+ GET /v1/cms/public/site-knowledge
102
+ Authorization: Bearer rtlk_cust_***
103
+ ```
104
+
105
+ ```jsonc
106
+ {
107
+ "tenant_id": "…",
108
+ "site_id": "…",
109
+ "brand": { "voice": "존댓말, 쉬운 말", "tone": "차분하고 신뢰감 있게", "persona": "세무 상담이 처음인 사장님 대상" },
110
+ "lexicon": {
111
+ "forbidden_words": ["대박", "100% 보장"],
112
+ "preferred_terms": [{ "from": "유저", "to": "사용자" }]
113
+ },
114
+ "updated_at": "2026-06-24T…Z"
115
+ }
116
+ ```
117
+
118
+ AI 에이전트 활용 규칙:
119
+
120
+ - `brand` 의 어조·톤·화자를 글의 문체에 반영하세요.
121
+ - `forbidden_words` 의 표현은 본문에 쓰지 마세요.
122
+ - `preferred_terms` 는 `from` 표현 대신 `to` 표현을 쓰세요.
123
+ - 값은 어드민에서 바뀔 수 있으니 하드코딩하지 말고 본 API 로 조회하세요.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.25.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp",
6
6
  "bin": {