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}