diff --git a/app.vue b/app.vue
index 562ec73..afc3a12 100644
--- a/app.vue
+++ b/app.vue
@@ -1,16 +1,7 @@
diff --git a/components/site/RightSidebar.vue b/components/site/RightSidebar.vue
index b9b8285..b9adc6a 100644
--- a/components/site/RightSidebar.vue
+++ b/components/site/RightSidebar.vue
@@ -7,18 +7,7 @@ const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({
- title: 'sori.studio',
- description: 'sori.studio 개인 블로그',
- logoText: '井',
- logoUrl: '',
- socialLinks: [],
- adSidebarCode: '',
- adPostSidebarCode: '',
- copyrightText: '©2026 sori.studio'
- })
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const sidebarAdCode = computed(() => isPostDetailRoute.value ? siteSettings.value?.adPostSidebarCode : siteSettings.value?.adSidebarCode)
diff --git a/components/site/SiteAnnouncementBar.vue b/components/site/SiteAnnouncementBar.vue
index 108bd34..825175f 100644
--- a/components/site/SiteAnnouncementBar.vue
+++ b/components/site/SiteAnnouncementBar.vue
@@ -11,16 +11,7 @@ import {
/** @type {number} 슬라이드 애니메이션 시간(ms) */
const SLIDE_MS = 320
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({
- announcementEnabled: false,
- announcementText: '',
- announcementUrl: '',
- announcementBackgroundColor: '#15171a',
- announcementAlignment: 'center',
- updatedAt: null
- })
-})
+const { data: siteSettings } = await usePublicSiteSettings()
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
const inDom = ref(false)
diff --git a/components/site/SiteHeader.vue b/components/site/SiteHeader.vue
index 7eb1f77..be5c8b8 100644
--- a/components/site/SiteHeader.vue
+++ b/components/site/SiteHeader.vue
@@ -6,11 +6,7 @@ const userMenuToggleRef = ref(null)
const searchOpen = ref(false)
const member = ref(null)
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({
- title: 'sori.studio'
- })
-})
+const { data: siteSettings } = await usePublicSiteSettings()
/**
* 사용자 메뉴를 닫는다.
diff --git a/composables/usePublicSiteSettings.js b/composables/usePublicSiteSettings.js
new file mode 100644
index 0000000..66e425a
--- /dev/null
+++ b/composables/usePublicSiteSettings.js
@@ -0,0 +1,34 @@
+import { DEFAULT_BRAND_COLOR } from '~/lib/brand-color.js'
+
+/**
+ * 공개 사이트 설정 기본값을 만든다.
+ * @returns {Object} 공개 사이트 설정 기본값
+ */
+export const createDefaultPublicSiteSettings = () => ({
+ title: 'sori.studio',
+ description: 'sori.studio 개인 블로그',
+ faviconUrl: '',
+ logoUrl: '',
+ logoText: '井',
+ brandColor: DEFAULT_BRAND_COLOR,
+ socialLinks: [],
+ adSidebarCode: '',
+ adPostSidebarCode: '',
+ copyrightText: '©2026 sori.studio',
+ announcementEnabled: false,
+ announcementText: '',
+ announcementUrl: '',
+ announcementBackgroundColor: '#15171a',
+ announcementAlignment: 'center',
+ updatedAt: null,
+ signupBlockedUsernames: []
+})
+
+/**
+ * 공개 사이트 설정을 공통 key와 옵션으로 조회한다.
+ * @returns {Promise>} 사이트 설정 async data
+ */
+export const usePublicSiteSettings = () => useFetch('/api/site-settings', {
+ key: 'site-settings-public',
+ default: createDefaultPublicSiteSettings
+})
diff --git a/docs/map.md b/docs/map.md
index a613548..8da5604 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -26,6 +26,7 @@
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
+| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
## 공유 라이브러리(서버·클라이언트 공통)
@@ -264,7 +265,7 @@
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | 상단 외부 여백 없이 하단 여백만 두는 YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 제한된 사용자 iframe HTML, 기타 `http(s)` 외부 링크 |
-| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 허용 목록 sanitizer 적용 |
+| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 허용 목록 sanitizer 적용, 서버 전용 `sanitize-html`과 브라우저 DOM sanitizer 분리 |
## 관리자 페이지
diff --git a/docs/spec.md b/docs/spec.md
index 628616c..7eb723f 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -898,6 +898,7 @@ components/content/
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. 오른쪽 사이드바 로고는 48px 정사각형으로 고정해 옆 텍스트에 밀려 줄어들지 않게 한다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
+- 공개 앱·헤더·오른쪽 사이드바·어나운스 바·홈·목록·태그·상세·회원가입 화면의 공개 사이트 설정 조회는 `usePublicSiteSettings` composable의 `site-settings-public` key와 공통 기본값을 공유한다.
### 메뉴/네비게이션
diff --git a/docs/update.md b/docs/update.md
index a83aaac..fae4c5b 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.11.57
+
+- 공개 사이트 설정 조회를 `usePublicSiteSettings` composable로 통합해 Nuxt `site-settings-public` async data 옵션 충돌 경고를 제거.
+- HTML 블록 렌더러의 `sanitize-html` 정적 import를 서버 전용 동적 import로 바꿔 브라우저 번들 유입을 차단.
+- `/posts?page=2` SSR 응답과 게시물 페이지 API 응답, 프로덕션 빌드에서 페이지네이션 회귀를 확인.
+
## v1.11.56
- 공개 Markdown 렌더러의 라이트박스 이미지 목록과 갤러리 행 분할 계산을 `lib/content-gallery-images.js`로 분리.
diff --git a/package-lock.json b/package-lock.json
index 2a979f9..45e2231 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.11.56",
+ "version": "1.11.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.11.56",
+ "version": "1.11.57",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index 0bf1948..62452e1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.11.56",
+ "version": "1.11.57",
"private": true,
"type": "module",
"imports": {
diff --git a/pages/index.vue b/pages/index.vue
index fb26116..f2942a1 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -6,10 +6,7 @@ import {
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- key: 'site-settings-public',
- default: () => ({})
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const homeLatestPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT))
const featuredPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT))
diff --git a/pages/post/[slug].vue b/pages/post/[slug].vue
index 517b623..d803c97 100644
--- a/pages/post/[slug].vue
+++ b/pages/post/[slug].vue
@@ -16,10 +16,7 @@ const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
const { data: tags } = useLazyFetch('/api/tags?tagType=all', {
default: () => []
})
-const { data: siteSettings } = useLazyFetch('/api/site-settings', {
- key: 'site-settings-public',
- default: () => ({})
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const postToc = useState('post-detail-toc', () => [])
if (!post.value) {
diff --git a/pages/posts/index.vue b/pages/posts/index.vue
index f6a4e9a..924958d 100644
--- a/pages/posts/index.vue
+++ b/pages/posts/index.vue
@@ -7,9 +7,7 @@ import {
const route = useRoute()
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({})
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []
diff --git a/pages/signup.vue b/pages/signup.vue
index d35d7ce..a015da7 100644
--- a/pages/signup.vue
+++ b/pages/signup.vue
@@ -14,13 +14,7 @@ const signupCompleted = ref(false)
const createdAdmin = ref(false)
const statusMessage = ref('')
const submitErrorMessage = ref('')
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({
- title: 'AFFiNE',
- description: 'Configure your Self Host AFFiNE with a few simple settings.',
- signupBlockedUsernames: []
- })
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
diff --git a/pages/tag/[slug].vue b/pages/tag/[slug].vue
index 234838e..feaa575 100644
--- a/pages/tag/[slug].vue
+++ b/pages/tag/[slug].vue
@@ -28,9 +28,7 @@ if (!tag.value) {
})
}
-const { data: siteSettings } = await useFetch('/api/site-settings', {
- default: () => ({})
-})
+const { data: siteSettings } = await usePublicSiteSettings()
const currentPage = computed(() => normalizeListPage(route.query.page))
const tagPostListPageSize = computed(() => normalizeDisplayCount(siteSettings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE))