diff --git a/.github/assets/readme-hero.json b/.github/assets/readme-hero.json index 2d1b5f6e53..5e029b6c64 100644 --- a/.github/assets/readme-hero.json +++ b/.github/assets/readme-hero.json @@ -1,4 +1,8 @@ { "en": "confirmed up to here Model says \"I'll rerun the failing test.\" Runs a command Bash · npm test Asks permission leaves the sandbox You approve written to the log Gets the result exit 1 · pruned, kept Edits a file resume.ts Turn ends run completed one turn · seven RuntimeEvents · append-only State(t) = Project(Log[0…t])", - "zh-CN": "到这里已确认 模型说 「我重新跑一下失败的测试。」 执行命令 Bash · npm test 请求权限 超出沙箱 你批准了 已写进日志 拿到结果 exit 1 · 裁剪展示,全量保留 编辑文件 resume.ts 本轮结束 运行完成 一轮交互 · 7 条运行时事件 · 只追加写入 State(t) = Project(Log[0…t])" + "zh-CN": "到这里已确认 模型说 「我重新跑一下失败的测试。」 执行命令 Bash · npm test 请求权限 超出沙箱 你批准了 已写进日志 拿到结果 exit 1 · 裁剪展示,全量保留 编辑文件 resume.ts 本轮结束 运行完成 一轮交互 · 7 条运行时事件 · 只追加写入 State(t) = Project(Log[0…t])", + "headline": { + "en": "A high-performance agent workspace that keeps a complete record of everything it did.", + "zh-CN": "一个高性能的 Agent 工作台, 并完整记录 它做过的每一件事。" + } } diff --git a/website/scripts/hero-text.mjs b/website/scripts/hero-text.mjs index c4fc6f9c8d..c227628056 100644 --- a/website/scripts/hero-text.mjs +++ b/website/scripts/hero-text.mjs @@ -34,15 +34,24 @@ const entities = { ' ': ' ', }; -export function heroText(html) { - const start = html.indexOf('
'); - if (start === -1) throw new Error('no hero header in the page'); - const end = html.indexOf('
', start); - return html - .slice(start, end) - .replace(hidden, ' ') +// The social preview image keeps the headline above the scene, so it has +// this on top of what the README hero has. +export function headlineText(html) { + const [, headline] = html.match(/

(.*?)<\/h1>/su) ?? []; + if (!headline) throw new Error('no hero headline in the page'); + return text(headline); +} + +const text = (html) => + html .replace(/<[^>]+>/gu, ' ') .replace(/&[a-z#0-9]+;/gu, (entity) => entities[entity] ?? entity) .replace(/\s+/gu, ' ') .trim(); + +export function heroText(html) { + const start = html.indexOf('
'); + if (start === -1) throw new Error('no hero header in the page'); + const end = html.indexOf('
', start); + return text(html.slice(start, end).replace(hidden, ' ')); } diff --git a/website/scripts/readme-hero.mjs b/website/scripts/readme-hero.mjs index 5407fbe5b6..1b42a830b5 100644 --- a/website/scripts/readme-hero.mjs +++ b/website/scripts/readme-hero.mjs @@ -18,10 +18,12 @@ */ /** - * Renders the README hero images from the built site, so the README shows - * the same headline and RuntimeEvents scene as maka.apache.org. Run + * Renders the README hero images and the social preview images from the + * built site, so the README and every link card show the same headline and + * RuntimeEvents scene as maka.apache.org. Run * `npm --workspace @maka/website run readme-hero` after changing the hero - * copy or styles and commit the PNGs it writes to `.github/assets/`. + * copy or styles and commit the PNGs it writes to `.github/assets/` and + * `website/src/assets/`. */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; @@ -31,10 +33,11 @@ import { fileURLToPath } from 'node:url'; import { chromium } from '@playwright/test'; -import { heroText } from './hero-text.mjs'; +import { headlineText, heroText } from './hero-text.mjs'; const dist = fileURLToPath(new URL('../dist/', import.meta.url)); const assets = fileURLToPath(new URL('../../.github/assets/', import.meta.url)); +const social = fileURLToPath(new URL('../src/assets/', import.meta.url)); const types = { '.html': 'text/html', '.css': 'text/css', @@ -67,6 +70,25 @@ const readmeOnly = ` .scene { margin-top: 0 !important; } `; +// The social preview: the brand, the headline and the scene, centred in the +// 1200×630 frame link previews are cut to. Light only, since a preview shows +// on the sharing site's own background, and the sections below the hero are +// dropped so the frame ends where the scene does. +const socialOnly = ` + .lede, .cta, .fine, .hero ~ *, footer { display: none !important; } + .nav > :not(.brand) { display: none !important; } + html, body { overflow: hidden !important; } + .hero { + box-sizing: border-box !important; + min-height: 570px !important; + padding: 0 0 12px !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + } + .scene { margin-top: 28px !important; } +`; + // npm ci installs the Playwright package but not a browser, so a clean // checkout has to be able to fetch one before this command can run. const executable = (() => { @@ -87,10 +109,13 @@ if (!executable || !existsSync(executable)) { } const manifest = {}; +const headlines = {}; const browser = await chromium.launch(); try { for (const locale of ['en', 'zh-CN']) { - manifest[locale] = heroText(readFileSync(join(dist, locale, 'index.html'), 'utf8')); + const html = readFileSync(join(dist, locale, 'index.html'), 'utf8'); + manifest[locale] = heroText(html); + headlines[locale] = headlineText(html); for (const colorScheme of ['light', 'dark']) { const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, @@ -106,9 +131,24 @@ try { console.log(path); await page.close(); } + const page = await browser.newPage({ + viewport: { width: 1200, height: 630 }, + deviceScaleFactor: 2, + colorScheme: 'light', + reducedMotion: 'reduce', + }); + await page.goto(`${origin}/${locale}/`); + await page.addStyleTag({ content: socialOnly }); + await page.evaluate(() => document.fonts.ready); + const path = join(social, `social.${locale}.png`); + await page.screenshot({ path }); + console.log(path); + await page.close(); } // The copy these images were made from, so the site test can tell when the - // pages have moved on and the committed images have not. + // pages have moved on and the committed images have not. The README heroes + // show the scene; the social previews show the headline above it. + manifest.headline = headlines; const path = join(assets, 'readme-hero.json'); writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`); console.log(path); diff --git a/website/src/assets/social.en.png b/website/src/assets/social.en.png new file mode 100644 index 0000000000..46481767b1 Binary files /dev/null and b/website/src/assets/social.en.png differ diff --git a/website/src/assets/social.zh-CN.png b/website/src/assets/social.zh-CN.png new file mode 100644 index 0000000000..0715c0d59e Binary files /dev/null and b/website/src/assets/social.zh-CN.png differ diff --git a/website/src/components/Preview.astro b/website/src/components/Preview.astro new file mode 100644 index 0000000000..7bd0110e46 --- /dev/null +++ b/website/src/components/Preview.astro @@ -0,0 +1,60 @@ +--- +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// The tags a link preview on X, Slack and the like is built from; nothing +// else on the page reaches the card. The image is a render of the homepage +// hero in the page's language, made by scripts/readme-hero.mjs, and the +// crawlers need its URL to be absolute. +import type { ImageMetadata } from 'astro'; +import socialEn from '../assets/social.en.png'; +import socialZhCN from '../assets/social.zh-CN.png'; +import type { Copy, Locale } from '../copy/types'; + +const images: Record = { en: socialEn, 'zh-CN': socialZhCN }; +const ogLocales: Record = { en: 'en_US', 'zh-CN': 'zh_CN' }; + +interface Props { + copy: Copy; + url: URL; + title: string; + description: string; +} + +const { copy, url, title, description } = Astro.props; +const image = images[copy.locale]; +const src = new URL(image.src, Astro.site); +// The card's alt: what the site says it is, then what the picture shows. +const alt = `${copy.positioning} ${copy.sceneAlt}`; +--- + + + + + + + + + + + + + + + + diff --git a/website/src/copy/en.ts b/website/src/copy/en.ts index aa5d17b508..e0810ddd6c 100644 --- a/website/src/copy/en.ts +++ b/website/src/copy/en.ts @@ -32,6 +32,8 @@ export const en: Copy = { positioning: 'Apache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.', theme: { toDark: 'Switch to dark mode', toLight: 'Switch to light mode' }, + sceneAlt: + 'One turn of RuntimeEvents: the model speaks, runs a command, asks permission, you approve, it gets the result, edits a file, the turn ends.', nav: { docs: 'Docs', downloads: 'Downloads', diff --git a/website/src/copy/types.ts b/website/src/copy/types.ts index 668b9f231c..9ff5442596 100644 --- a/website/src/copy/types.ts +++ b/website/src/copy/types.ts @@ -27,6 +27,9 @@ export interface Copy { siteName: string; positioning: string; theme: { toDark: string; toLight: string }; + // The README hero's alt text, which describes the same scene the social + // preview shows; the site test holds it to README.md's ``. + sceneAlt: string; nav: { docs: string; downloads: string; diff --git a/website/src/copy/zh-CN.ts b/website/src/copy/zh-CN.ts index 6773ce82db..d6ed0fde7c 100644 --- a/website/src/copy/zh-CN.ts +++ b/website/src/copy/zh-CN.ts @@ -26,6 +26,8 @@ export const zhCN: Copy = { siteName: 'Apache Maka (Incubating)', positioning: 'Apache Maka(孵化中)是一个高性能的 Agent 工作台,并完整记录它做过的每一件事。', theme: { toDark: '切换到深色模式', toLight: '切换到浅色模式' }, + sceneAlt: + '一轮交互的运行时事件:模型说、执行命令、请求权限、你批准了、拿到结果、编辑文件、本轮结束。', nav: { docs: '文档', downloads: '下载', diff --git a/website/src/layouts/Site.astro b/website/src/layouts/Site.astro index 8f1e99df94..e6729be832 100644 --- a/website/src/layouts/Site.astro +++ b/website/src/layouts/Site.astro @@ -23,6 +23,7 @@ import '../styles/site.css'; import { Image } from 'astro:assets'; import logo from '../../../apps/desktop/assets/app-icons/sky.png'; import incubator from '../assets/incubator.png'; +import Preview from '../components/Preview.astro'; import { links } from '../copy/links'; import type { Copy, Locale } from '../copy/types'; @@ -70,6 +71,7 @@ const asf = [ {title} + {locales.map((locale) => )} diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 0a5ca04128..5af256182a 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -19,6 +19,12 @@ */ // The root sends visitors to the English homepage at once. Astro's own // redirect page waits two seconds first, so it is written out by hand. +// Link-preview crawlers stop here rather than follow the refresh, so the +// page carries the English homepage's preview tags itself. +import Preview from '../components/Preview.astro'; +import { en } from '../copy/en'; + +const home = new URL('/en/', Astro.site); --- @@ -27,11 +33,13 @@ - - Apache Maka (Incubating) + + {en.siteName} + + - Apache Maka (Incubating) + {en.siteName} diff --git a/website/test/site.test.mjs b/website/test/site.test.mjs index b9c6a8bd26..8f11997bcb 100644 --- a/website/test/site.test.mjs +++ b/website/test/site.test.mjs @@ -22,12 +22,13 @@ * the HTML that will be published rather than on the source that produced it. */ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; import test from 'node:test'; -import { heroText } from '../scripts/hero-text.mjs'; +import { headlineText, heroText } from '../scripts/hero-text.mjs'; const dist = new URL('../dist/', import.meta.url); +const repo = new URL('../../', import.meta.url); const page = (path) => readFileSync(new URL(path, dist), 'utf8'); const locales = ['en', 'zh-CN']; const pages = ['index.html', 'downloads/index.html']; @@ -66,6 +67,51 @@ test('the root redirects to the English homepage without a delay', () => { assert.match(page('index.html'), /content="0;url=\/en\/"/u); }); +const readmeAlt = (locale) => + readFileSync(new URL(locale === 'en' ? 'README.md' : 'README.zh-CN.md', repo), 'utf8').match( + /([^ name === key, + )?.[2]; + +// Link previews on X, Slack and the like are built from these tags alone, so +// every page carries them, the image URL is absolute and the image ships. The +// root page too: crawlers read it as-is rather than follow the meta refresh. +test('every page carries a complete link preview', () => { + for (const path of ['index.html', ...locales.flatMap((l) => pages.map((p) => `${l}/${p}`))]) { + const html = page(path); + const locale = path.startsWith('zh-CN/') ? 'zh-CN' : 'en'; + for (const key of ['og:title', 'og:description', 'og:url']) { + assert.ok(meta(html, key), `${path} ${key}`); + } + // The alt is the language's positioning line plus the scene description the + // README hero already carries, so the two never drift apart. + const alt = meta(html, 'og:image:alt'); + assert.equal(meta(html, 'twitter:image:alt'), alt, path); + assert.equal( + alt, + `${meta(page(`${locale}/index.html`), 'description')} ${readmeAlt(locale)}`, + path, + ); + assert.equal(meta(html, 'twitter:card'), 'summary_large_image', path); + assert.equal(meta(html, 'og:locale'), locale === 'en' ? 'en_US' : 'zh_CN', path); + assert.match(meta(html, 'og:url'), /^https:\/\/maka\.apache\.org\//u, path); + const image = meta(html, 'og:image'); + assert.match(image, /^https:\/\/maka\.apache\.org\/_astro\/social\.[^/]+\.png$/u, path); + assert.equal(meta(html, 'twitter:image'), image, path); + assert.ok(image.includes(`/social.${locale}.`), `${path} shows the ${locale} hero`); + const [width, height] = ['og:image:width', 'og:image:height'].map((k) => Number(meta(html, k))); + assert.equal(width / height, 1200 / 630, path); + assert.ok( + statSync(new URL(image.slice('https://maka.apache.org/'.length), dist)).size > 0, + image, + ); + } +}); + test('every copy button on the downloads page has its own accessible name', () => { for (const locale of locales) { const names = [ @@ -128,16 +174,15 @@ test('the READMEs and the repository description open with the same sentence', ( // The README heroes are screenshots of these pages, so the copy the render // baked in has to be the copy the pages carry now. Compare through the // manifest the render writes, which needs no browser and no pixels. -test('the committed README heroes were rendered from the current hero copy', () => { +test('the committed README heroes and social previews were rendered from the current hero copy', () => { const manifest = JSON.parse( readFileSync(new URL('../../.github/assets/readme-hero.json', import.meta.url), 'utf8'), ); + const rerender = 'run `npm --workspace @maka/website run readme-hero` and commit the images'; for (const locale of locales) { - assert.equal( - heroText(page(`${locale}/index.html`)), - manifest[locale], - `${locale}: run \`npm --workspace @maka/website run readme-hero\` and commit the images`, - ); + const html = page(`${locale}/index.html`); + assert.equal(heroText(html), manifest[locale], `${locale}: ${rerender}`); + assert.equal(headlineText(html), manifest.headline[locale], `${locale} headline: ${rerender}`); } });