diff --git a/README.md b/README.md
index 5d42a1d..5961b67 100644
--- a/README.md
+++ b/README.md
@@ -54,9 +54,20 @@ browser-language redirect; readers switch from the header language menu.
规则, 动作, 值, 服务器负载) and puts a space between CJK text and Latin words.
- Long Chinese headings may use `\u200b` to mark where they are allowed to
wrap; headings otherwise break only at spaces and punctuation.
-- To add a locale: add it to `src/lib/locales.ts`, add a content file, and add
- the path to `prerender.entries` in `svelte.config.js` and to
- `scripts/verify-build.mjs`.
+- To add a locale: add it to `src/lib/locales.ts`, add a content file, add the
+ path to `prerender.entries` in `svelte.config.js`, add its share-image file
+ in `src/lib/seo.ts`, regenerate the PNGs with
+ `swift scripts/share-cards/render.swift` on macOS, and extend
+ `scripts/verify-build.mjs`. The sitemap is generated from the locale list.
+
+Search and share metadata is prerendered from each locale's `meta` block.
+Title and description match the visible hero. The share card is a PNG because
+WeChat, QQ, and X do not unfurl SVG. Open Graph covers WeChat, Weibo, DingTalk,
+Feishu, Zhihu, Discord, Slack, and Telegram. X also gets Twitter Card tags.
+QQ and Qzone get `itemprop` name, description, and image. There is no review
+or price structured data: the site publishes neither, and invented ratings are
+spam. Submitting the sitemap in Google Search Console and 百度搜索资源平台 is a
+manual step after deploy.
## Stack
diff --git a/scripts/share-cards/render.swift b/scripts/share-cards/render.swift
new file mode 100644
index 0000000..a1d9824
--- /dev/null
+++ b/scripts/share-cards/render.swift
@@ -0,0 +1,149 @@
+import AppKit
+import Foundation
+
+let root = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+let staticDir = root.appendingPathComponent("static")
+
+let ink = NSColor(srgbRed: 0x10 / 255, green: 0x10 / 255, blue: 0x0e / 255, alpha: 1)
+let paper = NSColor(srgbRed: 0xf7 / 255, green: 0xf7 / 255, blue: 0xf5 / 255, alpha: 1)
+let muted = NSColor(srgbRed: 0xc2 / 255, green: 0xc2 / 255, blue: 0xbc / 255, alpha: 1)
+let accent = NSColor(srgbRed: 0xf9 / 255, green: 0x73 / 255, blue: 0x16 / 255, alpha: 1)
+
+func png(width: Int, height: Int, _ draw: () -> Void) -> Data {
+ guard
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: width,
+ pixelsHigh: height,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )
+ else {
+ fputs("could not allocate bitmap\n", stderr)
+ exit(1)
+ }
+ rep.size = NSSize(width: width, height: height)
+ guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
+ fputs("could not make graphics context\n", stderr)
+ exit(1)
+ }
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = context
+ draw()
+ NSGraphicsContext.restoreGraphicsState()
+ guard let data = rep.representation(using: .png, properties: [:]) else {
+ fputs("could not encode png\n", stderr)
+ exit(1)
+ }
+ return data
+}
+
+func write(_ name: String, _ data: Data) {
+ let url = staticDir.appendingPathComponent(name)
+ do {
+ try data.write(to: url)
+ } catch {
+ fputs("write \(name) failed: \(error)\n", stderr)
+ exit(1)
+ }
+}
+
+func drawMark(in rect: CGRect) {
+ let scale = rect.width / 32
+ let stroke = 2.5 * scale
+ let outer = NSBezierPath(rect: rect.insetBy(dx: stroke / 2, dy: stroke / 2))
+ outer.lineWidth = stroke
+ paper.setStroke()
+ outer.stroke()
+ let inner = 10 * scale
+ accent.setFill()
+ NSRect(x: rect.midX - inner / 2, y: rect.midY - inner / 2, width: inner, height: inner).fill()
+}
+
+func drawText(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor, kern: CGFloat, center: CGPoint) {
+ let font = NSFont.systemFont(ofSize: size, weight: weight)
+ let attributed = NSAttributedString(
+ string: text,
+ attributes: [
+ .font: font,
+ .foregroundColor: color,
+ .kern: kern
+ ]
+ )
+ let bounds = attributed.size()
+ attributed.draw(
+ in: NSRect(
+ x: center.x - bounds.width / 2,
+ y: center.y - bounds.height / 2,
+ width: bounds.width,
+ height: bounds.height
+ )
+ )
+}
+
+func card(file: String, subtitle: String, subtitleSize: CGFloat) {
+ let data = png(width: 1200, height: 630) {
+ ink.setFill()
+ NSRect(x: 0, y: 0, width: 1200, height: 630).fill()
+ let mark: CGFloat = 72
+ let titleSize: CGFloat = 76
+ let titleHeight = NSFont.systemFont(ofSize: titleSize, weight: .semibold).ascender
+ - NSFont.systemFont(ofSize: titleSize, weight: .semibold).descender
+ let subHeight = NSFont.systemFont(ofSize: subtitleSize, weight: .medium).ascender
+ - NSFont.systemFont(ofSize: subtitleSize, weight: .medium).descender
+ let gapAfterMark: CGFloat = 28
+ let gapAfterTitle: CGFloat = 18
+ let total = mark + gapAfterMark + titleHeight + gapAfterTitle + subHeight
+ let bottom = (630 - total) / 2
+ drawText(
+ subtitle,
+ size: subtitleSize,
+ weight: .medium,
+ color: muted,
+ kern: 0,
+ center: CGPoint(x: 600, y: bottom + subHeight / 2)
+ )
+ drawText(
+ "WrightKit",
+ size: titleSize,
+ weight: .semibold,
+ color: paper,
+ kern: -1.6,
+ center: CGPoint(x: 600, y: bottom + subHeight + gapAfterTitle + titleHeight / 2)
+ )
+ drawMark(
+ in: CGRect(
+ x: 600 - mark / 2,
+ y: bottom + subHeight + gapAfterTitle + titleHeight + gapAfterMark,
+ width: mark,
+ height: mark
+ )
+ )
+ }
+ write(file, data)
+}
+
+card(file: "og-en.png", subtitle: "Overwatch Workshop tooling", subtitleSize: 32)
+card(file: "og-zh-CN.png", subtitle: "守望先锋地图工坊开发工具", subtitleSize: 36)
+
+let icon = png(width: 180, height: 180) {
+ ink.setFill()
+ NSRect(x: 0, y: 0, width: 180, height: 180).fill()
+ drawMark(in: CGRect(x: 34, y: 34, width: 112, height: 112))
+}
+write("apple-touch-icon.png", icon)
+
+let favicon = png(width: 32, height: 32) {
+ ink.setFill()
+ NSRect(x: 0, y: 0, width: 32, height: 32).fill()
+ drawMark(in: CGRect(x: 3, y: 3, width: 26, height: 26))
+}
+write("favicon-32.png", favicon)
diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs
index 591fc3e..b66ce48 100644
--- a/scripts/verify-build.mjs
+++ b/scripts/verify-build.mjs
@@ -10,8 +10,20 @@ const ROOT_DIR = resolve(__dirname, '..');
// metadata, and commands and captured CLI output must stay untranslated.
const SITE_URL = 'https://wrightkit.dev';
const pages = [
- { file: 'index.html', lang: 'en', canonical: `${SITE_URL}/` },
- { file: 'zh-CN.html', lang: 'zh-CN', canonical: `${SITE_URL}/zh-CN` }
+ {
+ file: 'index.html',
+ lang: 'en',
+ canonical: `${SITE_URL}/`,
+ image: `${SITE_URL}/og-en.png`,
+ imageAlt: 'WrightKit, tooling for Overwatch Workshop development'
+ },
+ {
+ file: 'zh-CN.html',
+ lang: 'zh-CN',
+ canonical: `${SITE_URL}/zh-CN`,
+ image: `${SITE_URL}/og-zh-CN.png`,
+ imageAlt: 'WrightKit,守望先锋地图工坊开发工具'
+ }
];
const literals = [
'curl -fsSL https://install.wrightkit.dev/wright/install.sh | bash',
@@ -33,6 +45,18 @@ for (const page of pages) {
` ``,
+ `"@type":"WebPage"`,
+ `"url":"${page.canonical}"`,
...literals
];
for (const snippet of expected) {
@@ -43,4 +67,49 @@ for (const page of pages) {
}
}
-console.log(`[verify-build] OK: ${pages.length} localized pages verified.`);
+function pngSize(path) {
+ const bytes = readFileSync(path);
+ const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(png) || bytes.toString('ascii', 12, 16) !== 'IHDR') {
+ return null;
+ }
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
+}
+
+const images = [
+ { file: 'og-en.png', width: 1200, height: 630 },
+ { file: 'og-zh-CN.png', width: 1200, height: 630 },
+ { file: 'apple-touch-icon.png', width: 180, height: 180 },
+ { file: 'favicon-32.png', width: 32, height: 32 }
+];
+
+for (const image of images) {
+ const path = resolve(ROOT_DIR, 'build', image.file);
+ const size = existsSync(path) ? pngSize(path) : null;
+ if (!size || size.width !== image.width || size.height !== image.height) {
+ console.error(
+ `[verify-build] Error: build/${image.file} must be a ${image.width}×${image.height} PNG.`
+ );
+ process.exit(1);
+ }
+}
+
+const sitemap = readFileSync(resolve(ROOT_DIR, 'build/sitemap.xml'), 'utf8');
+for (const page of pages) {
+ if (!sitemap.includes(`