diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue
index b7ba17d..1d59548 100644
--- a/components/admin/AdminPostForm.vue
+++ b/components/admin/AdminPostForm.vue
@@ -74,6 +74,10 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '',
content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '',
+ seoTitle: props.initialPost.seoTitle || '',
+ seoDescription: props.initialPost.seoDescription || '',
+ canonicalUrl: props.initialPost.canonicalUrl || '',
+ noindex: Boolean(props.initialPost.noindex),
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
@@ -144,6 +148,10 @@ const createPostPayload = () => {
excerpt: form.excerpt.trim(),
content: form.content,
featuredImage: form.featuredImage.trim() || null,
+ seoTitle: form.seoTitle.trim(),
+ seoDescription: form.seoDescription.trim(),
+ canonicalUrl: form.canonicalUrl.trim(),
+ noindex: form.noindex,
status: form.status,
publishedAt,
tags: parseTags(form.tagsText)
@@ -160,6 +168,10 @@ const createAutosavePayload = () => ({
excerpt: form.excerpt,
content: form.content,
featuredImage: form.featuredImage,
+ seoTitle: form.seoTitle,
+ seoDescription: form.seoDescription,
+ canonicalUrl: form.canonicalUrl,
+ noindex: form.noindex,
status: form.status,
publishedAt: form.publishedAt,
tagsText: form.tagsText
@@ -476,6 +488,66 @@ defineExpose({
>
+
+
+
+ SEO
+
+
+ 검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
+
+
+
+
+
+
+
+
+
+
+
+
대표 이미지
diff --git a/db/migrations/008_add_post_seo_fields.sql b/db/migrations/008_add_post_seo_fields.sql
new file mode 100644
index 0000000..608bea8
--- /dev/null
+++ b/db/migrations/008_add_post_seo_fields.sql
@@ -0,0 +1,11 @@
+ALTER TABLE posts
+ ADD COLUMN IF NOT EXISTS seo_title TEXT NOT NULL DEFAULT '';
+
+ALTER TABLE posts
+ ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT '';
+
+ALTER TABLE posts
+ ADD COLUMN IF NOT EXISTS canonical_url TEXT NOT NULL DEFAULT '';
+
+ALTER TABLE posts
+ ADD COLUMN IF NOT EXISTS noindex BOOLEAN NOT NULL DEFAULT false;
diff --git a/docs/history.md b/docs/history.md
index 3f68d4e..1e69b2e 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,13 @@
# 의사결정 이력
+## 2026-05-03 v0.0.29
+
+### 게시물 SEO 설정 범위 결정
+
+게시물 SEO 설정은 우선 검색 결과에 직접 영향을 주는 SEO 제목, SEO 설명, canonical URL, robots noindex 값만 다룬다. OG 이미지는 대표 이미지 재사용 여부와 별도 이미지 선택 흐름이 더 필요하므로 이번 단계에서는 기본 OG 제목/설명/URL만 공개 상세 화면에 연결하고, 전용 OG 이미지는 다음 작업으로 남긴다.
+
+SEO 제목과 설명이 비어 있으면 기존 글 제목과 요약을 fallback으로 사용한다. 이렇게 하면 모든 글에 값을 강제로 입력하지 않아도 공개 화면의 기본 메타 품질을 유지할 수 있다.
+
## 2026-05-03 v0.0.28
### 예약 발행 저장 방식 결정
diff --git a/docs/map.md b/docs/map.md
index 8776409..50e0433 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -26,7 +26,7 @@
| 파일 | 화면 위치 |
|------|-----------|
-| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력 |
+| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
@@ -77,7 +77,7 @@
| pages/index.vue | 홈 |
| pages/posts/index.vue | 게시물 전체 목록 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
-| pages/post/[slug].vue | 블로그 글 상세 |
+| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO 메타 출력 |
| pages/tags/index.vue | 태그 전체 목록 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록 |
@@ -147,6 +147,7 @@
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
+| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
## 설정/배포
diff --git a/docs/spec.md b/docs/spec.md
index 34e4911..56bdbf7 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -124,6 +124,10 @@ components/content/
| content | Text | 마크다운 콘텐츠 |
| excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 |
+| seo_title | String | SEO 제목 |
+| seo_description | String | SEO 설명 |
+| canonical_url | String | canonical URL |
+| noindex | Boolean | 검색엔진 노출 제외 여부 |
| status | Enum | published/draft/private |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
@@ -295,6 +299,9 @@ components/content/
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다.
+- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
+- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
+- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
diff --git a/docs/todo.md b/docs/todo.md
index 223f52e..7b2cc5b 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -22,7 +22,6 @@
## 3차 관리자 개발
- [ ] 초안 자동 저장
-- [ ] SEO 설정
- [ ] OG 이미지 설정
- [ ] 글 미리보기
diff --git a/docs/update.md b/docs/update.md
index a7d7af5..f13be48 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,13 @@
# 업데이트 이력
+## v0.0.29
+
+- 게시물 SEO 메타데이터 컬럼 추가.
+- 관리자 글 작성/수정 폼에 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 설정 추가.
+- 관리자 게시물 생성/수정 API가 SEO 값을 저장하도록 수정.
+- 공개 게시물 상세 화면에 SEO 제목, description, canonical, robots, 기본 OG 메타 연결.
+- 패키지 버전을 0.0.29로 갱신.
+
## v0.0.28
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
diff --git a/package-lock.json b/package-lock.json
index ecd8a37..cd01ddf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "0.0.28",
+ "version": "0.0.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "0.0.28",
+ "version": "0.0.29",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index f16d03d..60852fa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "0.0.28",
+ "version": "0.0.29",
"private": true,
"type": "module",
"scripts": {
diff --git a/pages/post/[slug].vue b/pages/post/[slug].vue
index 2262989..3732000 100644
--- a/pages/post/[slug].vue
+++ b/pages/post/[slug].vue
@@ -16,6 +16,44 @@ if (!post.value) {
}
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
+const config = useRuntimeConfig()
+const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
+const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
+const seoTitle = computed(() => post.value.seoTitle || post.value.title)
+const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
+const canonicalUrl = computed(() => post.value.canonicalUrl || pageUrl.value)
+
+useHead(() => ({
+ title: seoTitle.value,
+ link: [
+ {
+ rel: 'canonical',
+ href: canonicalUrl.value
+ }
+ ],
+ meta: [
+ {
+ name: 'description',
+ content: seoDescription.value
+ },
+ {
+ name: 'robots',
+ content: post.value.noindex ? 'noindex, nofollow' : 'index, follow'
+ },
+ {
+ property: 'og:title',
+ content: seoTitle.value
+ },
+ {
+ property: 'og:description',
+ content: seoDescription.value
+ },
+ {
+ property: 'og:url',
+ content: pageUrl.value
+ }
+ ]
+}))
diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js
index 2e3b66b..36a0214 100644
--- a/server/repositories/content-repository.js
+++ b/server/repositories/content-repository.js
@@ -21,6 +21,10 @@ const mapPostRow = (row) => ({
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
+ seoTitle: row.seo_title || '',
+ seoDescription: row.seo_description || '',
+ canonicalUrl: row.canonical_url || '',
+ noindex: Boolean(row.noindex),
status: row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(),
@@ -241,6 +245,10 @@ export const createAdminPost = async (input) => {
content,
excerpt,
featured_image,
+ seo_title,
+ seo_description,
+ canonical_url,
+ noindex,
status,
published_at
)
@@ -250,6 +258,10 @@ export const createAdminPost = async (input) => {
${input.content},
${input.excerpt},
${input.featuredImage},
+ ${input.seoTitle},
+ ${input.seoDescription},
+ ${input.canonicalUrl},
+ ${input.noindex},
${input.status},
${input.publishedAt}
)
@@ -286,6 +298,10 @@ export const updateAdminPost = async (id, input) => {
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
+ seo_title = ${input.seoTitle},
+ seo_description = ${input.seoDescription},
+ canonical_url = ${input.canonicalUrl},
+ noindex = ${input.noindex},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()
diff --git a/server/utils/admin-post-input.js b/server/utils/admin-post-input.js
index ec00db8..547da87 100644
--- a/server/utils/admin-post-input.js
+++ b/server/utils/admin-post-input.js
@@ -7,6 +7,10 @@ export const adminPostInputSchema = z.object({
content: z.string().default(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
+ seoTitle: z.string().trim().max(80).default(''),
+ seoDescription: z.string().trim().max(180).default(''),
+ canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
+ noindex: z.boolean().default(false),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([])
diff --git a/server/utils/content-schema.js b/server/utils/content-schema.js
index abbeec9..2597929 100644
--- a/server/utils/content-schema.js
+++ b/server/utils/content-schema.js
@@ -9,6 +9,10 @@ export const postSchema = z.object({
content: z.string(),
excerpt: z.string().default(''),
featuredImage: z.string().nullable().default(null),
+ seoTitle: z.string().default(''),
+ seoDescription: z.string().default(''),
+ canonicalUrl: z.string().default(''),
+ noindex: z.boolean().default(false),
status: postStatusSchema,
publishedAt: z.string().nullable().default(null),
createdAt: z.string(),