@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-wave/blog",
3
- "version": "1.1.4",
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
+ '&': '&amp;',
90
+ '<': '&lt;',
91
+ '>': '&gt;',
92
+ '"': '&quot;',
93
+ "'": '&#039;',
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
+ }