Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
149 changes: 149 additions & 0 deletions scripts/share-cards/render.swift
Original file line number Diff line number Diff line change
@@ -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)
75 changes: 72 additions & 3 deletions scripts/verify-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -33,6 +45,18 @@ for (const page of pages) {
`<link rel="canonical" href="${page.canonical}"`,
...pages.map((alt) => `<link rel="alternate" hreflang="${alt.lang}" href="${alt.canonical}"`),
`<link rel="alternate" hreflang="x-default" href="${SITE_URL}/"`,
`<meta property="og:image" content="${page.image}"`,
`<meta property="og:image:width" content="1200"`,
`<meta property="og:image:height" content="630"`,
`<meta property="og:image:alt" content="${page.imageAlt}"`,
`<meta name="twitter:card" content="summary_large_image"`,
`<meta name="twitter:image" content="${page.image}"`,
`<meta itemprop="image" content="${page.image}"`,
`<meta name="robots" content="index, follow, max-image-preview:large"`,
`<meta name="applicable-device" content="pc,mobile"`,
`<script type="application/ld+json">`,
`"@type":"WebPage"`,
`"url":"${page.canonical}"`,
...literals
];
for (const snippet of expected) {
Expand All @@ -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(`<loc>${page.canonical}</loc>`)) {
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.`);
2 changes: 2 additions & 0 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="%sveltekit.assets%/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
Expand Down
4 changes: 3 additions & 1 deletion src/lib/content/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/lib/content/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const zhCN: Messages = {
meta: {
title: 'WrightKit:守望先锋地图工坊开发工具',
description:
'服务器负载的隐患在哪,不用再靠猜。Wright 静态分析守望先锋地图工坊和 OverPy 代码,把每个问题指到具体一行。'
'服务器负载的隐患在哪,不用再靠猜。Wright 静态分析守望先锋地图工坊和 OverPy 代码,把每个问题指到具体一行。',
imageAlt: 'WrightKit,守望先锋地图工坊开发工具',
keywords: '守望先锋, 地图工坊, OverPy, Wright, WrightKit, 服务器负载'
},
ui: {
skipToContent: '跳到正文',
Expand Down
90 changes: 90 additions & 0 deletions src/lib/seo.ts
Original file line number Diff line number Diff line change
@@ -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<Locale, string>
};

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');
}
Loading
Loading