From 79fb354d9180210c909d7bedc9394d1162505aa2 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 12 May 2026 09:56:52 +0900 Subject: [PATCH] =?UTF-8?q?v0.0.86:=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=ED=8C=A8=EB=94=A9,=20=ED=83=9C=EA=B7=B8=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EC=9C=A0=EC=A7=80,=20SEO=20=EC=9E=90=EB=8F=99,=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B4=80=EB=A6=AC=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPostForm.vue | 90 +++++++++++------------ docs/history.md | 32 ++++++++ docs/map.md | 6 +- docs/spec.md | 15 +++- docs/update.md | 23 ++++++ package.json | 2 +- pages/admin/posts/preview.vue | 28 ++++--- pages/admin/tags/index.vue | 70 +++++++++++------- server/repositories/content-repository.js | 44 ++++++++--- server/utils/admin-post-input.js | 4 +- 10 files changed, 212 insertions(+), 102 deletions(-) diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 90a40a6..a98f18a 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -96,8 +96,6 @@ const form = reactive({ excerpt: props.initialPost.excerpt || '', content: props.initialPost.content || '', featuredImage: props.initialPost.featuredImage || '', - seoTitle: props.initialPost.seoTitle || '', - seoDescription: props.initialPost.seoDescription || '', noindex: Boolean(props.initialPost.noindex), status: props.initialPost.status || 'draft', publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt), @@ -150,6 +148,24 @@ const toSlug = (value) => value .replace(/-+/g, '-') .replace(/^-|-$/g, '') +/** + * 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로) + * @param {string} value - 원본 문자열 + * @returns {string} 정규화된 태그 문자열 + */ +const normalizeTagToken = (value) => { + const raw = String(value).normalize('NFC').trim().toLowerCase() + if (!raw) { + return '' + } + + return raw + .replace(/\s+/g, '-') + .replace(/[^a-z0-9가-힣-]+/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + watch(() => form.title, (title) => { if (!slugTouched.value) { form.slug = toSlug(title) @@ -166,14 +182,29 @@ const touchSlug = () => { } /** - * 쉼표 구분 태그 문자열을 슬러그 배열로 변환 + * 쉼표 구분 태그 문자열을 토큰 배열로 변환 * @param {string} value - 태그 입력 문자열 - * @returns {Array} 태그 슬러그 목록 + * @returns {Array} 태그 토큰 목록 */ -const parseTags = (value) => [...new Set(value - .split(',') - .map((tag) => toSlug(tag)) - .filter(Boolean))] +const parseTags = (value) => { + const seen = new Set() + const out = [] + + for (const part of value.split(',')) { + const tag = normalizeTagToken(part) + if (!tag) { + continue + } + const dedupeKey = tag.toLowerCase() + if (seen.has(dedupeKey)) { + continue + } + seen.add(dedupeKey) + out.push(tag) + } + + return out +} const selectedTags = computed(() => parseTags(form.tagsText)) @@ -218,8 +249,8 @@ const createPostPayload = () => { excerpt: form.excerpt.trim(), content: form.content, featuredImage: form.featuredImage.trim() || null, - seoTitle: form.seoTitle.trim(), - seoDescription: form.seoDescription.trim(), + seoTitle: form.title.trim(), + seoDescription: form.excerpt.trim(), canonicalUrl: '', noindex: form.noindex, ogImage: null, @@ -239,8 +270,6 @@ const createAutosavePayload = () => ({ excerpt: form.excerpt, content: form.content, featuredImage: form.featuredImage, - seoTitle: form.seoTitle, - seoDescription: form.seoDescription, noindex: form.noindex, status: form.status, publishedAt: form.publishedAt, @@ -258,8 +287,6 @@ const isEmptyAutosavePayload = (payload) => ![ payload.excerpt, payload.content, payload.featuredImage, - payload.seoTitle, - payload.seoDescription, payload.tagsText ].some((value) => String(value || '').trim()) @@ -416,7 +443,7 @@ const removeFeaturedImage = () => { * @returns {void} */ const addTagFromInput = () => { - const nextTag = toSlug(tagInput.value) + const nextTag = normalizeTagToken(tagInput.value) if (!nextTag) { tagInput.value = '' @@ -794,43 +821,16 @@ defineExpose({ -
+

- SEO + 검색 노출

- 검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다. + 메타 제목·설명은 저장 시 글 제목과 요약을 그대로 사용합니다.

- - -