From 6af65e6508e1d7d69c1443365ae97fac7de3e769 Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Tue, 16 Jun 2026 12:25:03 +0900 Subject: [PATCH 1/5] Fix post detail TOC and code block injection --- .../src/components/posts/TableOfContents.jsx | 68 +++++-------------- Elevate.Web/src/pages/PostDetail.jsx | 4 +- Elevate.Web/src/utils/html.js | 30 +++++++- 3 files changed, 46 insertions(+), 56 deletions(-) diff --git a/Elevate.Web/src/components/posts/TableOfContents.jsx b/Elevate.Web/src/components/posts/TableOfContents.jsx index ef18d8f5..120ccf16 100644 --- a/Elevate.Web/src/components/posts/TableOfContents.jsx +++ b/Elevate.Web/src/components/posts/TableOfContents.jsx @@ -10,27 +10,7 @@ * `post-title` id: 게시글 제목을 목차 최상단에 별도 스타일로 추가하기 위한 가상 항목 */ import { useEffect, useState, useRef } from 'react'; - -/** - * 게시글 본문(`article`) 내 h1~h3 요소에서 heading 목록을 추출한다. - * @returns {{ id: string, text: string, level: number }[]} - */ -const extractHeadingsFromDOM = () => { - const headings = []; - const headingElements = document.querySelectorAll('article h1, article h2, article h3'); - - headingElements.forEach((element) => { - if (element.id) { - headings.push({ - id: element.id, - text: element.textContent || element.innerText, - level: parseInt(element.tagName[1], 10) - }); - } - }); - - return headings; -}; +import { getPostContentHeadings } from '../../utils/html'; /** * 평탄한 heading 배열을 부모-자식 중첩 트리로 변환한다. @@ -172,34 +152,13 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => { const observerRef = useRef(null); // MutationObserver로 article DOM 변경을 감지하고 heading을 재추출한다. - // dangerouslySetInnerHTML 반영 후 DOM이 업데이트될 때까지 150ms 딜레이를 둔다. + // heading id는 렌더 후 주입되므로 attribute 변경도 감지한다. useEffect(() => { - // article 요소 찾기 - const article = document.querySelector('article'); - if (!article) return; + const contentRoot = document.querySelector('article .post-content'); + if (!contentRoot) return; - // 초기 추출 (약간의 딜레이 포함) - const initialTimer = setTimeout(() => { - const flatHeadings = extractHeadingsFromDOM(); - - // 게시글 제목을 첫 번째 항목으로 추가 - if (postTitle) { - flatHeadings.unshift({ - id: 'post-title', - text: postTitle, - level: 1 - }); - } - - if (flatHeadings.length > 0) { - const nested = buildNestedHeadings(flatHeadings); - setHeadings(nested); - } - }, 150); - - // MutationObserver 설정 - article 내용 변경 감지 - const handleMutation = () => { - const flatHeadings = extractHeadingsFromDOM(); + const updateHeadings = () => { + const flatHeadings = getPostContentHeadings(contentRoot); // 게시글 제목을 첫 번째 항목으로 추가 if (postTitle) { @@ -213,18 +172,23 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => { if (flatHeadings.length > 0) { const nested = buildNestedHeadings(flatHeadings); setHeadings(nested); + } else { + setHeadings([]); } }; - const observer = new MutationObserver(handleMutation); - observer.observe(article, { + updateHeadings(); + + const observer = new MutationObserver(updateHeadings); + observer.observe(contentRoot, { + attributes: true, + attributeFilter: ['id'], childList: true, subtree: true, characterData: false }); return () => { - clearTimeout(initialTimer); observer.disconnect(); }; }, [contentMarkdown, postTitle]); @@ -241,7 +205,7 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => { } // 마크다운 렌더링된 제목 요소들 수집 - const headingElements = document.querySelectorAll('article h1, article h2, article h3'); + const headingElements = document.querySelectorAll('article .post-content h1, article .post-content h2, article .post-content h3'); if (headingElements.length === 0) return; @@ -277,7 +241,7 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => { observerRef.current = observer; const handleScroll = () => { - const headingElements = document.querySelectorAll('article h1, article h2, article h3'); + const headingElements = document.querySelectorAll('article .post-content h1, article .post-content h2, article .post-content h3'); if (headingElements.length === 0) return; const scrollPosition = window.scrollY + window.innerHeight; diff --git a/Elevate.Web/src/pages/PostDetail.jsx b/Elevate.Web/src/pages/PostDetail.jsx index 764bc405..3ef9a245 100644 --- a/Elevate.Web/src/pages/PostDetail.jsx +++ b/Elevate.Web/src/pages/PostDetail.jsx @@ -12,7 +12,7 @@ */ import { useParams, Link, useNavigate } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import GlassDocLayout from '../components/layout/GlassDocLayout'; import TableOfContents from '../components/posts/TableOfContents'; import SeriesNavigator from '../components/posts/SeriesNavigator'; @@ -122,7 +122,7 @@ const PostDetail = ({ categoryProp, useLatest = false }) => { // HTML 콘텐츠 렌더링 후 heading ID 주입(TableOfContents용) + 링크 핸들러 주입(SPA 이동/외부 링크) // + data-collapsible="true" 코드 블록에 접이식 토글 버튼 주입 - useEffect(() => { + useLayoutEffect(() => { if (!contentRef.current || !post?.contentMarkdown) return; optimizeEmbeddedMedia(contentRef.current); injectHeadingIds(contentRef.current); diff --git a/Elevate.Web/src/utils/html.js b/Elevate.Web/src/utils/html.js index e3963d63..371f5621 100644 --- a/Elevate.Web/src/utils/html.js +++ b/Elevate.Web/src/utils/html.js @@ -61,6 +61,27 @@ export function injectHeadingIds(containerEl) { }); } +/** + * 게시글 본문 컨테이너 내 h1~h3 요소에서 목차 항목을 추출한다. + * + * PostDetail이 렌더 후 heading id를 주입하므로, 목차 컴포넌트는 전역 article 대신 + * 실제 본문 컨테이너를 기준으로 이 함수를 호출한다. + * + * @param {Element} containerEl - 게시글 본문 DOM 컨테이너 + * @returns {{ id: string, text: string, level: number }[]} + */ +export function getPostContentHeadings(containerEl) { + if (!containerEl) return []; + + return Array.from(containerEl.querySelectorAll('h1, h2, h3')) + .filter((element) => element.id) + .map((element) => ({ + id: element.id, + text: element.textContent || element.innerText || '', + level: parseInt(element.tagName[1], 10), + })); +} + /** * 렌더링된 HTML 내 `` 링크 동작을 복원한다. * @@ -195,8 +216,13 @@ export function injectCollapsibleCodeBlocks(containerEl) { // data-collapsible-injected 속성으로 중복 주입 방지 (idempotency) containerEl.querySelectorAll('pre:not([data-collapsible-injected])').forEach((pre) => { - const code = pre.querySelector('code'); - if (!code) return; + let code = pre.querySelector('code'); + if (!code) { + code = document.createElement('code'); + code.textContent = pre.textContent || ''; + pre.textContent = ''; + pre.appendChild(code); + } const lines = code.textContent.replace(/\n$/, '').split('\n'); const isCollapsible = lines.length >= COLLAPSE_THRESHOLD && pre.getAttribute('data-collapsible') !== 'false'; From 90b0d737272aff6b3712e245816a9dc1d7c1ba9c Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Tue, 16 Jun 2026 12:50:30 +0900 Subject: [PATCH 2/5] docs: update Elevate Web README links --- Elevate.Web/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Elevate.Web/README.md b/Elevate.Web/README.md index 8955fa27..5f402db5 100644 --- a/Elevate.Web/README.md +++ b/Elevate.Web/README.md @@ -1,7 +1,7 @@ # Elevate Web -Elevate 공개 블로그 SPA입니다. React + Vite 기반으로 빌드되며, Azure Static Web Apps(`swa-elv-web-test`)에 배포됩니다. +Elevate 공개 블로그 SPA입니다. React + Vite 기반으로 빌드되며, 운영 환경은 GitHub Pages(`https://microsoft-elevate.com`)에 배포됩니다. Azure Static Web Apps(`swa-elv-web-test`)는 테스트/보조 환경입니다. ## 빠른 시작 @@ -22,12 +22,10 @@ npm run build | 문서 | 위치 | |------|------| +| 인수인계 시작점 | [documents/00-handover/README.md](../documents/00-handover/README.md) | | 프로젝트 전체 계획 | [documents/01-getting-started/PLAN.md](../documents/01-getting-started/PLAN.md) | -| Elevate.Web 아키텍처 | [documents/04-application/ELEVATE_WEB_ARCHITECTURE.md](../documents/04-application/ELEVATE_WEB_ARCHITECTURE.md) | -| 컴포넌트 가이드 | [documents/04-application/ELEVATE_WEB_COMPONENTS.md](../documents/04-application/ELEVATE_WEB_COMPONENTS.md) | -| API 계약 | [documents/04-application/DATA_MODEL_AND_API_CONTRACT.md](../documents/04-application/DATA_MODEL_AND_API_CONTRACT.md) | -| 게시글 관리 가이드 | [documents/04-application/POSTS_GUIDE.md](../documents/04-application/POSTS_GUIDE.md) | -| Microsoft Clarity 가이드 | [documents/04-application/CLARITY_INTEGRATION_GUIDE.md](../documents/04-application/CLARITY_INTEGRATION_GUIDE.md) | +| Elevate.Web 아키텍처 | [documents/Elevate.Web/ARCHITECTURE.md](../documents/Elevate.Web/ARCHITECTURE.md) | +| Elevate.Admin 아키텍처 | [documents/Elevate.Admin/ARCHITECTURE.md](../documents/Elevate.Admin/ARCHITECTURE.md) | +| Elevate.Server 아키텍처 | [documents/Elevate.Server/ARCHITECTURE.md](../documents/Elevate.Server/ARCHITECTURE.md) | | 배포 및 운영 | [documents/06-operations/DEPLOYMENT_AND_RUNBOOK.md](../documents/06-operations/DEPLOYMENT_AND_RUNBOOK.md) | - - +| Microsoft Clarity 가이드 | [documents/04-application/CLARITY_INTEGRATION_GUIDE.md](../documents/04-application/CLARITY_INTEGRATION_GUIDE.md) | From 8c35bc5ca1c1830211f79762bfe2a191ab02a54a Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Wed, 17 Jun 2026 13:26:36 +0900 Subject: [PATCH 3/5] fix: support additional admin attachment formats --- .../src/components/editor/AttachUploader.jsx | 22 ++++++++--- .../src/controllers/adminController.js | 15 +++++--- .../tests/admin-attachments.test.js | 38 +++++++++++++++++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/Elevate.Admin/src/components/editor/AttachUploader.jsx b/Elevate.Admin/src/components/editor/AttachUploader.jsx index f0c71316..27b61ef2 100644 --- a/Elevate.Admin/src/components/editor/AttachUploader.jsx +++ b/Elevate.Admin/src/components/editor/AttachUploader.jsx @@ -17,8 +17,13 @@ const ATTACH_MIME_MAP = { '.zip': 'application/zip', '.xls': 'application/vnd.ms-excel', '.doc': 'application/msword', + '.ppt': 'application/vnd.ms-powerpoint', + '.hwp': 'application/x-hwp', + '.hwpx': 'application/vnd.hancom.hwpx', } +const ATTACH_EXTENSIONS = Object.keys(ATTACH_MIME_MAP) + /** 첨부파일 최대 크기: 50MB */ const MAX_ATTACH_BYTES = 50 * 1024 * 1024 @@ -27,6 +32,13 @@ function getContentType(file) { return ATTACH_MIME_MAP[ext] || file.type || 'application/octet-stream' } +function getUploadErrorMessage(error) { + const message = String(error?.message || '').trim() + if (!message) return '업로드에 실패했습니다.' + const trimmed = message.length > 180 ? `${message.slice(0, 180)}...` : message + return `업로드에 실패했습니다. (${trimmed})` +} + export default function AttachUploader({ postId, draftSessionId, onUploadingChange }) { const { msalInstance } = useAuth() const inputRef = useRef(null) @@ -76,7 +88,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan const contentType = getContentType(file) if (!Object.values(ATTACH_MIME_MAP).includes(contentType)) { - setError('지원하지 않는 파일 형식입니다. (.docx .xlsx .pptx .pdf .csv .zip .xls .doc)') + setError(`지원하지 않는 파일 형식입니다. (${ATTACH_EXTENSIONS.join(' ')})`) return } @@ -104,8 +116,8 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan const signedUrl = result?.signedUrl || null setFiles(prev => [...prev, { id: result.fileId, fileName: uploadFile.name, blobUrl, signedUrl, isDeleting: false }]) setStatus('done') - } catch { - setError('업로드에 실패했습니다.') + } catch (err) { + setError(getUploadErrorMessage(err)) setStatus('error') } finally { onUploadingChange?.(false) @@ -139,7 +151,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan @@ -154,7 +166,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan {canUpload - ? 'docx · xlsx · pptx · pdf · csv · zip · xls · doc (최대 50MB)' + ? `${ATTACH_EXTENSIONS.map((ext) => ext.slice(1)).join(' · ')} (최대 50MB)` : '첨부파일 업로드 준비 중입니다'} diff --git a/Elevate.Server/src/controllers/adminController.js b/Elevate.Server/src/controllers/adminController.js index 06e9084d..4caa3791 100644 --- a/Elevate.Server/src/controllers/adminController.js +++ b/Elevate.Server/src/controllers/adminController.js @@ -23,11 +23,16 @@ const allowedAttachMimeTypes = new Set([ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/pdf', - 'text/csv', - 'application/zip', - 'application/vnd.ms-excel', - 'application/msword' -]); + 'text/csv', + 'application/zip', + 'application/vnd.ms-excel', + 'application/msword', + 'application/vnd.ms-powerpoint', + 'application/x-hwp', + 'application/haansofthwp', + 'application/vnd.hancom.hwp', + 'application/vnd.hancom.hwpx' +]); const maxAttachSizeBytes = 50 * 1024 * 1024; const attachCategoryPartition = '_attach'; const draftAttachTtlMs = 24 * 60 * 60 * 1000; diff --git a/Elevate.Server/tests/admin-attachments.test.js b/Elevate.Server/tests/admin-attachments.test.js index 486aca93..fa163820 100644 --- a/Elevate.Server/tests/admin-attachments.test.js +++ b/Elevate.Server/tests/admin-attachments.test.js @@ -251,6 +251,44 @@ test('createFileMetadata stores trimmed postId and clears draftSessionId for sav assert.equal(createdFileDocument.draftSessionId, null); }); +test('createFileMetadata accepts PowerPoint and Hangul attachment formats', async () => { + const cases = [ + { + blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/slides.ppt', + contentType: 'application/vnd.ms-powerpoint', + fileName: 'slides.ppt' + }, + { + blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/report.hwp', + contentType: 'application/x-hwp', + fileName: 'report.hwp' + }, + { + blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/report.hwpx', + contentType: 'application/vnd.hancom.hwpx', + fileName: 'report.hwpx' + } + ]; + + for (const attachment of cases) { + createdFileDocument = null; + const res = makeRes(); + + await createFileMetadata({ + body: { + postId: 'post-1', + ...attachment, + sizeBytes: 1234 + }, + correlationId: 'x' + }, res); + + assert.equal(res.getStatus(), 201); + assert.equal(createdFileDocument.contentType, attachment.contentType); + assert.equal(createdFileDocument.fileName, attachment.fileName); + } +}); + test('createFileMetadata stores expiry and removes stale draft attachments', async () => { createdFileDocument = null; mockExpiredDraftAttachmentResources = [ From 69dc9fb98fc32d8621f4fee474e508954b908d4e Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Wed, 17 Jun 2026 17:25:27 +0900 Subject: [PATCH 4/5] fix: omit ttl from persisted attachments --- Elevate.Server/src/controllers/adminController.js | 10 ++++++---- Elevate.Server/tests/admin-attachments.test.js | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Elevate.Server/src/controllers/adminController.js b/Elevate.Server/src/controllers/adminController.js index 4caa3791..d521ede4 100644 --- a/Elevate.Server/src/controllers/adminController.js +++ b/Elevate.Server/src/controllers/adminController.js @@ -905,11 +905,13 @@ exports.createFileMetadata = async (req, res) => { contentType, sizeBytes, fileName: trimmedFileName, - expiresAt: isDraftAttachment ? getDraftAttachmentExpiresAt(new Date(now)) : null, - ttl: isDraftAttachment ? Math.floor(draftAttachTtlMs / 1000) : null, createdAt: now, updatedAt: now }; + if (isDraftAttachment) { + fileDocument.expiresAt = getDraftAttachmentExpiresAt(new Date(now)); + fileDocument.ttl = Math.floor(draftAttachTtlMs / 1000); + } await container.items.create(fileDocument); @@ -956,10 +958,10 @@ exports.linkDraftAttachmentsToPost = async (req, res) => { ...file, postId: normalizedPostId, draftSessionId: null, - expiresAt: null, - ttl: null, updatedAt: new Date().toISOString() }; + delete updated.expiresAt; + delete updated.ttl; await container.item(file.id, file.category || file.partitionKey || attachCategoryPartition).replace(updated); } diff --git a/Elevate.Server/tests/admin-attachments.test.js b/Elevate.Server/tests/admin-attachments.test.js index fa163820..2ac65cac 100644 --- a/Elevate.Server/tests/admin-attachments.test.js +++ b/Elevate.Server/tests/admin-attachments.test.js @@ -249,6 +249,8 @@ test('createFileMetadata stores trimmed postId and clears draftSessionId for sav assert.equal(res.getStatus(), 201); assert.equal(createdFileDocument.postId, 'post-1'); assert.equal(createdFileDocument.draftSessionId, null); + assert.equal(Object.hasOwn(createdFileDocument, 'ttl'), false); + assert.equal(Object.hasOwn(createdFileDocument, 'expiresAt'), false); }); test('createFileMetadata accepts PowerPoint and Hangul attachment formats', async () => { @@ -438,11 +440,12 @@ test('linkDraftAttachmentsToPost links draft attachments and clears draftSession id: doc.id, postId: doc.postId, draftSessionId: doc.draftSessionId, - ttl: doc.ttl + hasTtl: Object.hasOwn(doc, 'ttl'), + hasExpiresAt: Object.hasOwn(doc, 'expiresAt') })), [ - { id: 'file-1', postId: 'post-1', draftSessionId: null, ttl: null }, - { id: 'file-2', postId: 'post-1', draftSessionId: null, ttl: null } + { id: 'file-1', postId: 'post-1', draftSessionId: null, hasTtl: false, hasExpiresAt: false }, + { id: 'file-2', postId: 'post-1', draftSessionId: null, hasTtl: false, hasExpiresAt: false } ] ); assert.match(replacedFileDocuments[0].doc.updatedAt, /^\d{4}-\d{2}-\d{2}T/); From 2057fb23ed84da36f1ab79a1424208471d0a86db Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Fri, 19 Jun 2026 13:18:54 +0900 Subject: [PATCH 5/5] Fix legacy post table of contents --- Elevate.Web/src/pages/PostDetail.jsx | 28 ++++++++++++++++------------ Elevate.Web/src/utils/html.js | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Elevate.Web/src/pages/PostDetail.jsx b/Elevate.Web/src/pages/PostDetail.jsx index 3ef9a245..851941eb 100644 --- a/Elevate.Web/src/pages/PostDetail.jsx +++ b/Elevate.Web/src/pages/PostDetail.jsx @@ -12,12 +12,12 @@ */ import { useParams, Link, useNavigate } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import GlassDocLayout from '../components/layout/GlassDocLayout'; import TableOfContents from '../components/posts/TableOfContents'; import SeriesNavigator from '../components/posts/SeriesNavigator'; import { getPost, getLatestAgenthonPost } from '../api/posts'; -import { sanitizeHtml, injectHeadingIds, injectLinkHandlers, injectCollapsibleCodeBlocks, optimizeEmbeddedMedia } from '../utils/html'; +import { preparePostHtml, injectHeadingIds, injectLinkHandlers, injectCollapsibleCodeBlocks, optimizeEmbeddedMedia } from '../utils/html'; import { formatDateKo } from '../utils/url'; import { POST_DETAIL_VALID_CATEGORIES, CATEGORY_DISPLAY_NAMES, getCategoryListRoute } from '../constants/categories'; import { DEFAULT_OG_IMAGE, SITE_NAME, canonicalUrl } from '../constants/seo'; @@ -91,7 +91,11 @@ const PostDetail = ({ categoryProp, useLatest = false }) => { if (!useLatest) setResolvedPostId(postIdParam ?? null); }, [postIdParam, useLatest]); - const postId = resolvedPostId; + const postId = resolvedPostId; + const preparedContentHtml = useMemo( + () => preparePostHtml(post?.contentMarkdown || ''), + [post?.contentMarkdown] + ); useEffect(() => { if (!normalizedCategory || !postId) return; @@ -173,10 +177,10 @@ const PostDetail = ({ categoryProp, useLatest = false }) => { { label: postTitle }, ]; - // 게시글 로드 완료 시 좌측 TOC를 표시한다. - const leftAside = !loading && !loadingLatest && post - ? - : null; + // 게시글 로드 완료 시 좌측 TOC를 표시한다. + const leftAside = !loading && !loadingLatest && post + ? + : null; // 시리즈가 있을 때만 우측 SeriesNavigator를 표시한다. const rightAside = !loading && !loadingLatest && post && hasSeriesNavigator @@ -316,11 +320,11 @@ const PostDetail = ({ categoryProp, useLatest = false }) => { {/* Post Body */}
-
+
)} diff --git a/Elevate.Web/src/utils/html.js b/Elevate.Web/src/utils/html.js index 371f5621..3673161e 100644 --- a/Elevate.Web/src/utils/html.js +++ b/Elevate.Web/src/utils/html.js @@ -24,6 +24,26 @@ export function sanitizeHtml(html) { }); } +/** + * 게시글 HTML을 렌더링 전에 소독하고 h1~h6 id를 주입한다. + * + * 렌더 후 DOM mutation에만 의존하면 React 재렌더나 effect 순서에 따라 기존 문서의 + * heading id가 비어 있을 수 있다. 렌더 전에 HTML 문자열 자체를 안정화해 TOC와 + * 실제 본문 앵커가 같은 id를 보게 한다. + * + * @param {string} html - API에서 받은 게시글 HTML 문자열 + * @returns {string} sanitize + heading id 주입이 끝난 HTML 문자열 + */ +export function preparePostHtml(html) { + const sanitized = sanitizeHtml(html); + if (!sanitized || typeof document === 'undefined') return sanitized; + + const template = document.createElement('template'); + template.innerHTML = sanitized; + injectHeadingIds(template.content); + return template.innerHTML; +} + /** * 컨테이너 내 모든 heading(h1~h6)에 id를 주입한다. *