@x-wave/blog 1.1.4 → 2.1.0
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.md +2 -2
- package/index.js +488 -501
- package/package.json +5 -1
- package/vite-config/blog-discovery.ts +160 -0
- package/vite-config/index.ts +17 -0
- package/vite-config/meta-tags.ts +184 -0
- package/vite-config/setup-ssg.ts +105 -0
- package/vite-config/static-gen-plugin.ts +98 -0
- package/vite-config/types.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x-wave/blog",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"import": "./types/index.js",
|
|
20
20
|
"types": "./types/index.d.ts"
|
|
21
21
|
},
|
|
22
|
+
"./vite-config": {
|
|
23
|
+
"import": "./vite-config/index.ts",
|
|
24
|
+
"types": "./vite-config/index.ts"
|
|
25
|
+
},
|
|
22
26
|
"./styles": {
|
|
23
27
|
"import": "./styles/index.css"
|
|
24
28
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type {
|
|
3
|
+
BlogDiscoveryResult,
|
|
4
|
+
BlogPostMetadata,
|
|
5
|
+
BlogSSGConfig,
|
|
6
|
+
} from './types.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse YAML frontmatter from MDX content
|
|
10
|
+
*/
|
|
11
|
+
function parseFrontmatter(content: string): Record<string, unknown> {
|
|
12
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/)
|
|
13
|
+
|
|
14
|
+
if (!match) return {}
|
|
15
|
+
|
|
16
|
+
const frontmatter: Record<string, unknown> = {}
|
|
17
|
+
const frontmatterText = match[1]
|
|
18
|
+
let currentKey = ''
|
|
19
|
+
let isArrayContext = false
|
|
20
|
+
const arrayValues: string[] = []
|
|
21
|
+
|
|
22
|
+
for (const line of frontmatterText.split('\n')) {
|
|
23
|
+
const trimmed = line.trim()
|
|
24
|
+
|
|
25
|
+
// Handle array items (lines starting with -)
|
|
26
|
+
if (trimmed.startsWith('-')) {
|
|
27
|
+
if (isArrayContext) {
|
|
28
|
+
const value = trimmed.substring(1).trim()
|
|
29
|
+
arrayValues.push(value)
|
|
30
|
+
}
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// If we were in array context and hit a non-array line, save the array
|
|
35
|
+
if (isArrayContext && !trimmed.startsWith('-')) {
|
|
36
|
+
frontmatter[currentKey] = arrayValues.slice()
|
|
37
|
+
arrayValues.length = 0
|
|
38
|
+
isArrayContext = false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Handle key-value pairs
|
|
42
|
+
if (trimmed?.includes(':')) {
|
|
43
|
+
const [key, ...valueParts] = trimmed.split(':')
|
|
44
|
+
const value = valueParts.join(':').trim()
|
|
45
|
+
currentKey = key.trim()
|
|
46
|
+
|
|
47
|
+
// If value is empty, this might be an array declaration
|
|
48
|
+
if (!value) {
|
|
49
|
+
isArrayContext = true
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Parse boolean values
|
|
54
|
+
if (value === 'true') frontmatter[currentKey] = true
|
|
55
|
+
else if (value === 'false') frontmatter[currentKey] = false
|
|
56
|
+
else frontmatter[currentKey] = value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Save any remaining array
|
|
61
|
+
if (isArrayContext) {
|
|
62
|
+
frontmatter[currentKey] = arrayValues.slice()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return frontmatter
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract language from file path based on doc structure
|
|
70
|
+
* Expected: docs/[language]/[slug].mdx
|
|
71
|
+
*/
|
|
72
|
+
function extractLanguageFromPath(filePath: string): string {
|
|
73
|
+
const match = filePath.match(/\/docs\/([a-z]{2})\//)
|
|
74
|
+
return match ? match[1] : 'en'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract slug from file path
|
|
79
|
+
* Expected: docs/[language]/[slug].mdx
|
|
80
|
+
*/
|
|
81
|
+
function extractSlugFromPath(filePath: string): string {
|
|
82
|
+
const match = filePath.match(/\/([a-z0-9-]+)\.mdx$/)
|
|
83
|
+
return match ? match[1] : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Discover all blog posts from Vite glob import
|
|
88
|
+
*
|
|
89
|
+
* @param mdxFiles - Object from import.meta.glob('./docs/**\/*.mdx', { query: '?raw', import: 'default', eager: false })
|
|
90
|
+
* @param config - SSG configuration options
|
|
91
|
+
* @returns Collection of discovered blog posts organized by language and slug
|
|
92
|
+
*/
|
|
93
|
+
export async function discoverBlogPosts(
|
|
94
|
+
mdxFiles: Record<string, () => Promise<unknown>>,
|
|
95
|
+
config: BlogSSGConfig = {},
|
|
96
|
+
): Promise<BlogDiscoveryResult> {
|
|
97
|
+
const posts: BlogPostMetadata[] = []
|
|
98
|
+
const postsByLanguage: Record<string, BlogPostMetadata[]> = {}
|
|
99
|
+
const postsBySlug: Record<string, BlogPostMetadata> = {}
|
|
100
|
+
|
|
101
|
+
// Iterate through all MDX files
|
|
102
|
+
for (const [filePath, getContent] of Object.entries(mdxFiles)) {
|
|
103
|
+
try {
|
|
104
|
+
// Load content
|
|
105
|
+
const content = (await getContent()) as string
|
|
106
|
+
|
|
107
|
+
// Extract language and slug
|
|
108
|
+
const language = extractLanguageFromPath(filePath)
|
|
109
|
+
const slug = extractSlugFromPath(filePath)
|
|
110
|
+
|
|
111
|
+
if (!slug) continue
|
|
112
|
+
|
|
113
|
+
// Parse frontmatter
|
|
114
|
+
const frontmatter = parseFrontmatter(content)
|
|
115
|
+
|
|
116
|
+
// Create metadata object
|
|
117
|
+
const metadata: BlogPostMetadata = {
|
|
118
|
+
slug,
|
|
119
|
+
title: (frontmatter.title as string) || slug,
|
|
120
|
+
description: frontmatter.description as string | undefined,
|
|
121
|
+
ogImage: frontmatter.ogImage as string | undefined,
|
|
122
|
+
keywords: frontmatter.keywords as string[] | string | undefined,
|
|
123
|
+
date: frontmatter.date as string | undefined,
|
|
124
|
+
author: frontmatter.author as string | undefined,
|
|
125
|
+
language,
|
|
126
|
+
filePath,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
posts.push(metadata)
|
|
130
|
+
|
|
131
|
+
// Index by language
|
|
132
|
+
if (!postsByLanguage[language]) {
|
|
133
|
+
postsByLanguage[language] = []
|
|
134
|
+
}
|
|
135
|
+
postsByLanguage[language].push(metadata)
|
|
136
|
+
|
|
137
|
+
// Index by slug (use [language-slug] composite key to avoid conflicts)
|
|
138
|
+
postsBySlug[`${language}:${slug}`] = metadata
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.warn(`Warning: Failed to process ${filePath}:`, error)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
posts,
|
|
146
|
+
postsByLanguage,
|
|
147
|
+
postsBySlug,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Filter blog posts by specific criteria
|
|
153
|
+
* Useful for separating blog posts from docs
|
|
154
|
+
*/
|
|
155
|
+
export function filterBlogPosts(
|
|
156
|
+
posts: BlogPostMetadata[],
|
|
157
|
+
predicate: (post: BlogPostMetadata) => boolean,
|
|
158
|
+
): BlogPostMetadata[] {
|
|
159
|
+
return posts.filter(predicate)
|
|
160
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Blog discovery
|
|
2
|
+
export { discoverBlogPosts, filterBlogPosts } from './blog-discovery.ts'
|
|
3
|
+
|
|
4
|
+
// Meta tag generation
|
|
5
|
+
export {
|
|
6
|
+
generateHtmlWithMetaTags,
|
|
7
|
+
generateMetaTags,
|
|
8
|
+
generateMetaTagsObject,
|
|
9
|
+
type MetaTagsOptions,
|
|
10
|
+
} from './meta-tags.ts'
|
|
11
|
+
// SSG setup utilities (recommended for consumers)
|
|
12
|
+
export { type SetupSSGOptions, setupSSG } from './setup-ssg.ts'
|
|
13
|
+
// Vite plugin
|
|
14
|
+
export {
|
|
15
|
+
createStaticGenPlugin,
|
|
16
|
+
type StaticGenPluginOptions,
|
|
17
|
+
} from './static-gen-plugin.ts'
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { BlogPostMetadata } from './types.ts'
|
|
2
|
+
|
|
3
|
+
export interface MetaTagsOptions {
|
|
4
|
+
/** Base URL for absolute OG image URLs */
|
|
5
|
+
baseUrl?: string
|
|
6
|
+
/** Default OG image URL if post doesn't have one */
|
|
7
|
+
defaultOgImage?: string
|
|
8
|
+
/** Site title for Twitter Card */
|
|
9
|
+
siteTitle?: string
|
|
10
|
+
/** Site name for OG tags */
|
|
11
|
+
siteName?: string
|
|
12
|
+
/** Twitter handle for Twitter Card */
|
|
13
|
+
twitterHandle?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate meta tags HTML string from blog post metadata
|
|
18
|
+
*/
|
|
19
|
+
export function generateMetaTags(
|
|
20
|
+
post: BlogPostMetadata,
|
|
21
|
+
options: MetaTagsOptions = {},
|
|
22
|
+
): string {
|
|
23
|
+
const {
|
|
24
|
+
baseUrl = 'https://docs.staking.polkadot.cloud',
|
|
25
|
+
defaultOgImage = '/img/og-image.png',
|
|
26
|
+
siteTitle = 'Polkadot Cloud Staking',
|
|
27
|
+
siteName = 'Polkadot Cloud Staking Documentation',
|
|
28
|
+
twitterHandle = '@PolkadotCloud',
|
|
29
|
+
} = options
|
|
30
|
+
|
|
31
|
+
const postUrl = `${baseUrl}/${post.language}/docs/${post.slug}`
|
|
32
|
+
const ogImage = post.ogImage
|
|
33
|
+
? `${baseUrl}${post.ogImage.startsWith('/') ? '' : '/'}${post.ogImage}`
|
|
34
|
+
: `${baseUrl}${defaultOgImage}`
|
|
35
|
+
|
|
36
|
+
// Normalize keywords
|
|
37
|
+
let keywordsString = ''
|
|
38
|
+
if (post.keywords) {
|
|
39
|
+
if (Array.isArray(post.keywords)) {
|
|
40
|
+
keywordsString = post.keywords.join(', ')
|
|
41
|
+
} else {
|
|
42
|
+
keywordsString = post.keywords
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tags: string[] = [
|
|
47
|
+
// Basic meta tags
|
|
48
|
+
`<meta name="description" content="${escapeHtml(post.description || siteTitle)}">`,
|
|
49
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1.0">`,
|
|
50
|
+
|
|
51
|
+
// Open Graph (OG) tags
|
|
52
|
+
`<meta property="og:type" content="article">`,
|
|
53
|
+
`<meta property="og:title" content="${escapeHtml(post.title)} | ${escapeHtml(siteName)}">`,
|
|
54
|
+
`<meta property="og:description" content="${escapeHtml(post.description || siteTitle)}">`,
|
|
55
|
+
`<meta property="og:url" content="${escapeHtml(postUrl)}">`,
|
|
56
|
+
`<meta property="og:image" content="${escapeHtml(ogImage)}">`,
|
|
57
|
+
`<meta property="og:site_name" content="${escapeHtml(siteName)}">`,
|
|
58
|
+
|
|
59
|
+
// Article-specific meta tags
|
|
60
|
+
`<meta property="article:published_time" content="${post.date || new Date().toISOString()}">`,
|
|
61
|
+
post.author
|
|
62
|
+
? `<meta property="article:author" content="${escapeHtml(post.author)}">`
|
|
63
|
+
: '',
|
|
64
|
+
|
|
65
|
+
// Twitter Card tags
|
|
66
|
+
`<meta name="twitter:card" content="summary_large_image">`,
|
|
67
|
+
`<meta name="twitter:site" content="${twitterHandle}">`,
|
|
68
|
+
`<meta name="twitter:title" content="${escapeHtml(post.title)} | ${escapeHtml(siteName)}">`,
|
|
69
|
+
`<meta name="twitter:description" content="${escapeHtml(post.description || siteTitle)}">`,
|
|
70
|
+
`<meta name="twitter:image" content="${escapeHtml(ogImage)}">`,
|
|
71
|
+
|
|
72
|
+
// Keywords
|
|
73
|
+
keywordsString
|
|
74
|
+
? `<meta name="keywords" content="${escapeHtml(keywordsString)}">`
|
|
75
|
+
: '',
|
|
76
|
+
|
|
77
|
+
// Canonical URL
|
|
78
|
+
`<link rel="canonical" href="${escapeHtml(postUrl)}">`,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
return tags.filter(Boolean).join('\n\t')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Escape HTML special characters in attribute values
|
|
86
|
+
*/
|
|
87
|
+
function escapeHtml(text: string): string {
|
|
88
|
+
const map: Record<string, string> = {
|
|
89
|
+
'&': '&',
|
|
90
|
+
'<': '<',
|
|
91
|
+
'>': '>',
|
|
92
|
+
'"': '"',
|
|
93
|
+
"'": ''',
|
|
94
|
+
}
|
|
95
|
+
return text.replace(/[&<>"']/g, (char) => map[char])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate an HTML template with injected meta tags
|
|
100
|
+
* Used for static page generation
|
|
101
|
+
*/
|
|
102
|
+
export function generateHtmlWithMetaTags(
|
|
103
|
+
baseHtml: string,
|
|
104
|
+
post: BlogPostMetadata,
|
|
105
|
+
options: MetaTagsOptions = {},
|
|
106
|
+
): string {
|
|
107
|
+
const metaTags = generateMetaTags(post, options)
|
|
108
|
+
|
|
109
|
+
// Remove default OG and Twitter meta tags to replace them with custom ones
|
|
110
|
+
let html = baseHtml
|
|
111
|
+
// Remove default og:* meta tags (handles multiline)
|
|
112
|
+
.replace(/<meta\s+property="og:[^"]*"[^>]*>/gis, '')
|
|
113
|
+
// Remove default article:* meta tags (handles multiline)
|
|
114
|
+
.replace(/<meta\s+property="article:[^"]*"[^>]*>/gis, '')
|
|
115
|
+
// Remove default twitter:* meta tags with either 'name' or 'property' attribute (handles multiline)
|
|
116
|
+
.replace(/<meta\s+(?:name|property)="twitter:[^"]*"[^>]*>/gis, '')
|
|
117
|
+
// Remove default description tag
|
|
118
|
+
.replace(/<meta\s+name="description"[^>]*>/gis, '')
|
|
119
|
+
// Remove canonical link if present
|
|
120
|
+
.replace(/<link\s+rel="canonical"[^>]*>/gis, '')
|
|
121
|
+
|
|
122
|
+
// Clean up excess blank lines left by removed tags
|
|
123
|
+
html = html.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
124
|
+
|
|
125
|
+
// Remove orphaned section comments followed by blank lines
|
|
126
|
+
html = html.replace(
|
|
127
|
+
/\s*<!--\s*(?:Open Graph|Facebook|Twitter)[^>]*-->\s*\n/g,
|
|
128
|
+
'',
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Final cleanup: remove any remaining multiple blank lines
|
|
132
|
+
html = html.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
133
|
+
|
|
134
|
+
// Inject custom meta tags before closing </head> tag
|
|
135
|
+
return html.replace('</head>', `\t${metaTags}\n</head>`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate meta tags as a JSON object for dynamic injection
|
|
140
|
+
* Useful if you want to update meta tags on client-side navigation
|
|
141
|
+
*/
|
|
142
|
+
export function generateMetaTagsObject(
|
|
143
|
+
post: BlogPostMetadata,
|
|
144
|
+
options: MetaTagsOptions = {},
|
|
145
|
+
): Record<string, string> {
|
|
146
|
+
const {
|
|
147
|
+
baseUrl = 'https://docs.staking.polkadot.cloud',
|
|
148
|
+
defaultOgImage = '/img/og-image.png',
|
|
149
|
+
siteName = 'Polkadot Cloud Staking Documentation',
|
|
150
|
+
} = options
|
|
151
|
+
|
|
152
|
+
const postUrl = `${baseUrl}/${post.language}/docs/${post.slug}`
|
|
153
|
+
const ogImage = post.ogImage
|
|
154
|
+
? `${baseUrl}${post.ogImage.startsWith('/') ? '' : '/'}${post.ogImage}`
|
|
155
|
+
: `${baseUrl}${defaultOgImage}`
|
|
156
|
+
|
|
157
|
+
// Normalize keywords
|
|
158
|
+
let keywordsArray: string[] = []
|
|
159
|
+
if (post.keywords) {
|
|
160
|
+
if (Array.isArray(post.keywords)) {
|
|
161
|
+
keywordsArray = post.keywords
|
|
162
|
+
} else {
|
|
163
|
+
keywordsArray = post.keywords.split(',').map((k) => k.trim())
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
description: post.description || siteName,
|
|
169
|
+
'og:type': 'article',
|
|
170
|
+
'og:title': `${post.title} | ${siteName}`,
|
|
171
|
+
'og:description': post.description || siteName,
|
|
172
|
+
'og:url': postUrl,
|
|
173
|
+
'og:image': ogImage,
|
|
174
|
+
'og:site_name': siteName,
|
|
175
|
+
'article:published_time': post.date || new Date().toISOString(),
|
|
176
|
+
...(post.author && { 'article:author': post.author }),
|
|
177
|
+
'twitter:card': 'summary_large_image',
|
|
178
|
+
'twitter:title': `${post.title} | ${siteName}`,
|
|
179
|
+
'twitter:description': post.description || siteName,
|
|
180
|
+
'twitter:image': ogImage,
|
|
181
|
+
...(keywordsArray.length > 0 && { keywords: keywordsArray.join(', ') }),
|
|
182
|
+
canonical: postUrl,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { PluginOption } from 'vite'
|
|
4
|
+
import { discoverBlogPosts } from './blog-discovery.ts'
|
|
5
|
+
import type { MetaTagsOptions } from './meta-tags.ts'
|
|
6
|
+
import { createStaticGenPlugin } from './static-gen-plugin.ts'
|
|
7
|
+
|
|
8
|
+
export interface SetupSSGOptions {
|
|
9
|
+
/** Path to the docs directory (e.g., 'src/docs') */
|
|
10
|
+
docsPath: string
|
|
11
|
+
/** Output directory for generated static pages (default: 'dist/docs') */
|
|
12
|
+
outputDir?: string
|
|
13
|
+
/** Meta tags configuration */
|
|
14
|
+
metaTagsOptions?: MetaTagsOptions
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read all MDX files from a docs directory
|
|
19
|
+
* @param docsDir - Path to the docs directory
|
|
20
|
+
* @returns Object mapping file paths to content loaders
|
|
21
|
+
*/
|
|
22
|
+
function readMdxFiles(docsDir: string): Record<string, () => Promise<string>> {
|
|
23
|
+
const mdxFiles: Record<string, () => Promise<string>> = {}
|
|
24
|
+
|
|
25
|
+
function walkDir(dir: string, relativePath = '') {
|
|
26
|
+
if (!fs.existsSync(dir)) return
|
|
27
|
+
|
|
28
|
+
const files = fs.readdirSync(dir)
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const fullPath = path.join(dir, file)
|
|
32
|
+
const relPath = path.join(relativePath, file)
|
|
33
|
+
const stat = fs.statSync(fullPath)
|
|
34
|
+
|
|
35
|
+
if (stat.isDirectory()) {
|
|
36
|
+
walkDir(fullPath, relPath)
|
|
37
|
+
} else if (file.endsWith('.mdx')) {
|
|
38
|
+
// Create a lazy loader function
|
|
39
|
+
mdxFiles[path.join(docsDir, relPath)] = async () =>
|
|
40
|
+
fs.readFileSync(fullPath, 'utf-8')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
walkDir(docsDir)
|
|
46
|
+
return mdxFiles
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Setup static site generation for blog posts
|
|
51
|
+
*
|
|
52
|
+
* This is a convenience function that handles the entire SSG setup process.
|
|
53
|
+
* It discovers blog posts from your docs directory and creates a Vite plugin.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { setupSSG } from '@x-wave/blog/vite-config'
|
|
58
|
+
*
|
|
59
|
+
* export default defineConfig(async (env) => {
|
|
60
|
+
* const ssgPlugin = env.command === 'build'
|
|
61
|
+
* ? await setupSSG({
|
|
62
|
+
* docsPath: 'src/docs',
|
|
63
|
+
* outputDir: 'dist/docs',
|
|
64
|
+
* metaTagsOptions: {
|
|
65
|
+
* baseUrl: 'https://example.com',
|
|
66
|
+
* siteName: 'My Documentation',
|
|
67
|
+
* }
|
|
68
|
+
* })
|
|
69
|
+
* : undefined
|
|
70
|
+
*
|
|
71
|
+
* return {
|
|
72
|
+
* plugins: [react(), ssgPlugin].filter(Boolean),
|
|
73
|
+
* // ... rest of config
|
|
74
|
+
* }
|
|
75
|
+
* })
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export async function setupSSG(
|
|
79
|
+
options: SetupSSGOptions,
|
|
80
|
+
): Promise<PluginOption | undefined> {
|
|
81
|
+
try {
|
|
82
|
+
const { docsPath, outputDir = 'dist/docs', metaTagsOptions = {} } = options
|
|
83
|
+
|
|
84
|
+
const mdxFiles = readMdxFiles(docsPath)
|
|
85
|
+
const { posts } = await discoverBlogPosts(mdxFiles, {
|
|
86
|
+
blogContentPath: docsPath,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (posts.length === 0) {
|
|
90
|
+
console.log('📝 No blog posts found, SSG disabled')
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`📝 Discovered ${posts.length} blog posts for SSG`)
|
|
95
|
+
|
|
96
|
+
return createStaticGenPlugin({
|
|
97
|
+
posts,
|
|
98
|
+
outputDir,
|
|
99
|
+
metaTagsOptions,
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.warn('⚠️ SSG initialization failed:', error)
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Plugin } from 'vite'
|
|
4
|
+
import { generateHtmlWithMetaTags, type MetaTagsOptions } from './meta-tags.ts'
|
|
5
|
+
import type { BlogPostMetadata, BlogSSGConfig } from './types.ts'
|
|
6
|
+
|
|
7
|
+
export interface StaticGenPluginOptions extends BlogSSGConfig {
|
|
8
|
+
/** Blog posts to generate static pages for */
|
|
9
|
+
posts: BlogPostMetadata[]
|
|
10
|
+
/** Meta tag generation options */
|
|
11
|
+
metaTagsOptions?: MetaTagsOptions
|
|
12
|
+
/** Reference to original index.html path */
|
|
13
|
+
indexHtmlPath?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vite plugin for static site generation of blog posts
|
|
18
|
+
*
|
|
19
|
+
* Generates individual HTML files for each blog post with injected meta tags
|
|
20
|
+
* These files are created after the main build completes
|
|
21
|
+
*/
|
|
22
|
+
export function createStaticGenPlugin(options: StaticGenPluginOptions): Plugin {
|
|
23
|
+
let config: any
|
|
24
|
+
const {
|
|
25
|
+
posts,
|
|
26
|
+
outputDir = 'dist/docs',
|
|
27
|
+
metaTagsOptions = {},
|
|
28
|
+
indexHtmlPath = 'index.html',
|
|
29
|
+
} = options
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: 'vite-plugin-blog-static-gen',
|
|
33
|
+
|
|
34
|
+
configResolved(resolvedConfig) {
|
|
35
|
+
config = resolvedConfig
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async generateBundle() {
|
|
39
|
+
// This hook runs during the bundle generation phase
|
|
40
|
+
// We'll actually write files in writeBundle to ensure index.html exists
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async writeBundle() {
|
|
44
|
+
if (!posts || posts.length === 0) {
|
|
45
|
+
console.log('📝 No blog posts to generate static files for')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Read the generated index.html
|
|
51
|
+
const indexHtmlPath_ = path.resolve(config.build.outDir, 'index.html')
|
|
52
|
+
if (!fs.existsSync(indexHtmlPath_)) {
|
|
53
|
+
console.warn(
|
|
54
|
+
`⚠️ Index HTML not found at ${indexHtmlPath_}, skipping static generation`,
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const indexHtmlContent = fs.readFileSync(indexHtmlPath_, 'utf-8')
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
`📝 Generating static pages for ${posts.length} blog posts...`,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Create static HTML file for each blog post
|
|
66
|
+
for (const post of posts) {
|
|
67
|
+
const htmlWithMeta = generateHtmlWithMetaTags(
|
|
68
|
+
indexHtmlContent,
|
|
69
|
+
post,
|
|
70
|
+
metaTagsOptions,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Create directory structure: dist/docs/[language]/[slug]/index.html
|
|
74
|
+
const postDir = path.resolve(
|
|
75
|
+
config.build.outDir,
|
|
76
|
+
post.language,
|
|
77
|
+
post.slug,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
fs.mkdirSync(postDir, { recursive: true })
|
|
81
|
+
|
|
82
|
+
// Write the static HTML file
|
|
83
|
+
const htmlPath = path.resolve(postDir, 'index.html')
|
|
84
|
+
fs.writeFileSync(htmlPath, htmlWithMeta, 'utf-8')
|
|
85
|
+
|
|
86
|
+
console.log(` ✓ Generated ${post.language}/${post.slug}/`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
`✨ Static generation complete for ${posts.length} blog posts`,
|
|
91
|
+
)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('❌ Error generating static blog posts:', error)
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for blog SSG functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface BlogPostMetadata {
|
|
6
|
+
slug: string
|
|
7
|
+
title: string
|
|
8
|
+
description?: string
|
|
9
|
+
ogImage?: string
|
|
10
|
+
keywords?: string[] | string
|
|
11
|
+
date?: string
|
|
12
|
+
author?: string
|
|
13
|
+
language: string
|
|
14
|
+
filePath: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BlogDiscoveryResult {
|
|
18
|
+
posts: BlogPostMetadata[]
|
|
19
|
+
postsByLanguage: Record<string, BlogPostMetadata[]>
|
|
20
|
+
postsBySlug: Record<string, BlogPostMetadata>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BlogSSGConfig {
|
|
24
|
+
/** Path to blog MDX files relative to app root */
|
|
25
|
+
blogContentPath?: string
|
|
26
|
+
/** Whether to generate static files */
|
|
27
|
+
generateStatic?: boolean
|
|
28
|
+
/** Output directory for generated blog posts */
|
|
29
|
+
outputDir?: string
|
|
30
|
+
/** Base path for blog routes */
|
|
31
|
+
basePath?: string
|
|
32
|
+
}
|