@mandujs/core 0.9.39 → 0.9.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +27 -0
- package/README.md +21 -5
- package/package.json +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/mandu.ts +60 -0
- package/src/contract/client-safe.test.ts +42 -0
- package/src/contract/client-safe.ts +114 -0
- package/src/contract/client.ts +12 -11
- package/src/contract/handler.ts +10 -11
- package/src/contract/index.ts +25 -16
- package/src/contract/registry.test.ts +206 -0
- package/src/contract/registry.ts +568 -0
- package/src/contract/schema.ts +48 -12
- package/src/contract/types.ts +58 -35
- package/src/contract/validator.ts +32 -17
- package/src/filling/context.ts +103 -0
- package/src/generator/templates.ts +70 -17
- package/src/guard/analyzer.ts +9 -4
- package/src/guard/check.ts +66 -30
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -0
- package/src/guard/presets/index.ts +193 -60
- package/src/guard/rules.ts +12 -6
- package/src/guard/statistics.ts +6 -0
- package/src/guard/suggestions.ts +9 -2
- package/src/guard/types.ts +11 -1
- package/src/guard/validator.ts +160 -9
- package/src/guard/watcher.ts +2 -0
- package/src/index.ts +8 -1
- package/src/runtime/index.ts +1 -0
- package/src/runtime/streaming-ssr.ts +123 -2
- package/src/seo/index.ts +214 -0
- package/src/seo/integration/ssr.ts +307 -0
- package/src/seo/render/basic.ts +427 -0
- package/src/seo/render/index.ts +143 -0
- package/src/seo/render/jsonld.ts +539 -0
- package/src/seo/render/opengraph.ts +191 -0
- package/src/seo/render/robots.ts +116 -0
- package/src/seo/render/sitemap.ts +137 -0
- package/src/seo/render/twitter.ts +126 -0
- package/src/seo/resolve/index.ts +353 -0
- package/src/seo/resolve/opengraph.ts +143 -0
- package/src/seo/resolve/robots.ts +73 -0
- package/src/seo/resolve/title.ts +94 -0
- package/src/seo/resolve/twitter.ts +73 -0
- package/src/seo/resolve/url.ts +97 -0
- package/src/seo/routes/index.ts +290 -0
- package/src/seo/types.ts +575 -0
- package/src/slot/validator.ts +39 -16
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Metadata Resolution
|
|
3
|
+
*
|
|
4
|
+
* Layout 체인에서 메타데이터를 수집하고 병합
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Metadata,
|
|
9
|
+
ResolvedMetadata,
|
|
10
|
+
MetadataItem,
|
|
11
|
+
MetadataParams,
|
|
12
|
+
Author,
|
|
13
|
+
Icons,
|
|
14
|
+
IconDescriptor,
|
|
15
|
+
ResolvedIcons,
|
|
16
|
+
Verification,
|
|
17
|
+
ResolvedVerification,
|
|
18
|
+
JsonLd,
|
|
19
|
+
ThemeColor,
|
|
20
|
+
ResourceHint,
|
|
21
|
+
FormatDetection,
|
|
22
|
+
GoogleMeta,
|
|
23
|
+
AppLinks,
|
|
24
|
+
} from '../types'
|
|
25
|
+
import { resolveTitle, extractTitleTemplate } from './title'
|
|
26
|
+
import { normalizeMetadataBase, resolveUrl } from './url'
|
|
27
|
+
import { resolveRobots } from './robots'
|
|
28
|
+
import { resolveOpenGraph } from './opengraph'
|
|
29
|
+
import { resolveTwitter } from './twitter'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 기본 메타데이터 생성
|
|
33
|
+
*/
|
|
34
|
+
export function createDefaultMetadata(): ResolvedMetadata {
|
|
35
|
+
return {
|
|
36
|
+
metadataBase: null,
|
|
37
|
+
title: null,
|
|
38
|
+
description: null,
|
|
39
|
+
applicationName: null,
|
|
40
|
+
authors: null,
|
|
41
|
+
generator: 'Mandu',
|
|
42
|
+
keywords: null,
|
|
43
|
+
referrer: null,
|
|
44
|
+
creator: null,
|
|
45
|
+
publisher: null,
|
|
46
|
+
robots: null,
|
|
47
|
+
alternates: null,
|
|
48
|
+
icons: null,
|
|
49
|
+
manifest: null,
|
|
50
|
+
openGraph: null,
|
|
51
|
+
twitter: null,
|
|
52
|
+
verification: null,
|
|
53
|
+
category: null,
|
|
54
|
+
classification: null,
|
|
55
|
+
jsonLd: null,
|
|
56
|
+
// Google SEO 최적화
|
|
57
|
+
google: null,
|
|
58
|
+
formatDetection: null,
|
|
59
|
+
resourceHints: null,
|
|
60
|
+
themeColor: null,
|
|
61
|
+
viewport: null,
|
|
62
|
+
appLinks: null,
|
|
63
|
+
other: null,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Author 배열로 정규화
|
|
69
|
+
*/
|
|
70
|
+
function resolveAuthors(authors: Author | Author[] | null | undefined): Author[] | null {
|
|
71
|
+
if (!authors) return null
|
|
72
|
+
return Array.isArray(authors) ? authors : [authors]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keywords 배열로 정규화
|
|
77
|
+
*/
|
|
78
|
+
function resolveKeywords(keywords: string | string[] | null | undefined): string[] | null {
|
|
79
|
+
if (!keywords) return null
|
|
80
|
+
if (typeof keywords === 'string') {
|
|
81
|
+
return keywords.split(',').map(k => k.trim())
|
|
82
|
+
}
|
|
83
|
+
return keywords
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Icons 해석
|
|
88
|
+
*/
|
|
89
|
+
function resolveIcons(icons: Metadata['icons'], metadataBase: URL | null): ResolvedIcons | null {
|
|
90
|
+
if (!icons) return null
|
|
91
|
+
|
|
92
|
+
const result: ResolvedIcons = {
|
|
93
|
+
icon: [],
|
|
94
|
+
apple: [],
|
|
95
|
+
shortcut: [],
|
|
96
|
+
other: [],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 단순 문자열 URL
|
|
100
|
+
if (typeof icons === 'string') {
|
|
101
|
+
const url = resolveUrl(icons, metadataBase)
|
|
102
|
+
if (url) {
|
|
103
|
+
result.icon.push({ url: url.href })
|
|
104
|
+
}
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 배열
|
|
109
|
+
if (Array.isArray(icons)) {
|
|
110
|
+
for (const icon of icons) {
|
|
111
|
+
const descriptor = typeof icon === 'string'
|
|
112
|
+
? { url: icon }
|
|
113
|
+
: icon instanceof URL
|
|
114
|
+
? { url: icon.href }
|
|
115
|
+
: { ...icon, url: typeof icon.url === 'string' ? icon.url : icon.url.href }
|
|
116
|
+
|
|
117
|
+
const resolvedUrl = resolveUrl(descriptor.url, metadataBase)
|
|
118
|
+
if (resolvedUrl) {
|
|
119
|
+
result.icon.push({ ...descriptor, url: resolvedUrl.href })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Icons 객체
|
|
126
|
+
const iconsObj = icons as Icons
|
|
127
|
+
|
|
128
|
+
const processIconArray = (
|
|
129
|
+
items: typeof iconsObj.icon,
|
|
130
|
+
target: IconDescriptor[]
|
|
131
|
+
) => {
|
|
132
|
+
if (!items) return
|
|
133
|
+
const arr = Array.isArray(items) ? items : [items]
|
|
134
|
+
for (const item of arr) {
|
|
135
|
+
const descriptor = typeof item === 'string'
|
|
136
|
+
? { url: item }
|
|
137
|
+
: item instanceof URL
|
|
138
|
+
? { url: item.href }
|
|
139
|
+
: { ...item, url: typeof item.url === 'string' ? item.url : item.url.href }
|
|
140
|
+
|
|
141
|
+
const resolvedUrl = resolveUrl(descriptor.url, metadataBase)
|
|
142
|
+
if (resolvedUrl) {
|
|
143
|
+
target.push({ ...descriptor, url: resolvedUrl.href })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
processIconArray(iconsObj.icon, result.icon)
|
|
149
|
+
processIconArray(iconsObj.apple, result.apple)
|
|
150
|
+
processIconArray(iconsObj.shortcut, result.shortcut)
|
|
151
|
+
|
|
152
|
+
if (iconsObj.other) {
|
|
153
|
+
const others = Array.isArray(iconsObj.other) ? iconsObj.other : [iconsObj.other]
|
|
154
|
+
for (const other of others) {
|
|
155
|
+
const resolvedUrl = resolveUrl(other.url, metadataBase)
|
|
156
|
+
if (resolvedUrl) {
|
|
157
|
+
result.other.push({ ...other, url: resolvedUrl.href })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Verification 해석
|
|
167
|
+
*/
|
|
168
|
+
function resolveVerification(verification: Verification | null | undefined): ResolvedVerification | null {
|
|
169
|
+
if (!verification) return null
|
|
170
|
+
|
|
171
|
+
const toArray = (val: string | string[] | undefined): string[] | null => {
|
|
172
|
+
if (!val) return null
|
|
173
|
+
return Array.isArray(val) ? val : [val]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
google: toArray(verification.google),
|
|
178
|
+
yahoo: toArray(verification.yahoo),
|
|
179
|
+
yandex: toArray(verification.yandex),
|
|
180
|
+
me: toArray(verification.me),
|
|
181
|
+
other: verification.other
|
|
182
|
+
? Object.fromEntries(
|
|
183
|
+
Object.entries(verification.other).map(([k, v]) => [k, toArray(v)!])
|
|
184
|
+
)
|
|
185
|
+
: null,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* JSON-LD 배열로 정규화
|
|
191
|
+
*/
|
|
192
|
+
function resolveJsonLd(jsonLd: JsonLd | JsonLd[] | null | undefined): JsonLd[] | null {
|
|
193
|
+
if (!jsonLd) return null
|
|
194
|
+
const arr = Array.isArray(jsonLd) ? jsonLd : [jsonLd]
|
|
195
|
+
// @context 기본값 추가
|
|
196
|
+
return arr.map(item => ({
|
|
197
|
+
'@context': 'https://schema.org',
|
|
198
|
+
...item,
|
|
199
|
+
}))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Theme Color 배열로 정규화
|
|
204
|
+
*/
|
|
205
|
+
function resolveThemeColor(
|
|
206
|
+
themeColor: string | ThemeColor | ThemeColor[] | null | undefined
|
|
207
|
+
): ThemeColor[] | null {
|
|
208
|
+
if (!themeColor) return null
|
|
209
|
+
if (typeof themeColor === 'string') {
|
|
210
|
+
return [{ color: themeColor }]
|
|
211
|
+
}
|
|
212
|
+
return Array.isArray(themeColor) ? themeColor : [themeColor]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Viewport 문자열로 정규화
|
|
217
|
+
*/
|
|
218
|
+
function resolveViewport(
|
|
219
|
+
viewport: Metadata['viewport']
|
|
220
|
+
): string | null {
|
|
221
|
+
if (!viewport) return null
|
|
222
|
+
if (typeof viewport === 'string') return viewport
|
|
223
|
+
|
|
224
|
+
const parts: string[] = []
|
|
225
|
+
|
|
226
|
+
if (viewport.width !== undefined) {
|
|
227
|
+
parts.push(`width=${viewport.width}`)
|
|
228
|
+
}
|
|
229
|
+
if (viewport.height !== undefined) {
|
|
230
|
+
parts.push(`height=${viewport.height}`)
|
|
231
|
+
}
|
|
232
|
+
if (viewport.initialScale !== undefined) {
|
|
233
|
+
parts.push(`initial-scale=${viewport.initialScale}`)
|
|
234
|
+
}
|
|
235
|
+
if (viewport.minimumScale !== undefined) {
|
|
236
|
+
parts.push(`minimum-scale=${viewport.minimumScale}`)
|
|
237
|
+
}
|
|
238
|
+
if (viewport.maximumScale !== undefined) {
|
|
239
|
+
parts.push(`maximum-scale=${viewport.maximumScale}`)
|
|
240
|
+
}
|
|
241
|
+
if (viewport.userScalable !== undefined) {
|
|
242
|
+
parts.push(`user-scalable=${viewport.userScalable ? 'yes' : 'no'}`)
|
|
243
|
+
}
|
|
244
|
+
if (viewport.viewportFit !== undefined) {
|
|
245
|
+
parts.push(`viewport-fit=${viewport.viewportFit}`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return parts.length > 0 ? parts.join(', ') : null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 두 메타데이터 객체 병합
|
|
253
|
+
*/
|
|
254
|
+
export function mergeMetadata(
|
|
255
|
+
parent: ResolvedMetadata,
|
|
256
|
+
child: Metadata
|
|
257
|
+
): ResolvedMetadata {
|
|
258
|
+
const metadataBase = normalizeMetadataBase(child.metadataBase) || parent.metadataBase
|
|
259
|
+
|
|
260
|
+
// 부모의 title template 추출
|
|
261
|
+
const parentTemplate = parent.title?.template || null
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
metadataBase,
|
|
265
|
+
title: resolveTitle(child.title, parentTemplate) ?? parent.title,
|
|
266
|
+
description: child.description ?? parent.description,
|
|
267
|
+
applicationName: child.applicationName ?? parent.applicationName,
|
|
268
|
+
authors: resolveAuthors(child.authors) ?? parent.authors,
|
|
269
|
+
generator: child.generator ?? parent.generator,
|
|
270
|
+
keywords: resolveKeywords(child.keywords) ?? parent.keywords,
|
|
271
|
+
referrer: child.referrer ?? parent.referrer,
|
|
272
|
+
creator: child.creator ?? parent.creator,
|
|
273
|
+
publisher: child.publisher ?? parent.publisher,
|
|
274
|
+
robots: resolveRobots(child.robots) ?? parent.robots,
|
|
275
|
+
alternates: parent.alternates, // TODO: resolve alternates
|
|
276
|
+
icons: resolveIcons(child.icons, metadataBase) ?? parent.icons,
|
|
277
|
+
manifest: resolveUrl(child.manifest, metadataBase) ?? parent.manifest,
|
|
278
|
+
openGraph: resolveOpenGraph(child.openGraph, metadataBase) ?? parent.openGraph,
|
|
279
|
+
twitter: resolveTwitter(child.twitter, metadataBase) ?? parent.twitter,
|
|
280
|
+
verification: resolveVerification(child.verification) ?? parent.verification,
|
|
281
|
+
category: child.category ?? parent.category,
|
|
282
|
+
classification: child.classification ?? parent.classification,
|
|
283
|
+
jsonLd: resolveJsonLd(child.jsonLd) ?? parent.jsonLd,
|
|
284
|
+
// Google SEO 최적화
|
|
285
|
+
google: child.google ?? parent.google,
|
|
286
|
+
formatDetection: child.formatDetection ?? parent.formatDetection,
|
|
287
|
+
resourceHints: child.resourceHints ?? parent.resourceHints,
|
|
288
|
+
themeColor: resolveThemeColor(child.themeColor) ?? parent.themeColor,
|
|
289
|
+
viewport: resolveViewport(child.viewport) ?? parent.viewport,
|
|
290
|
+
appLinks: child.appLinks ?? parent.appLinks,
|
|
291
|
+
other: child.other ?? parent.other,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 메타데이터 항목 해석 (정적/동적)
|
|
297
|
+
*/
|
|
298
|
+
async function resolveMetadataItem(
|
|
299
|
+
item: MetadataItem,
|
|
300
|
+
params: MetadataParams,
|
|
301
|
+
parentPromise: Promise<ResolvedMetadata>
|
|
302
|
+
): Promise<Metadata | null> {
|
|
303
|
+
if (!item) return null
|
|
304
|
+
|
|
305
|
+
// 정적 메타데이터
|
|
306
|
+
if (typeof item !== 'function') {
|
|
307
|
+
return item
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 동적 메타데이터 (generateMetadata)
|
|
311
|
+
return await item(params, parentPromise)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 전체 메타데이터 해석 파이프라인
|
|
316
|
+
*
|
|
317
|
+
* Layout 체인을 순회하며 메타데이터를 병합
|
|
318
|
+
*
|
|
319
|
+
* @param metadataItems - [rootLayout, ...nestedLayouts, page]
|
|
320
|
+
* @param params - URL 파라미터
|
|
321
|
+
* @param searchParams - 쿼리 파라미터
|
|
322
|
+
*/
|
|
323
|
+
export async function resolveMetadata(
|
|
324
|
+
metadataItems: MetadataItem[],
|
|
325
|
+
params: Record<string, string> = {},
|
|
326
|
+
searchParams: Record<string, string> = {}
|
|
327
|
+
): Promise<ResolvedMetadata> {
|
|
328
|
+
let resolved = createDefaultMetadata()
|
|
329
|
+
|
|
330
|
+
for (const item of metadataItems) {
|
|
331
|
+
// 현재 resolved를 Promise로 래핑 (generateMetadata의 parent 파라미터용)
|
|
332
|
+
const parentPromise = Promise.resolve(resolved)
|
|
333
|
+
|
|
334
|
+
const metadata = await resolveMetadataItem(
|
|
335
|
+
item,
|
|
336
|
+
{ params, searchParams },
|
|
337
|
+
parentPromise
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if (metadata) {
|
|
341
|
+
resolved = mergeMetadata(resolved, metadata)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return resolved
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Re-export sub-modules
|
|
349
|
+
export { resolveTitle, extractTitleTemplate } from './title'
|
|
350
|
+
export { normalizeMetadataBase, resolveUrl, urlToString } from './url'
|
|
351
|
+
export { resolveRobots } from './robots'
|
|
352
|
+
export { resolveOpenGraph } from './opengraph'
|
|
353
|
+
export { resolveTwitter } from './twitter'
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Open Graph Resolution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
OpenGraph,
|
|
7
|
+
OpenGraphImage,
|
|
8
|
+
OpenGraphVideo,
|
|
9
|
+
OpenGraphAudio,
|
|
10
|
+
ResolvedOpenGraph,
|
|
11
|
+
} from '../types'
|
|
12
|
+
import { resolveUrl } from './url'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OG 이미지 배열로 정규화
|
|
16
|
+
*/
|
|
17
|
+
function resolveOgImages(
|
|
18
|
+
images: OpenGraph['images'],
|
|
19
|
+
metadataBase: URL | null
|
|
20
|
+
): OpenGraphImage[] | null {
|
|
21
|
+
if (!images) return null
|
|
22
|
+
|
|
23
|
+
const arr = Array.isArray(images) ? images : [images]
|
|
24
|
+
const resolved: OpenGraphImage[] = []
|
|
25
|
+
|
|
26
|
+
for (const image of arr) {
|
|
27
|
+
if (typeof image === 'string' || image instanceof URL) {
|
|
28
|
+
const url = resolveUrl(image, metadataBase)
|
|
29
|
+
if (url) {
|
|
30
|
+
resolved.push({ url: url.href })
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
const url = resolveUrl(image.url, metadataBase)
|
|
34
|
+
if (url) {
|
|
35
|
+
resolved.push({
|
|
36
|
+
...image,
|
|
37
|
+
url: url.href,
|
|
38
|
+
secureUrl: image.secureUrl
|
|
39
|
+
? resolveUrl(image.secureUrl, metadataBase)?.href
|
|
40
|
+
: undefined,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return resolved.length > 0 ? resolved : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* OG 비디오 배열로 정규화
|
|
51
|
+
*/
|
|
52
|
+
function resolveOgVideos(
|
|
53
|
+
videos: OpenGraph['videos'],
|
|
54
|
+
metadataBase: URL | null
|
|
55
|
+
): OpenGraphVideo[] | null {
|
|
56
|
+
if (!videos) return null
|
|
57
|
+
|
|
58
|
+
const arr = Array.isArray(videos) ? videos : [videos]
|
|
59
|
+
const resolved: OpenGraphVideo[] = []
|
|
60
|
+
|
|
61
|
+
for (const video of arr) {
|
|
62
|
+
if (typeof video === 'string' || video instanceof URL) {
|
|
63
|
+
const url = resolveUrl(video, metadataBase)
|
|
64
|
+
if (url) {
|
|
65
|
+
resolved.push({ url: url.href })
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
const url = resolveUrl(video.url, metadataBase)
|
|
69
|
+
if (url) {
|
|
70
|
+
resolved.push({
|
|
71
|
+
...video,
|
|
72
|
+
url: url.href,
|
|
73
|
+
secureUrl: video.secureUrl
|
|
74
|
+
? resolveUrl(video.secureUrl, metadataBase)?.href
|
|
75
|
+
: undefined,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return resolved.length > 0 ? resolved : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* OG 오디오 배열로 정규화
|
|
86
|
+
*/
|
|
87
|
+
function resolveOgAudio(
|
|
88
|
+
audio: OpenGraph['audio'],
|
|
89
|
+
metadataBase: URL | null
|
|
90
|
+
): OpenGraphAudio[] | null {
|
|
91
|
+
if (!audio) return null
|
|
92
|
+
|
|
93
|
+
const arr = Array.isArray(audio) ? audio : [audio]
|
|
94
|
+
const resolved: OpenGraphAudio[] = []
|
|
95
|
+
|
|
96
|
+
for (const item of arr) {
|
|
97
|
+
if (typeof item === 'string' || item instanceof URL) {
|
|
98
|
+
const url = resolveUrl(item, metadataBase)
|
|
99
|
+
if (url) {
|
|
100
|
+
resolved.push({ url: url.href })
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
const url = resolveUrl(item.url, metadataBase)
|
|
104
|
+
if (url) {
|
|
105
|
+
resolved.push({
|
|
106
|
+
...item,
|
|
107
|
+
url: url.href,
|
|
108
|
+
secureUrl: item.secureUrl
|
|
109
|
+
? resolveUrl(item.secureUrl, metadataBase)?.href
|
|
110
|
+
: undefined,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return resolved.length > 0 ? resolved : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Open Graph 메타데이터 해석
|
|
121
|
+
*/
|
|
122
|
+
export function resolveOpenGraph(
|
|
123
|
+
openGraph: OpenGraph | null | undefined,
|
|
124
|
+
metadataBase: URL | null
|
|
125
|
+
): ResolvedOpenGraph | null {
|
|
126
|
+
if (!openGraph) return null
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
type: openGraph.type || 'website',
|
|
130
|
+
url: resolveUrl(openGraph.url, metadataBase),
|
|
131
|
+
title: openGraph.title || null,
|
|
132
|
+
description: openGraph.description || null,
|
|
133
|
+
siteName: openGraph.siteName || null,
|
|
134
|
+
locale: openGraph.locale || null,
|
|
135
|
+
images: resolveOgImages(openGraph.images, metadataBase),
|
|
136
|
+
videos: resolveOgVideos(openGraph.videos, metadataBase),
|
|
137
|
+
audio: resolveOgAudio(openGraph.audio, metadataBase),
|
|
138
|
+
determiner: openGraph.determiner || null,
|
|
139
|
+
article: openGraph.article || null,
|
|
140
|
+
profile: openGraph.profile || null,
|
|
141
|
+
book: openGraph.book || null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Robots Resolution
|
|
3
|
+
*
|
|
4
|
+
* robots 메타 태그 값 생성
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Robots, ResolvedRobots } from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Robots 객체를 문자열로 변환
|
|
11
|
+
*/
|
|
12
|
+
function robotsToString(robots: Robots): string {
|
|
13
|
+
const values: string[] = []
|
|
14
|
+
|
|
15
|
+
// Boolean directives
|
|
16
|
+
if (robots.index === true) values.push('index')
|
|
17
|
+
if (robots.index === false) values.push('noindex')
|
|
18
|
+
if (robots.follow === true) values.push('follow')
|
|
19
|
+
if (robots.follow === false) values.push('nofollow')
|
|
20
|
+
if (robots.noarchive) values.push('noarchive')
|
|
21
|
+
if (robots.nosnippet) values.push('nosnippet')
|
|
22
|
+
if (robots.noimageindex) values.push('noimageindex')
|
|
23
|
+
if (robots.nocache) values.push('nocache')
|
|
24
|
+
if (robots.notranslate) values.push('notranslate')
|
|
25
|
+
|
|
26
|
+
// Numeric directives
|
|
27
|
+
if (robots['max-snippet'] !== undefined) {
|
|
28
|
+
values.push(`max-snippet:${robots['max-snippet']}`)
|
|
29
|
+
}
|
|
30
|
+
if (robots['max-image-preview']) {
|
|
31
|
+
values.push(`max-image-preview:${robots['max-image-preview']}`)
|
|
32
|
+
}
|
|
33
|
+
if (robots['max-video-preview'] !== undefined) {
|
|
34
|
+
values.push(`max-video-preview:${robots['max-video-preview']}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return values.join(', ')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Robots 설정 해석
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRobots(
|
|
44
|
+
robots: string | Robots | null | undefined
|
|
45
|
+
): ResolvedRobots | null {
|
|
46
|
+
if (!robots) return null
|
|
47
|
+
|
|
48
|
+
// 문자열 그대로 사용
|
|
49
|
+
if (typeof robots === 'string') {
|
|
50
|
+
return {
|
|
51
|
+
basic: robots,
|
|
52
|
+
googleBot: null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 객체에서 문자열 생성
|
|
57
|
+
const basicString = robotsToString(robots)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
basic: basicString || null,
|
|
61
|
+
googleBot: null, // 향후 googleBot 별도 설정 지원
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 기본 robots 값 생성 (index, follow)
|
|
67
|
+
*/
|
|
68
|
+
export function getDefaultRobots(): ResolvedRobots {
|
|
69
|
+
return {
|
|
70
|
+
basic: 'index, follow',
|
|
71
|
+
googleBot: null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Title Resolution
|
|
3
|
+
*
|
|
4
|
+
* title.template 패턴 처리
|
|
5
|
+
* Layout의 template이 Page의 title에 적용됨
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Title, TemplateString, AbsoluteTemplateString, AbsoluteString } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Title이 TemplateString 객체인지 확인 (default 필드 있음)
|
|
12
|
+
*/
|
|
13
|
+
function isTemplateString(title: Title): title is TemplateString {
|
|
14
|
+
return typeof title === 'object' && title !== null && 'default' in title
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Title이 AbsoluteString 객체인지 확인 (absolute 필드만 있음, default 없음)
|
|
19
|
+
*/
|
|
20
|
+
function isAbsoluteString(title: Title): title is AbsoluteString {
|
|
21
|
+
return typeof title === 'object' && title !== null && 'absolute' in title && !('default' in title)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Title을 AbsoluteTemplateString으로 변환
|
|
26
|
+
*/
|
|
27
|
+
export function resolveTitle(
|
|
28
|
+
title: Title | null | undefined,
|
|
29
|
+
parentTemplate: string | null
|
|
30
|
+
): AbsoluteTemplateString | null {
|
|
31
|
+
if (title === null || title === undefined) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// String title
|
|
36
|
+
if (typeof title === 'string') {
|
|
37
|
+
// 부모 템플릿 적용
|
|
38
|
+
const absolute = parentTemplate
|
|
39
|
+
? parentTemplate.replace('%s', title)
|
|
40
|
+
: title
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
absolute,
|
|
44
|
+
template: null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// AbsoluteString object ({ absolute: string }) - 템플릿 무시
|
|
49
|
+
if (isAbsoluteString(title)) {
|
|
50
|
+
return {
|
|
51
|
+
absolute: title.absolute,
|
|
52
|
+
template: title.template || null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// TemplateString object ({ default: string, template?: string })
|
|
57
|
+
if (isTemplateString(title)) {
|
|
58
|
+
// default에 부모 템플릿 적용
|
|
59
|
+
const absolute = parentTemplate
|
|
60
|
+
? parentTemplate.replace('%s', title.default)
|
|
61
|
+
: title.default
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
absolute,
|
|
65
|
+
template: title.template || null,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Layout 체인에서 title template 추출
|
|
74
|
+
*/
|
|
75
|
+
export function extractTitleTemplate(title: Title | null | undefined): string | null {
|
|
76
|
+
if (!title) return null
|
|
77
|
+
|
|
78
|
+
if (typeof title === 'string') {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isTemplateString(title)) {
|
|
83
|
+
return title.template || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* title.absolute 값 추출 (렌더링용)
|
|
91
|
+
*/
|
|
92
|
+
export function getTitleString(resolved: AbsoluteTemplateString | null): string | null {
|
|
93
|
+
return resolved?.absolute || null
|
|
94
|
+
}
|