Skip to content

Commit 9d61c4d

Browse files
Generate dynamic social previews on GitHub Pages
1 parent 8ba8185 commit 9d61c4d

8 files changed

Lines changed: 632 additions & 11 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Generate community share previews
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- "s/**"
8+
- "og/**"
9+
schedule:
10+
- cron: "*/10 * * * *"
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: write
15+
16+
concurrency:
17+
group: community-share-previews
18+
cancel-in-progress: true
19+
20+
jobs:
21+
generate:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 10
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 22
29+
- name: Generate pages and cards
30+
run: node scripts/generate-share-pages.mjs
31+
- name: Publish generated assets
32+
run: |
33+
git config user.name "github-actions[bot]"
34+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
35+
git add --all s og
36+
if git diff --cached --quiet; then
37+
echo "Generated previews are already current."
38+
exit 0
39+
fi
40+
git commit -m "Generate community share previews [skip ci]"
41+
git push

README.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,12 @@ The canonical repository is
1616
Pages deploys from the default branch root with the custom domain
1717
`link.pythonide.xin` and enforced HTTPS.
1818

19-
The companion `../link-edge/` Cloudflare Worker intercepts only `/s/*` and
20-
`/og/*`. It server-renders per-script Open Graph metadata for crawlers and
21-
creates a 1200×630 PNG card from the existing public community API. All other
22-
routes, including AASA and MCP OAuth, continue to come directly from GitHub
23-
Pages. This split is required because GitHub Pages cannot generate a different
24-
HTML `<head>` for each script and WeChat does not reliably execute page
25-
JavaScript when building a link preview.
19+
The companion `../link-edge/` renderer server-renders per-script Open Graph
20+
metadata and creates a 1200×630 PNG card from the existing public community
21+
API. The GitHub Actions workflow in `.github/workflows/` runs this renderer on
22+
every source publish and every ten minutes, committing generated `/s/*/` pages
23+
and `/og/*` cards to GitHub Pages. This is required because WeChat does not
24+
reliably execute page JavaScript when building a link preview.
2625

2726
To publish changes, copy the contents of this `link-site/` directory to that
2827
repository root and verify the Pages deployment. The DNS record is:
@@ -38,10 +37,9 @@ Do not change the root `@` or `www` records used by the main website. Do not
3837
recreate a repository named `pythonide-link` under the previous owner because
3938
that would break GitHub's repository-transfer redirects.
4039

41-
After publishing the static site, deploy `../link-edge/worker.js` with the
42-
routes declared in `../link-edge/wrangler.jsonc`. The Cloudflare zone must keep
43-
the `link` record proxied for Worker routes to run. The Worker does not replace
44-
or proxy the AASA and OAuth paths.
40+
The publish script copies `../link-edge/` into the link repository as `edge/`
41+
so the workflow and renderer always use the same tested implementation. No DNS
42+
migration or community API change is required.
4543

4644
Before either deployment, run:
4745

edge/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Python IDE link edge renderer
2+
3+
This worker adds server-rendered Open Graph metadata and dynamic PNG preview
4+
cards to the existing `link.pythonide.xin` GitHub Pages site. It does not change
5+
the community API and only intercepts `/s/*` and `/og/*`.
6+
7+
The worker fetches the published `index.html` from the `pythonide-link`
8+
repository, reads public script details from the existing community API, and
9+
replaces the `edge:meta` block before returning the page. Social crawlers such
10+
as WeChat therefore receive the script title and description without executing
11+
JavaScript.
12+
13+
Run `npm test` before deploying. Deploy with Wrangler after authenticating the
14+
Cloudflare account that owns `pythonide.xin`.

edge/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "pythonide-link-edge",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"test": "node --test tests/*.test.js",
7+
"check": "node --check worker.js"
8+
}
9+
}

edge/tests/worker.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import {
4+
handleSharePage,
5+
injectMetadata,
6+
renderCard,
7+
safeHTTPSURL,
8+
socialPayload,
9+
} from '../worker.js';
10+
11+
const script = {
12+
title: '天气卡片 <测试>',
13+
summary: '展示未来天气',
14+
ai_summary: '一个简洁的天气卡片作品',
15+
category: 'appui',
16+
file_type: 'py',
17+
author_name: '社区作者',
18+
content: 'from appui import App\n\napp = App()\napp.run()',
19+
like_count: 12,
20+
run_count: 34,
21+
};
22+
23+
test('builds canonical per-script social metadata', () => {
24+
const meta = socialPayload(script, 'scr_123', 'https://link.pythonide.xin');
25+
assert.equal(meta.title, '天气卡片 <测试>');
26+
assert.equal(meta.description, '一个简洁的天气卡片作品');
27+
assert.equal(meta.url, 'https://link.pythonide.xin/s/scr_123');
28+
assert.equal(meta.image, 'https://link.pythonide.xin/og/script/scr_123.png');
29+
assert.equal(meta.isGeneratedImage, true);
30+
});
31+
32+
test('uses an existing HTTPS cover as the share thumbnail', () => {
33+
const meta = socialPayload({ ...script, cover_image_url: 'https://cdn.example.com/cover.png' }, 'scr_123');
34+
assert.equal(meta.image, 'https://cdn.example.com/cover.png');
35+
assert.equal(meta.isGeneratedImage, false);
36+
assert.equal(safeHTTPSURL('http://cdn.example.com/cover.png'), '');
37+
});
38+
39+
test('injects escaped metadata into the static share page', () => {
40+
const html = '<html><head><!-- edge:meta-start --><meta name="description" content="old"><!-- edge:meta-end --><title>Old</title></head></html>';
41+
const output = injectMetadata(html, socialPayload(script, 'scr_123'));
42+
assert.match(output, / &lt;&gt;/);
43+
assert.match(output, /og:image/);
44+
assert.match(output, /application\/ld\+json/);
45+
assert.doesNotMatch(output, /<title>Old<\/title>/);
46+
});
47+
48+
test('renders a valid 1200 by 630 PNG social card', async () => {
49+
const bytes = await renderCard(script, 'scr_123');
50+
assert.deepEqual(Array.from(bytes.slice(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]);
51+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52+
assert.equal(view.getUint32(16), 1200);
53+
assert.equal(view.getUint32(20), 630);
54+
assert.ok(bytes.byteLength > 10000);
55+
});
56+
57+
test('server-renders metadata before a social crawler receives the page', async () => {
58+
const staticHTML = '<html><head><!-- edge:meta-start --><meta name="description" content="old"><!-- edge:meta-end --><title>Old</title></head><body>Share page</body></html>';
59+
const fetcher = async (url) => {
60+
if (String(url).includes('/v1/scripts/')) {
61+
return new Response(JSON.stringify({ script }), { status: 200, headers: { 'Content-Type': 'application/json' } });
62+
}
63+
return new Response(staticHTML, { status: 200, headers: { 'Content-Type': 'text/html' } });
64+
};
65+
const response = await handleSharePage(
66+
new Request('https://link.pythonide.xin/s/scr_123'),
67+
'scr_123',
68+
{ STATIC_INDEX_URL: 'https://static.example/index.html' },
69+
fetcher,
70+
);
71+
const html = await response.text();
72+
assert.equal(response.status, 200);
73+
assert.match(response.headers.get('content-type'), /text\/html/);
74+
assert.match(html, / &lt;&gt;/);
75+
assert.match(html, /https:\/\/link\.pythonide\.xin\/og\/script\/scr_123\.png/);
76+
});

0 commit comments

Comments
 (0)