@mandujs/core 0.13.0 → 0.13.2
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 +4 -4
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +9 -0
- package/src/config/validate.ts +12 -0
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +46 -46
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +24 -1
- package/src/filling/deps.ts +238 -238
- package/src/filling/index.ts +4 -0
- package/src/filling/sse-catchup.test.ts +56 -0
- package/src/filling/sse-catchup.ts +67 -0
- package/src/filling/sse.test.ts +168 -0
- package/src/filling/sse.ts +162 -0
- package/src/generator/index.ts +3 -3
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +6 -1
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-scanner.ts +497 -497
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/escape.ts +44 -0
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +257 -0
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +16 -21
- package/src/runtime/streaming-ssr.ts +24 -33
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu SEO - Sitemap XML Rendering
|
|
3
|
-
*
|
|
4
|
-
* Sitemap 배열을 XML 문자열로 변환
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { Sitemap, SitemapEntry } from '../types'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* XML 이스케이프
|
|
11
|
-
*/
|
|
12
|
-
function escapeXml(str: string): string {
|
|
13
|
-
return str
|
|
14
|
-
.replace(/&/g, '&')
|
|
15
|
-
.replace(/</g, '<')
|
|
16
|
-
.replace(/>/g, '>')
|
|
17
|
-
.replace(/"/g, '"')
|
|
18
|
-
.replace(/'/g, ''')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Date를 ISO 문자열로 변환
|
|
23
|
-
*/
|
|
24
|
-
function formatDate(date: string | Date): string {
|
|
25
|
-
if (date instanceof Date) {
|
|
26
|
-
return date.toISOString()
|
|
27
|
-
}
|
|
28
|
-
return date
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 단일 sitemap 항목을 XML로 변환
|
|
33
|
-
*/
|
|
34
|
-
function renderSitemapEntry(entry: SitemapEntry): string {
|
|
35
|
-
const lines: string[] = [' <url>']
|
|
36
|
-
|
|
37
|
-
lines.push(` <loc>${escapeXml(entry.url)}</loc>`)
|
|
38
|
-
|
|
39
|
-
if (entry.lastModified) {
|
|
40
|
-
lines.push(` <lastmod>${formatDate(entry.lastModified)}</lastmod>`)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (entry.changeFrequency) {
|
|
44
|
-
lines.push(` <changefreq>${entry.changeFrequency}</changefreq>`)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (entry.priority !== undefined) {
|
|
48
|
-
lines.push(` <priority>${entry.priority.toFixed(1)}</priority>`)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 이미지 sitemap 확장
|
|
52
|
-
if (entry.images && entry.images.length > 0) {
|
|
53
|
-
for (const image of entry.images) {
|
|
54
|
-
lines.push(' <image:image>')
|
|
55
|
-
lines.push(` <image:loc>${escapeXml(image)}</image:loc>`)
|
|
56
|
-
lines.push(' </image:image>')
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// 다국어 alternate 링크 (xhtml:link)
|
|
61
|
-
if (entry.alternates?.languages) {
|
|
62
|
-
for (const [lang, url] of Object.entries(entry.alternates.languages)) {
|
|
63
|
-
lines.push(` <xhtml:link rel="alternate" hreflang="${lang}" href="${escapeXml(url)}" />`)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
lines.push(' </url>')
|
|
68
|
-
|
|
69
|
-
return lines.join('\n')
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Sitemap 배열을 XML 문자열로 렌더링
|
|
74
|
-
*
|
|
75
|
-
* @param sitemap - Sitemap 항목 배열
|
|
76
|
-
* @returns XML 문자열
|
|
77
|
-
*
|
|
78
|
-
* @example
|
|
79
|
-
* ```typescript
|
|
80
|
-
* const xml = renderSitemap([
|
|
81
|
-
* { url: 'https://example.com', lastModified: new Date(), priority: 1.0 },
|
|
82
|
-
* { url: 'https://example.com/about', changeFrequency: 'monthly' },
|
|
83
|
-
* ])
|
|
84
|
-
* ```
|
|
85
|
-
*/
|
|
86
|
-
export function renderSitemap(sitemap: Sitemap): string {
|
|
87
|
-
const hasImages = sitemap.some(entry => entry.images && entry.images.length > 0)
|
|
88
|
-
const hasAlternates = sitemap.some(entry => entry.alternates?.languages)
|
|
89
|
-
|
|
90
|
-
// XML 네임스페이스 구성
|
|
91
|
-
const namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"']
|
|
92
|
-
|
|
93
|
-
if (hasImages) {
|
|
94
|
-
namespaces.push('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (hasAlternates) {
|
|
98
|
-
namespaces.push('xmlns:xhtml="http://www.w3.org/1999/xhtml"')
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const lines: string[] = [
|
|
102
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
103
|
-
`<urlset ${namespaces.join(' ')}>`,
|
|
104
|
-
...sitemap.map(renderSitemapEntry),
|
|
105
|
-
'</urlset>',
|
|
106
|
-
]
|
|
107
|
-
|
|
108
|
-
return lines.join('\n')
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Sitemap Index 렌더링 (대규모 사이트용)
|
|
113
|
-
*
|
|
114
|
-
* @param sitemaps - 개별 sitemap URL 배열
|
|
115
|
-
* @returns XML 문자열
|
|
116
|
-
*/
|
|
117
|
-
export function renderSitemapIndex(
|
|
118
|
-
sitemaps: Array<{ url: string; lastModified?: string | Date }>
|
|
119
|
-
): string {
|
|
120
|
-
const lines: string[] = [
|
|
121
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
122
|
-
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
123
|
-
]
|
|
124
|
-
|
|
125
|
-
for (const sitemap of sitemaps) {
|
|
126
|
-
lines.push(' <sitemap>')
|
|
127
|
-
lines.push(` <loc>${escapeXml(sitemap.url)}</loc>`)
|
|
128
|
-
if (sitemap.lastModified) {
|
|
129
|
-
lines.push(` <lastmod>${formatDate(sitemap.lastModified)}</lastmod>`)
|
|
130
|
-
}
|
|
131
|
-
lines.push(' </sitemap>')
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
lines.push('</sitemapindex>')
|
|
135
|
-
|
|
136
|
-
return lines.join('\n')
|
|
137
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Sitemap XML Rendering
|
|
3
|
+
*
|
|
4
|
+
* Sitemap 배열을 XML 문자열로 변환
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Sitemap, SitemapEntry } from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* XML 이스케이프
|
|
11
|
+
*/
|
|
12
|
+
function escapeXml(str: string): string {
|
|
13
|
+
return str
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Date를 ISO 문자열로 변환
|
|
23
|
+
*/
|
|
24
|
+
function formatDate(date: string | Date): string {
|
|
25
|
+
if (date instanceof Date) {
|
|
26
|
+
return date.toISOString()
|
|
27
|
+
}
|
|
28
|
+
return date
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 단일 sitemap 항목을 XML로 변환
|
|
33
|
+
*/
|
|
34
|
+
function renderSitemapEntry(entry: SitemapEntry): string {
|
|
35
|
+
const lines: string[] = [' <url>']
|
|
36
|
+
|
|
37
|
+
lines.push(` <loc>${escapeXml(entry.url)}</loc>`)
|
|
38
|
+
|
|
39
|
+
if (entry.lastModified) {
|
|
40
|
+
lines.push(` <lastmod>${formatDate(entry.lastModified)}</lastmod>`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (entry.changeFrequency) {
|
|
44
|
+
lines.push(` <changefreq>${entry.changeFrequency}</changefreq>`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry.priority !== undefined) {
|
|
48
|
+
lines.push(` <priority>${entry.priority.toFixed(1)}</priority>`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 이미지 sitemap 확장
|
|
52
|
+
if (entry.images && entry.images.length > 0) {
|
|
53
|
+
for (const image of entry.images) {
|
|
54
|
+
lines.push(' <image:image>')
|
|
55
|
+
lines.push(` <image:loc>${escapeXml(image)}</image:loc>`)
|
|
56
|
+
lines.push(' </image:image>')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 다국어 alternate 링크 (xhtml:link)
|
|
61
|
+
if (entry.alternates?.languages) {
|
|
62
|
+
for (const [lang, url] of Object.entries(entry.alternates.languages)) {
|
|
63
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="${lang}" href="${escapeXml(url)}" />`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
lines.push(' </url>')
|
|
68
|
+
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sitemap 배열을 XML 문자열로 렌더링
|
|
74
|
+
*
|
|
75
|
+
* @param sitemap - Sitemap 항목 배열
|
|
76
|
+
* @returns XML 문자열
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* const xml = renderSitemap([
|
|
81
|
+
* { url: 'https://example.com', lastModified: new Date(), priority: 1.0 },
|
|
82
|
+
* { url: 'https://example.com/about', changeFrequency: 'monthly' },
|
|
83
|
+
* ])
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function renderSitemap(sitemap: Sitemap): string {
|
|
87
|
+
const hasImages = sitemap.some(entry => entry.images && entry.images.length > 0)
|
|
88
|
+
const hasAlternates = sitemap.some(entry => entry.alternates?.languages)
|
|
89
|
+
|
|
90
|
+
// XML 네임스페이스 구성
|
|
91
|
+
const namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"']
|
|
92
|
+
|
|
93
|
+
if (hasImages) {
|
|
94
|
+
namespaces.push('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (hasAlternates) {
|
|
98
|
+
namespaces.push('xmlns:xhtml="http://www.w3.org/1999/xhtml"')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const lines: string[] = [
|
|
102
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
103
|
+
`<urlset ${namespaces.join(' ')}>`,
|
|
104
|
+
...sitemap.map(renderSitemapEntry),
|
|
105
|
+
'</urlset>',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
return lines.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sitemap Index 렌더링 (대규모 사이트용)
|
|
113
|
+
*
|
|
114
|
+
* @param sitemaps - 개별 sitemap URL 배열
|
|
115
|
+
* @returns XML 문자열
|
|
116
|
+
*/
|
|
117
|
+
export function renderSitemapIndex(
|
|
118
|
+
sitemaps: Array<{ url: string; lastModified?: string | Date }>
|
|
119
|
+
): string {
|
|
120
|
+
const lines: string[] = [
|
|
121
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
122
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
for (const sitemap of sitemaps) {
|
|
126
|
+
lines.push(' <sitemap>')
|
|
127
|
+
lines.push(` <loc>${escapeXml(sitemap.url)}</loc>`)
|
|
128
|
+
if (sitemap.lastModified) {
|
|
129
|
+
lines.push(` <lastmod>${formatDate(sitemap.lastModified)}</lastmod>`)
|
|
130
|
+
}
|
|
131
|
+
lines.push(' </sitemap>')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push('</sitemapindex>')
|
|
135
|
+
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
@@ -1,126 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu SEO - Twitter Card Meta Tags Rendering
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { ResolvedMetadata } from '../types'
|
|
6
|
-
import { urlToString } from '../resolve/url'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* HTML 이스케이프
|
|
10
|
-
*/
|
|
11
|
-
function escapeHtml(str: string): string {
|
|
12
|
-
return str
|
|
13
|
-
.replace(/&/g, '&')
|
|
14
|
-
.replace(/</g, '<')
|
|
15
|
-
.replace(/>/g, '>')
|
|
16
|
-
.replace(/"/g, '"')
|
|
17
|
-
.replace(/'/g, ''')
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Twitter 메타 태그 생성 헬퍼
|
|
22
|
-
*/
|
|
23
|
-
function twitter(name: string, content: string | number): string {
|
|
24
|
-
return `<meta name="twitter:${name}" content="${escapeHtml(String(content))}" />`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Twitter Card 메타 태그 렌더링
|
|
29
|
-
*/
|
|
30
|
-
export function renderTwitter(metadata: ResolvedMetadata): string {
|
|
31
|
-
const tw = metadata.twitter
|
|
32
|
-
if (!tw) return ''
|
|
33
|
-
|
|
34
|
-
const tags: string[] = []
|
|
35
|
-
|
|
36
|
-
// Card type
|
|
37
|
-
tags.push(twitter('card', tw.card))
|
|
38
|
-
|
|
39
|
-
// Site
|
|
40
|
-
if (tw.site) {
|
|
41
|
-
tags.push(twitter('site', tw.site))
|
|
42
|
-
}
|
|
43
|
-
if (tw.siteId) {
|
|
44
|
-
tags.push(twitter('site:id', tw.siteId))
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Creator
|
|
48
|
-
if (tw.creator) {
|
|
49
|
-
tags.push(twitter('creator', tw.creator))
|
|
50
|
-
}
|
|
51
|
-
if (tw.creatorId) {
|
|
52
|
-
tags.push(twitter('creator:id', tw.creatorId))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Title - fallback to OG or metadata title
|
|
56
|
-
const title = tw.title || metadata.openGraph?.title || metadata.title?.absolute
|
|
57
|
-
if (title) {
|
|
58
|
-
tags.push(twitter('title', title))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Description - fallback to OG or metadata description
|
|
62
|
-
const description = tw.description || metadata.openGraph?.description || metadata.description
|
|
63
|
-
if (description) {
|
|
64
|
-
tags.push(twitter('description', description))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Images
|
|
68
|
-
if (tw.images) {
|
|
69
|
-
for (let i = 0; i < tw.images.length; i++) {
|
|
70
|
-
const image = tw.images[i]
|
|
71
|
-
if (i === 0) {
|
|
72
|
-
tags.push(twitter('image', urlToString(image.url)))
|
|
73
|
-
if (image.alt) {
|
|
74
|
-
tags.push(twitter('image:alt', image.alt))
|
|
75
|
-
}
|
|
76
|
-
} else {
|
|
77
|
-
// Multiple images (for galleries)
|
|
78
|
-
tags.push(twitter(`image${i}`, urlToString(image.url)))
|
|
79
|
-
if (image.alt) {
|
|
80
|
-
tags.push(twitter(`image${i}:alt`, image.alt))
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Player card
|
|
87
|
-
if (tw.players) {
|
|
88
|
-
for (const player of tw.players) {
|
|
89
|
-
tags.push(twitter('player', String(player.playerUrl)))
|
|
90
|
-
if (player.streamUrl) {
|
|
91
|
-
tags.push(twitter('player:stream', String(player.streamUrl)))
|
|
92
|
-
}
|
|
93
|
-
tags.push(twitter('player:width', player.width))
|
|
94
|
-
tags.push(twitter('player:height', player.height))
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// App card
|
|
99
|
-
if (tw.app) {
|
|
100
|
-
if (tw.app.name) {
|
|
101
|
-
tags.push(twitter('app:name:iphone', tw.app.name))
|
|
102
|
-
tags.push(twitter('app:name:ipad', tw.app.name))
|
|
103
|
-
tags.push(twitter('app:name:googleplay', tw.app.name))
|
|
104
|
-
}
|
|
105
|
-
if (tw.app.id?.iphone) {
|
|
106
|
-
tags.push(twitter('app:id:iphone', String(tw.app.id.iphone)))
|
|
107
|
-
}
|
|
108
|
-
if (tw.app.id?.ipad) {
|
|
109
|
-
tags.push(twitter('app:id:ipad', String(tw.app.id.ipad)))
|
|
110
|
-
}
|
|
111
|
-
if (tw.app.id?.googleplay) {
|
|
112
|
-
tags.push(twitter('app:id:googleplay', tw.app.id.googleplay))
|
|
113
|
-
}
|
|
114
|
-
if (tw.app.url?.iphone) {
|
|
115
|
-
tags.push(twitter('app:url:iphone', String(tw.app.url.iphone)))
|
|
116
|
-
}
|
|
117
|
-
if (tw.app.url?.ipad) {
|
|
118
|
-
tags.push(twitter('app:url:ipad', String(tw.app.url.ipad)))
|
|
119
|
-
}
|
|
120
|
-
if (tw.app.url?.googleplay) {
|
|
121
|
-
tags.push(twitter('app:url:googleplay', String(tw.app.url.googleplay)))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return tags.join('\n')
|
|
126
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SEO - Twitter Card Meta Tags Rendering
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ResolvedMetadata } from '../types'
|
|
6
|
+
import { urlToString } from '../resolve/url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HTML 이스케이프
|
|
10
|
+
*/
|
|
11
|
+
function escapeHtml(str: string): string {
|
|
12
|
+
return str
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Twitter 메타 태그 생성 헬퍼
|
|
22
|
+
*/
|
|
23
|
+
function twitter(name: string, content: string | number): string {
|
|
24
|
+
return `<meta name="twitter:${name}" content="${escapeHtml(String(content))}" />`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Twitter Card 메타 태그 렌더링
|
|
29
|
+
*/
|
|
30
|
+
export function renderTwitter(metadata: ResolvedMetadata): string {
|
|
31
|
+
const tw = metadata.twitter
|
|
32
|
+
if (!tw) return ''
|
|
33
|
+
|
|
34
|
+
const tags: string[] = []
|
|
35
|
+
|
|
36
|
+
// Card type
|
|
37
|
+
tags.push(twitter('card', tw.card))
|
|
38
|
+
|
|
39
|
+
// Site
|
|
40
|
+
if (tw.site) {
|
|
41
|
+
tags.push(twitter('site', tw.site))
|
|
42
|
+
}
|
|
43
|
+
if (tw.siteId) {
|
|
44
|
+
tags.push(twitter('site:id', tw.siteId))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Creator
|
|
48
|
+
if (tw.creator) {
|
|
49
|
+
tags.push(twitter('creator', tw.creator))
|
|
50
|
+
}
|
|
51
|
+
if (tw.creatorId) {
|
|
52
|
+
tags.push(twitter('creator:id', tw.creatorId))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Title - fallback to OG or metadata title
|
|
56
|
+
const title = tw.title || metadata.openGraph?.title || metadata.title?.absolute
|
|
57
|
+
if (title) {
|
|
58
|
+
tags.push(twitter('title', title))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Description - fallback to OG or metadata description
|
|
62
|
+
const description = tw.description || metadata.openGraph?.description || metadata.description
|
|
63
|
+
if (description) {
|
|
64
|
+
tags.push(twitter('description', description))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Images
|
|
68
|
+
if (tw.images) {
|
|
69
|
+
for (let i = 0; i < tw.images.length; i++) {
|
|
70
|
+
const image = tw.images[i]
|
|
71
|
+
if (i === 0) {
|
|
72
|
+
tags.push(twitter('image', urlToString(image.url)))
|
|
73
|
+
if (image.alt) {
|
|
74
|
+
tags.push(twitter('image:alt', image.alt))
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Multiple images (for galleries)
|
|
78
|
+
tags.push(twitter(`image${i}`, urlToString(image.url)))
|
|
79
|
+
if (image.alt) {
|
|
80
|
+
tags.push(twitter(`image${i}:alt`, image.alt))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Player card
|
|
87
|
+
if (tw.players) {
|
|
88
|
+
for (const player of tw.players) {
|
|
89
|
+
tags.push(twitter('player', String(player.playerUrl)))
|
|
90
|
+
if (player.streamUrl) {
|
|
91
|
+
tags.push(twitter('player:stream', String(player.streamUrl)))
|
|
92
|
+
}
|
|
93
|
+
tags.push(twitter('player:width', player.width))
|
|
94
|
+
tags.push(twitter('player:height', player.height))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// App card
|
|
99
|
+
if (tw.app) {
|
|
100
|
+
if (tw.app.name) {
|
|
101
|
+
tags.push(twitter('app:name:iphone', tw.app.name))
|
|
102
|
+
tags.push(twitter('app:name:ipad', tw.app.name))
|
|
103
|
+
tags.push(twitter('app:name:googleplay', tw.app.name))
|
|
104
|
+
}
|
|
105
|
+
if (tw.app.id?.iphone) {
|
|
106
|
+
tags.push(twitter('app:id:iphone', String(tw.app.id.iphone)))
|
|
107
|
+
}
|
|
108
|
+
if (tw.app.id?.ipad) {
|
|
109
|
+
tags.push(twitter('app:id:ipad', String(tw.app.id.ipad)))
|
|
110
|
+
}
|
|
111
|
+
if (tw.app.id?.googleplay) {
|
|
112
|
+
tags.push(twitter('app:id:googleplay', tw.app.id.googleplay))
|
|
113
|
+
}
|
|
114
|
+
if (tw.app.url?.iphone) {
|
|
115
|
+
tags.push(twitter('app:url:iphone', String(tw.app.url.iphone)))
|
|
116
|
+
}
|
|
117
|
+
if (tw.app.url?.ipad) {
|
|
118
|
+
tags.push(twitter('app:url:ipad', String(tw.app.url.ipad)))
|
|
119
|
+
}
|
|
120
|
+
if (tw.app.url?.googleplay) {
|
|
121
|
+
tags.push(twitter('app:url:googleplay', String(tw.app.url.googleplay)))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return tags.join('\n')
|
|
126
|
+
}
|