diff --git a/docs/public/og-image.png b/docs/public/og-image.png new file mode 100644 index 0000000..8253a57 Binary files /dev/null and b/docs/public/og-image.png differ diff --git a/website/assets/og-image.svg b/website/assets/og-image.svg new file mode 100644 index 0000000..aa199e1 --- /dev/null +++ b/website/assets/og-image.svg @@ -0,0 +1,50 @@ + + monSQLize + MongoDB today, MySQL and PostgreSQL adapters next + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TYPESCRIPT DATA RUNTIME + monSQLize + + MongoDB today, MySQL and PostgreSQL adapters next + diff --git a/website/package.json b/website/package.json index f66359a..1a91b29 100644 --- a/website/package.json +++ b/website/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "rspress dev", "build": "rspress build", + "render:og-image": "node scripts/render-og-image.cjs", "preview": "rspress preview", "type-check": "tsc --noEmit -p tsconfig.json", "check:links": "node ../scripts/validation/check-site-links.cjs", diff --git a/website/rspress.config.ts b/website/rspress.config.ts index aaf93d5..889483f 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -8,6 +8,13 @@ import type { UserConfig, } from '@rspress/core'; import { pluginSitemap } from '@rspress/plugin-sitemap'; +import { + applyBuiltPageMeta, + docsSiteOrigin, + localeDescriptions, + siteBase, + socialHeadEntries, +} from './social-meta'; type MarkdownAstNode = { type?: string; @@ -386,7 +393,6 @@ const isExternalLink = (link: string) => /^https?:\/\//.test(link); const repositoryRoot = path.join(import.meta.dirname, '..'); const docsRoot = path.join(repositoryRoot, 'docs'); const githubRepositoryUrl = 'https://github.com/devcodex-labs/monSQLize'; -const siteBase = '/monSQLize/'; const isExternalOrSiteLink = (url: string) => /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(url); @@ -535,8 +541,13 @@ const rewriteGeneratedLanguageSwitchLinks: RspressPlugin = { name: 'rewrite-generated-language-switch-links', afterBuild(config) { const builtSiteDir = path.resolve(import.meta.dirname, config.outDir || 'dist'); + const htmlFiles = collectHtmlFiles(builtSiteDir); + let descriptionCorrected = 0; + let socialAdded = 0; + const correctedPages: string[] = []; + const socialPages: string[] = []; - collectHtmlFiles(builtSiteDir).forEach(htmlFile => { + htmlFiles.forEach(htmlFile => { const originalContent = fs.readFileSync(htmlFile, 'utf8'); let rewrittenContent = originalContent; @@ -544,10 +555,30 @@ const rewriteGeneratedLanguageSwitchLinks: RspressPlugin = { rewrittenContent = rewrittenContent.split(source).join(target); }); + const relativePath = path.relative(builtSiteDir, htmlFile); + const meta = applyBuiltPageMeta(rewrittenContent, relativePath); + if (meta.descriptionCorrected) { + descriptionCorrected += 1; + correctedPages.push(relativePath); + } + if (meta.socialAdded) { + socialAdded += 1; + socialPages.push(relativePath); + } + rewrittenContent = meta.html; + if (rewrittenContent !== originalContent) { fs.writeFileSync(htmlFile, rewrittenContent); } }); + + console.log(`[docs-meta] pages=${htmlFiles.length} description-corrections=${descriptionCorrected} social-tags-added=${socialAdded}`); + if (correctedPages.length > 0) { + console.log(`[docs-meta] corrected descriptions: ${correctedPages.join(', ')}`); + } + if (socialPages.length > 0) { + console.log(`[docs-meta] inserted social tags: ${socialPages.join(', ')}`); + } } }; @@ -555,12 +586,15 @@ const config: UserConfig = { root: path.join(import.meta.dirname, '..', 'docs'), themeDir: path.join(import.meta.dirname, 'theme'), base: siteBase, + siteOrigin: docsSiteOrigin, lang: 'en', title: 'monSQLize', icon: '/favicon.svg', globalStyles: path.join(import.meta.dirname, 'styles', 'home.css'), - description: 'A database-native TypeScript production data runtime layer with MongoDB stable today, plus cache, pools, transactions, models, sync, and observability.', + // Locale descriptions are the fallback. A root description would override them + // for every page that has no extracted summary, including Chinese pages. outDir: 'dist', + head: socialHeadEntries(), builderConfig: { performance: { buildCache: false, @@ -584,13 +618,13 @@ const config: UserConfig = { lang: 'en', label: 'English', title: 'monSQLize', - description: 'A database-native TypeScript production data runtime layer.' + description: localeDescriptions.en }, { lang: 'zh', label: '简体中文', title: 'monSQLize', - description: '数据库原生 TypeScript 生产数据运行时增强层。' + description: localeDescriptions.zh } ], plugins: [ @@ -618,7 +652,7 @@ const config: UserConfig = { lang: 'en', label: 'English', title: 'monSQLize', - description: 'A database-native TypeScript production data runtime layer.', + description: localeDescriptions.en, nav: englishNav, sidebar: { '/': englishSidebar @@ -628,7 +662,7 @@ const config: UserConfig = { lang: 'zh', label: '简体中文', title: 'monSQLize', - description: '数据库原生 TypeScript 生产数据运行时增强层。', + description: localeDescriptions.zh, nav: chineseNav, sidebar: { '/zh/': chineseSidebar diff --git a/website/scripts/check-browser.cjs b/website/scripts/check-browser.cjs index 2308024..502a1fa 100644 --- a/website/scripts/check-browser.cjs +++ b/website/scripts/check-browser.cjs @@ -14,6 +14,9 @@ const contentTypes = { '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.woff2': 'font/woff2', }; diff --git a/website/scripts/render-og-image.cjs b/website/scripts/render-og-image.cjs new file mode 100644 index 0000000..94442fe --- /dev/null +++ b/website/scripts/render-og-image.cjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Render the docs social card. + * + * Source: website/assets/og-image.svg + * Output: docs/public/og-image.png (1200x630, committed, copied into the site build) + * + * Requires rsvg-convert (librsvg2-bin) and optipng. The SVG uses Inter, with + * Liberation Sans as a fallback. Re-run with: npm run render:og-image + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const websiteRoot = path.resolve(__dirname, '..'); +const svgPath = path.join(websiteRoot, 'assets', 'og-image.svg'); +const pngPath = path.resolve(websiteRoot, '..', 'docs', 'public', 'og-image.png'); +const width = 1200; +const height = 630; +const maxBytes = 300 * 1024; + +function run(command, args) { + const result = spawnSync(command, args, { stdio: 'inherit' }); + if (result.error) { + console.error(`[og-image] failed to run ${command}: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function readPngSize(filePath) { + const header = Buffer.alloc(24); + const fd = fs.openSync(filePath, 'r'); + try { + fs.readSync(fd, header, 0, 24, 0); + } finally { + fs.closeSync(fd); + } + if (header.toString('ascii', 1, 4) !== 'PNG') { + throw new Error(`${filePath} is not a PNG`); + } + return { + width: header.readUInt32BE(16), + height: header.readUInt32BE(20), + }; +} + +run('rsvg-convert', [ + '--width', String(width), + '--height', String(height), + '--output', pngPath, + svgPath, +]); +run('optipng', ['-o7', '-strip', 'all', '-quiet', pngPath]); + +const size = readPngSize(pngPath); +const bytes = fs.statSync(pngPath).size; +if (size.width !== width || size.height !== height) { + console.error(`[og-image] expected ${width}x${height}, got ${size.width}x${size.height}`); + process.exit(1); +} +if (bytes > maxBytes) { + console.error(`[og-image] ${bytes} bytes exceeds ${maxBytes} bytes`); + process.exit(1); +} + +console.log(`[og-image] wrote ${path.relative(path.resolve(websiteRoot, '..'), pngPath)} ${size.width}x${size.height} ${bytes} bytes`); diff --git a/website/social-meta.ts b/website/social-meta.ts new file mode 100644 index 0000000..3e3f09a --- /dev/null +++ b/website/social-meta.ts @@ -0,0 +1,141 @@ +import type { RouteMeta } from '@rspress/core'; + +export const docsSiteOrigin = 'https://devcodex-labs.github.io'; +export const siteBase = '/monSQLize/'; +export const shareImageUrl = `${docsSiteOrigin}${siteBase}og-image.png`; + +export const localeDescriptions = { + en: 'A database-native TypeScript production data runtime layer with MongoDB stable today, plus cache, pools, transactions, models, sync, and observability.', + zh: '数据库原生 TypeScript 生产数据运行时增强层。当前 MongoDB 稳定,并提供缓存、连接池、事务、Model、同步与可观测能力。', +} as const; + +export const shareImageAlt = { + en: 'monSQLize: MongoDB today, MySQL and PostgreSQL adapters next', + zh: 'monSQLize:当前支持 MongoDB,后续接入 MySQL 与 PostgreSQL 适配器', +} as const; + +export type DocsLocale = keyof typeof localeDescriptions; + +const cjkPattern = /[\u4e00-\u9fff]/; + +type SocialRoute = Pick & { lang?: string }; + +export function localeFromLanguage(language: string | undefined, routePath = ''): DocsLocale { + if (language?.toLowerCase().startsWith('zh')) return 'zh'; + if (routePath === '/zh' || routePath.startsWith('/zh/')) return 'zh'; + return 'en'; +} + +export function absolutePageUrl(routePath: string): string { + const normalized = routePath.startsWith('/') ? routePath : `/${routePath}`; + const prefix = `${docsSiteOrigin}${siteBase}`; + if (normalized === '/') return prefix; + if (normalized.endsWith('/')) return `${prefix}${normalized.slice(1)}`; + return `${prefix}${normalized.slice(1)}.html`; +} + +export function routePathFromHtmlFile(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/'); + if (normalized === 'index.html') return '/'; + if (normalized.endsWith('/index.html')) return `/${normalized.slice(0, -'index.html'.length)}`; + if (normalized.endsWith('.html')) return `/${normalized.slice(0, -'.html'.length)}`; + return `/${normalized}`; +} + +export function localeFromHtml(html: string, relativePath: string): DocsLocale { + const language = html.match(/]*\blang=["']([^"']+)["']/i)?.[1]; + return localeFromLanguage(language, routePathFromHtmlFile(relativePath)); +} + +export function socialMetaTags(route: SocialRoute): Array<['meta', Record]> { + const locale = localeFromLanguage(route.lang, route.routePath); + return [ + ['meta', { property: 'og:image', content: shareImageUrl }], + ['meta', { property: 'og:image:width', content: '1200' }], + ['meta', { property: 'og:image:height', content: '630' }], + ['meta', { property: 'og:image:alt', content: shareImageAlt[locale] }], + ['meta', { property: 'og:url', content: absolutePageUrl(route.routePath) }], + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:image', content: shareImageUrl }], + ]; +} + +export function socialHeadEntries(): Array<(route: RouteMeta) => ['meta', Record] | undefined> { + return socialMetaTags({ lang: 'en', routePath: '/' }).map((_, index) => (route: RouteMeta) => { + if (!route?.routePath) return undefined; + return socialMetaTags(route)[index]; + }); +} + +function escapeAttribute(value: string): string { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(/): string { + const rendered = Object.entries(attrs) + .map(([key, value]) => `${key}="${escapeAttribute(value)}"`) + .join(' '); + return ``; +} + +function descriptionIsWrongLanguage(content: string, locale: DocsLocale): boolean { + const trimmed = content.trim(); + if (!trimmed) return true; + const hasCjk = cjkPattern.test(trimmed); + return locale === 'en' ? hasCjk : !hasCjk; +} + +function replaceMetaContent(tag: string, content: string): string { + if (/\bcontent="[^"]*"/.test(tag)) { + return tag.replace(/\bcontent="[^"]*"/, `content="${escapeAttribute(content)}"`); + } + if (/\bcontent='[^']*'/.test(tag)) { + return tag.replace(/\bcontent='[^']*'/, `content="${escapeAttribute(content)}"`); + } + return tag; +} + +export function applyBuiltPageMeta(html: string, relativePath: string): { + html: string; + descriptionCorrected: boolean; + socialAdded: boolean; +} { + const locale = localeFromHtml(html, relativePath); + const description = localeDescriptions[locale]; + let descriptionCorrected = false; + + if (!html.includes('')) { + return { html, descriptionCorrected: false, socialAdded: false }; + } + + let next = html.replace(/"']|"[^"]*"|'[^']*')*>/gi, tag => { + const isDescription = /\b(?:name=["']description["']|property=["']og:description["'])/i.test(tag); + if (!isDescription) return tag; + const contentMatch = /\bcontent=(["'])([\s\S]*?)\1/i.exec(tag); + const content = contentMatch?.[2] ?? ''; + if (!descriptionIsWrongLanguage(content, locale)) return tag; + descriptionCorrected = true; + return replaceMetaContent(tag, description); + }); + + const missingDescription: string[] = []; + if (!/\bname=["']description["']/i.test(next)) { + missingDescription.push(renderMetaTag({ name: 'description', content: description })); + } + if (!/\bproperty=["']og:description["']/i.test(next)) { + missingDescription.push(renderMetaTag({ property: 'og:description', content: description })); + } + if (missingDescription.length > 0) { + descriptionCorrected = true; + next = next.replace('', `${missingDescription.join('')}`); + } + + const socialAdded = !/\bproperty=["']og:image["']/i.test(next); + if (socialAdded) { + const routePath = routePathFromHtmlFile(relativePath); + const tags = socialMetaTags({ lang: locale, routePath }).map(([, attrs]) => renderMetaTag(attrs)).join(''); + next = next.replace('', `${tags}`); + } + + return { html: next, descriptionCorrected, socialAdded }; +}