게시물 SEO 설정 추가
This commit is contained in:
@@ -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({
|
||||
>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
11
db/migrations/008_add_post_seo_fields.sql
Normal file
11
db/migrations/008_add_post_seo_fields.sql
Normal 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;
|
||||
@@ -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
|
||||
|
||||
### 예약 발행 저장 방식 결정
|
||||
|
||||
@@ -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 필드 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
@@ -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 상태에서만 표시한다.
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] SEO 설정
|
||||
- [ ] OG 이미지 설정
|
||||
- [ ] 글 미리보기
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user