게시물 SEO 설정 추가

This commit is contained in:
2026-05-03 10:03:53 +09:00
parent 60f9fd52f0
commit fc5f41b9cc
13 changed files with 174 additions and 6 deletions

View File

@@ -74,6 +74,10 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '', excerpt: props.initialPost.excerpt || '',
content: props.initialPost.content || '', content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '', 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', status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt), publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || '' tagsText: props.initialPost.tags?.join(', ') || ''
@@ -144,6 +148,10 @@ const createPostPayload = () => {
excerpt: form.excerpt.trim(), excerpt: form.excerpt.trim(),
content: form.content, content: form.content,
featuredImage: form.featuredImage.trim() || null, featuredImage: form.featuredImage.trim() || null,
seoTitle: form.seoTitle.trim(),
seoDescription: form.seoDescription.trim(),
canonicalUrl: form.canonicalUrl.trim(),
noindex: form.noindex,
status: form.status, status: form.status,
publishedAt, publishedAt,
tags: parseTags(form.tagsText) tags: parseTags(form.tagsText)
@@ -160,6 +168,10 @@ const createAutosavePayload = () => ({
excerpt: form.excerpt, excerpt: form.excerpt,
content: form.content, content: form.content,
featuredImage: form.featuredImage, featuredImage: form.featuredImage,
seoTitle: form.seoTitle,
seoDescription: form.seoDescription,
canonicalUrl: form.canonicalUrl,
noindex: form.noindex,
status: form.status, status: form.status,
publishedAt: form.publishedAt, publishedAt: form.publishedAt,
tagsText: form.tagsText tagsText: form.tagsText
@@ -476,6 +488,66 @@ defineExpose({
> >
</label> </label>
<div class="admin-post-form__seo grid gap-3 rounded border border-line bg-white p-4 text-sm">
<div>
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">
SEO
</h2>
<p class="admin-post-form__section-description mt-1 text-xs text-muted">
검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
</p>
</div>
<label class="admin-post-form__field grid gap-2">
<span class="admin-post-form__label font-medium">SEO 제목</span>
<input
v-model="form.seoTitle"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="text"
maxlength="80"
placeholder="비워두면 글 제목을 사용"
>
<span class="admin-post-form__hint text-xs text-muted">
{{ form.seoTitle.length }}/80
</span>
</label>
<label class="admin-post-form__field grid gap-2">
<span class="admin-post-form__label font-medium">SEO 설명</span>
<textarea
v-model="form.seoDescription"
class="admin-post-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
maxlength="180"
placeholder="비워두면 요약을 사용"
/>
<span class="admin-post-form__hint text-xs text-muted">
{{ form.seoDescription.length }}/180
</span>
</label>
<label class="admin-post-form__field grid gap-2">
<span class="admin-post-form__label font-medium">Canonical URL</span>
<input
v-model="form.canonicalUrl"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="url"
placeholder="비워두면 기본 글 주소를 사용"
>
</label>
<label class="admin-post-form__checkbox flex items-start gap-2 text-sm">
<input
v-model="form.noindex"
class="admin-post-form__checkbox-input mt-1"
type="checkbox"
>
<span>
<span class="admin-post-form__label block font-medium">검색엔진 노출 제외</span>
<span class="admin-post-form__hint mt-1 block text-xs text-muted">공개 글이어도 robots noindex 메타를 추가합니다.</span>
</span>
</label>
</div>
<div class="admin-post-form__field grid gap-2 text-sm"> <div class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">대표 이미지</span> <span class="admin-post-form__label font-medium">대표 이미지</span>
<figure v-if="form.featuredImage" class="admin-post-form__featured overflow-hidden rounded border border-line bg-white"> <figure v-if="form.featuredImage" class="admin-post-form__featured overflow-hidden rounded border border-line bg-white">

View File

@@ -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;

View File

@@ -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 ## 2026-05-03 v0.0.28
### 예약 발행 저장 방식 결정 ### 예약 발행 저장 방식 결정

View File

@@ -26,7 +26,7 @@
| 파일 | 화면 위치 | | 파일 | 화면 위치 |
|------|-----------| |------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
@@ -77,7 +77,7 @@
| pages/index.vue | 홈 | | pages/index.vue | 홈 |
| pages/posts/index.vue | 게시물 전체 목록 | | pages/posts/index.vue | 게시물 전체 목록 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 | | pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세 | | pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO 메타 출력 |
| pages/tags/index.vue | 태그 전체 목록 | | pages/tags/index.vue | 태그 전체 목록 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 | | pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록 | | pages/tag/[slug].vue | 태그별 글 목록 |
@@ -147,6 +147,7 @@
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | | db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | | db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
## 설정/배포 ## 설정/배포

View File

@@ -124,6 +124,10 @@ components/content/
| content | Text | 마크다운 콘텐츠 | | content | Text | 마크다운 콘텐츠 |
| excerpt | String | 요약 | | excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 | | featured_image | String nullable | 대표 이미지 |
| seo_title | String | SEO 제목 |
| seo_description | String | SEO 설명 |
| canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 |
| status | Enum | published/draft/private | | status | Enum | published/draft/private |
| published_at | DateTime | 발행일 | | published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
@@ -295,6 +299,9 @@ components/content/
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다. - 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다. - 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다. - 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다.
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다. - 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다.
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다. - 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다. - 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.

View File

@@ -22,7 +22,6 @@
## 3차 관리자 개발 ## 3차 관리자 개발
- [ ] 초안 자동 저장 - [ ] 초안 자동 저장
- [ ] SEO 설정
- [ ] OG 이미지 설정 - [ ] OG 이미지 설정
- [ ] 글 미리보기 - [ ] 글 미리보기

View File

@@ -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 ## v0.0.28
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가. - 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.28", "version": "0.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.28", "version": "0.0.29",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.28", "version": "0.0.29",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -16,6 +16,44 @@ if (!post.value) {
} }
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST') 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
}
]
}))
</script> </script>
<template> <template>

View File

@@ -21,6 +21,10 @@ const mapPostRow = (row) => ({
content: row.content, content: row.content,
excerpt: row.excerpt, excerpt: row.excerpt,
featuredImage: row.featured_image, featuredImage: row.featured_image,
seoTitle: row.seo_title || '',
seoDescription: row.seo_description || '',
canonicalUrl: row.canonical_url || '',
noindex: Boolean(row.noindex),
status: row.status, status: row.status,
publishedAt: row.published_at ? row.published_at.toISOString() : null, publishedAt: row.published_at ? row.published_at.toISOString() : null,
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
@@ -241,6 +245,10 @@ export const createAdminPost = async (input) => {
content, content,
excerpt, excerpt,
featured_image, featured_image,
seo_title,
seo_description,
canonical_url,
noindex,
status, status,
published_at published_at
) )
@@ -250,6 +258,10 @@ export const createAdminPost = async (input) => {
${input.content}, ${input.content},
${input.excerpt}, ${input.excerpt},
${input.featuredImage}, ${input.featuredImage},
${input.seoTitle},
${input.seoDescription},
${input.canonicalUrl},
${input.noindex},
${input.status}, ${input.status},
${input.publishedAt} ${input.publishedAt}
) )
@@ -286,6 +298,10 @@ export const updateAdminPost = async (id, input) => {
content = ${input.content}, content = ${input.content},
excerpt = ${input.excerpt}, excerpt = ${input.excerpt},
featured_image = ${input.featuredImage}, featured_image = ${input.featuredImage},
seo_title = ${input.seoTitle},
seo_description = ${input.seoDescription},
canonical_url = ${input.canonicalUrl},
noindex = ${input.noindex},
status = ${input.status}, status = ${input.status},
published_at = ${input.publishedAt}, published_at = ${input.publishedAt},
updated_at = now() updated_at = now()

View File

@@ -7,6 +7,10 @@ export const adminPostInputSchema = z.object({
content: z.string().default(''), content: z.string().default(''),
excerpt: z.string().default(''), excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null), 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'), status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null), publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([]) tags: z.array(z.string().trim().min(1)).default([])

View File

@@ -9,6 +9,10 @@ export const postSchema = z.object({
content: z.string(), content: z.string(),
excerpt: z.string().default(''), excerpt: z.string().default(''),
featuredImage: z.string().nullable().default(null), 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, status: postStatusSchema,
publishedAt: z.string().nullable().default(null), publishedAt: z.string().nullable().default(null),
createdAt: z.string(), createdAt: z.string(),