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(`${page.canonical}`)) { + console.error(`[verify-build] Error: sitemap.xml is missing ${page.canonical}`); + process.exit(1); + } +} +if (!sitemap.includes('hreflang="x-default"')) { + console.error('[verify-build] Error: sitemap.xml is missing x-default.'); + process.exit(1); +} + +const robots = readFileSync(resolve(ROOT_DIR, 'build/robots.txt'), 'utf8'); +if (!robots.includes(`Sitemap: ${SITE_URL}/sitemap.xml`)) { + console.error('[verify-build] Error: robots.txt is missing the sitemap URL.'); + process.exit(1); +} + +console.log(`[verify-build] OK: ${pages.length} localized pages, share images, and sitemap verified.`); diff --git a/src/app.html b/src/app.html index 5a3b3ff..5d94b9d 100644 --- a/src/app.html +++ b/src/app.html @@ -24,6 +24,8 @@ + + %sveltekit.head% diff --git a/src/lib/content/en.ts b/src/lib/content/en.ts index 01ca9cd..aa30d79 100644 --- a/src/lib/content/en.ts +++ b/src/lib/content/en.ts @@ -5,7 +5,9 @@ const en: Messages = { meta: { title: 'WrightKit: Tooling for Overwatch Workshop development', description: - 'Stop guessing where the server-load risks are. Wright statically analyzes Overwatch Workshop and OverPy code and points every problem to a line.' + 'Stop guessing where the server-load risks are. Wright statically analyzes Overwatch Workshop and OverPy code and points every problem to a line.', + imageAlt: 'WrightKit, tooling for Overwatch Workshop development', + keywords: 'Overwatch Workshop, OverPy, Wright, WrightKit, Workshop lint, server load' }, ui: { skipToContent: 'Skip to content', diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 0bc9d21..b132df0 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -31,6 +31,10 @@ export interface Messages { meta: { title: string; description: string; + /** Alt text for the 1200×630 share image. */ + imageAlt: string; + /** Terms that already appear on the page. Used by engines that still read meta keywords. */ + keywords: string; }; ui: { skipToContent: string; diff --git a/src/lib/content/zh-CN.ts b/src/lib/content/zh-CN.ts index 1d7aec7..a4a5ec8 100644 --- a/src/lib/content/zh-CN.ts +++ b/src/lib/content/zh-CN.ts @@ -12,7 +12,9 @@ const zhCN: Messages = { meta: { title: 'WrightKit:守望先锋地图工坊开发工具', description: - '服务器负载的隐患在哪,不用再靠猜。Wright 静态分析守望先锋地图工坊和 OverPy 代码,把每个问题指到具体一行。' + '服务器负载的隐患在哪,不用再靠猜。Wright 静态分析守望先锋地图工坊和 OverPy 代码,把每个问题指到具体一行。', + imageAlt: 'WrightKit,守望先锋地图工坊开发工具', + keywords: '守望先锋, 地图工坊, OverPy, Wright, WrightKit, 服务器负载' }, ui: { skipToContent: '跳到正文', diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..e34cff1 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,90 @@ +import { site } from './site'; +import { localeInfo, localePath, type Locale } from './locales'; + +/** Share-card image. PNG, not SVG: WeChat, QQ, and X do not unfurl SVG. */ +export const shareImage = { + width: 1200, + height: 630, + type: 'image/png', + file: { + en: 'og-en.png', + 'zh-CN': 'og-zh-CN.png' + } as const satisfies Record +}; + +export function pageUrl(locale: Locale): string { + return new URL(localePath(locale), site.url).href; +} + +export function shareImageUrl(locale: Locale): string { + return new URL(`/${shareImage.file[locale]}`, site.url).href; +} + +/** + * Entity data for the page that is actually rendered. No ratings and no price: + * the site has neither, and Google treats invented review markup as spam. + * Localized text stays on WebPage so the two locales do not disagree about one node. + */ +export function structuredData(locale: Locale, page: { title: string; description: string }) { + const canonical = pageUrl(locale); + const organization = `${site.url}/#organization`; + const website = `${site.url}/#website`; + const software = `${site.url}/#wright`; + + return { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + '@id': organization, + name: site.name, + url: pageUrl('en'), + logo: { + '@type': 'ImageObject', + url: new URL('/apple-touch-icon.png', site.url).href, + width: 180, + height: 180 + }, + sameAs: [site.org] + }, + { + '@type': 'WebSite', + '@id': website, + url: pageUrl('en'), + name: site.name, + publisher: { '@id': organization }, + inLanguage: ['en', 'zh-CN'] + }, + { + '@type': 'WebPage', + '@id': `${canonical}#webpage`, + url: canonical, + name: page.title, + description: page.description, + inLanguage: localeInfo[locale].tag, + isPartOf: { '@id': website }, + primaryImageOfPage: { + '@type': 'ImageObject', + url: shareImageUrl(locale), + width: shareImage.width, + height: shareImage.height + }, + about: { '@id': software } + }, + { + '@type': 'SoftwareSourceCode', + '@id': software, + name: 'Wright', + codeRepository: site.github, + license: 'https://www.gnu.org/licenses/agpl-3.0.html', + programmingLanguage: 'Rust', + runtimePlatform: 'macOS, Linux, Windows', + author: { '@id': organization } + } + ] + }; +} + +export function structuredDataJson(locale: Locale, page: { title: string; description: string }): string { + return JSON.stringify(structuredData(locale, page)).replaceAll('<', '\\u003c'); +} diff --git a/src/routes/[[lang=locale]]/+page.svelte b/src/routes/[[lang=locale]]/+page.svelte index a99c0cf..b6dd0d5 100644 --- a/src/routes/[[lang=locale]]/+page.svelte +++ b/src/routes/[[lang=locale]]/+page.svelte @@ -7,35 +7,53 @@ import LanguagesSection from '$lib/components/LanguagesSection.svelte'; import InstallSection from '$lib/components/InstallSection.svelte'; import { site } from '$lib/site'; - import { defaultLocale, localeInfo, localePath, locales, type Locale } from '$lib/locales'; + import { defaultLocale, localeInfo, locales } from '$lib/locales'; import { currentLocale, currentMessages } from '$lib/content'; + import { pageUrl, shareImage, shareImageUrl, structuredDataJson } from '$lib/seo'; const locale = $derived(currentLocale()); const t = $derived(currentMessages()); - - function absoluteUrl(target: Locale) { - return new URL(localePath(target), site.url).href; - } + const image = $derived(shareImageUrl(locale)); + const ld = $derived( + structuredDataJson(locale, { title: t.meta.title, description: t.meta.description }) + ); {t.meta.title} - + + + + {#each locales as alternate (alternate)} - + {/each} - + - + {#each locales.filter((l) => l !== locale) as alternate (alternate)} {/each} - + + + + + + + + + + + + + + + {@html ``} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..c1d67d6 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,36 @@ +import { defaultLocale, localeInfo, locales } from '$lib/locales'; +import { pageUrl } from '$lib/seo'; + +export const prerender = true; + +/** Both homepages, with the same alternates the HTML `hreflang` links declare. */ +export function GET() { + const alternates = [ + ...locales.map( + (locale) => + ` ` + ), + ` ` + ].join('\n'); + + const urls = locales + .map( + (locale) => ` + ${pageUrl(locale)} +${alternates} + ` + ) + .join('\n'); + + const body = ` + +${urls} + +`; + + return new Response(body, { + headers: { + 'content-type': 'application/xml; charset=utf-8' + } + }); +} diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..cf53f92 Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/favicon-32.png b/static/favicon-32.png new file mode 100644 index 0000000..c652502 Binary files /dev/null and b/static/favicon-32.png differ diff --git a/static/og-en.png b/static/og-en.png new file mode 100644 index 0000000..659b84c Binary files /dev/null and b/static/og-en.png differ diff --git a/static/og-zh-CN.png b/static/og-zh-CN.png new file mode 100644 index 0000000..9d63649 Binary files /dev/null and b/static/og-zh-CN.png differ diff --git a/static/robots.txt b/static/robots.txt index c2a49f4..a46cb44 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,2 +1,4 @@ User-agent: * Allow: / + +Sitemap: https://wrightkit.dev/sitemap.xml diff --git a/svelte.config.js b/svelte.config.js index e08de07..1070fd8 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -7,7 +7,7 @@ const config = { kit: { adapter: adapter(), prerender: { - entries: ['/', '/zh-CN'] + entries: ['/', '/zh-CN', '/sitemap.xml'] } } };