Skip to content

Commit 0d74a34

Browse files
Migrate public work shares to Community V2
1 parent f6b327a commit 0d74a34

9 files changed

Lines changed: 224 additions & 79 deletions

File tree

.github/workflows/generate-share-pages.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ on:
77
- "s/**"
88
- "og/**"
99
schedule:
10-
- cron: "*/5 * * * *"
10+
# Dynamic edge rendering is immediate. This job only refreshes the static
11+
# fallback and intentionally shares the Community recovery cadence instead
12+
# of waking TableStore every five minutes.
13+
- cron: "17 */6 * * *"
1114
workflow_dispatch:
1215

1316
permissions:

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ Pages deploys from the default branch root with the custom domain
2121
`link.pythonide.xin` and enforced HTTPS.
2222

2323
The companion `../link-edge/` renderer server-renders per-script Open Graph
24-
metadata and creates a 1200×630 PNG card from the existing public community
25-
API. The GitHub Actions workflow in `.github/workflows/` runs this renderer on
26-
every source publish and every five minutes, committing generated `/s/*/` pages
27-
and `/og/*` cards to GitHub Pages. This is required because WeChat does not
28-
reliably execute page JavaScript when building a link preview.
24+
metadata and creates a 1200×630 PNG card from the restricted public Community
25+
V2 work-share projection. The GitHub Actions workflow in `.github/workflows/`
26+
runs this renderer on every source publish and every six hours, committing
27+
generated `/s/*/` pages and `/og/*` cards to GitHub Pages. Dynamic edge
28+
rendering remains immediate; the generated files are only a fallback for
29+
crawlers such as WeChat that do not reliably execute page JavaScript. The six-
30+
hour cadence avoids waking the Community database every five minutes.
2931

3032
To publish changes, copy the contents of this `link-site/` directory to that
3133
repository root and verify the Pages deployment. The DNS record is:
@@ -42,8 +44,9 @@ recreate a repository named `pythonide-link` under the previous owner because
4244
that would break GitHub's repository-transfer redirects.
4345

4446
The publish script copies `../link-edge/` into the link repository as `edge/`
45-
so the workflow and renderer always use the same tested implementation. No DNS
46-
migration or community API change is required.
47+
so the workflow and renderer always use the same tested implementation. Both
48+
the browser fallback and edge renderer use `community-api.pythonide.xin` V2;
49+
they must never restore a direct `fcapp.run` or `/v1/scripts` dependency.
4750

4851
Before either deployment, run:
4952

assets/share-page.js

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
(function (global) {
22
'use strict';
33

4-
const API_BASE = 'https://community-api-axuaystczl.cn-hangzhou.fcapp.run';
54
const COMMUNITY_API_BASE = 'https://community-api.pythonide.xin/v2/community';
65
const APP_STORE_URL = 'https://apps.apple.com/app/id6753987304';
76
const SITE_ORIGIN = 'https://link.pythonide.xin';
@@ -11,12 +10,14 @@
1110

1211
const CATEGORY_LABELS = {
1312
python: 'Python',
13+
games: '游戏',
1414
miniapp: 'MiniApp',
1515
appui: 'AppUI',
1616
html: 'HTML',
1717
scene: 'Scene',
1818
pygame: 'Pygame',
1919
widget: 'Widget',
20+
markdown: 'Markdown',
2021
other: '社区作品',
2122
};
2223

@@ -409,9 +410,11 @@
409410
}
410411

411412
function filePresentation(script) {
412-
const fileType = String(script.file_type || '').trim().replace(/^\./, '').toLowerCase();
413+
const fileName = String(script.fileName || '').trim();
414+
const fileType = String(script.fileType || script.file_type || fileName.split('.').pop() || '')
415+
.trim().replace(/^\./, '').toLowerCase();
413416
const category = String(script.category || 'other').trim().toLowerCase();
414-
const runtime = String(script.runtime || '').trim();
417+
const runtime = String(script.runtimeKind || script.runtime || '').trim();
415418
return {
416419
badge: FILE_LABELS[fileType] || FILE_LABELS[category] || String(fileType || category || 'CODE').slice(0, 4).toUpperCase(),
417420
categoryLabel: CATEGORY_LABELS[category] || category || '社区作品',
@@ -421,10 +424,11 @@
421424
}
422425

423426
function isProjectScript(script) {
424-
const contentMode = String(script.content_mode || '').toLowerCase();
427+
const contentMode = String(script.contentMode || script.content_mode || '').toLowerCase();
425428
const tags = normalizedTags(script).map((value) => value.toLowerCase());
426429
return contentMode === 'project_package'
427-
|| Boolean(script.package_id)
430+
|| String(script.attachmentKind || '').toLowerCase() === 'project'
431+
|| Boolean(script.packageID || script.package_id)
428432
|| tags.includes('project')
429433
|| tags.includes('project_package')
430434
|| tags.includes('community_content_mode:project_package');
@@ -433,10 +437,12 @@
433437
function typePresentation(script, languageValue = 'zh') {
434438
const locale = languageValue === 'en' ? 'en' : 'zh';
435439
const category = String(script.category || '').trim().toLowerCase();
436-
const fileType = String(script.file_type || '').trim().replace(/^\./, '').toLowerCase();
440+
const fileName = String(script.fileName || '').trim();
441+
const fileType = String(script.fileType || script.file_type || fileName.split('.').pop() || '')
442+
.trim().replace(/^\./, '').toLowerCase();
437443
const tags = normalizedTags(script).map((value) => value.toLowerCase());
438444
const taggedRuntime = tags.find((value) => value.startsWith('community_runtime:'))?.split(':').slice(1).join(':') || '';
439-
const runtime = String(script.runtime || taggedRuntime).trim().toLowerCase();
445+
const runtime = String(script.runtimeKind || script.runtime || taggedRuntime).trim().toLowerCase();
440446
const project = isProjectScript(script);
441447
const format = project
442448
? (locale === 'en' ? 'project' : '项目')
@@ -474,7 +480,8 @@
474480

475481
function shareRevision(script, scriptID = '') {
476482
const source = String(
477-
script?.content_hash || script?.updated_at || script?.approved_at || script?.version || scriptID || 'preview',
483+
script?.revision || script?.updatedAt || script?.content_hash || script?.updated_at
484+
|| script?.approved_at || script?.version || scriptID || 'preview',
478485
);
479486
let hash = 2166136261;
480487
for (const character of source) {
@@ -485,13 +492,16 @@
485492
}
486493

487494
function shareImageURL(script, scriptID) {
488-
const cover = safeImageURL(script?.cover_image_url || script?.preview_image_url || script?.thumbnail_url);
495+
const cover = safeImageURL(
496+
script?.coverURL || script?.cover_image_url || script?.preview_image_url || script?.thumbnail_url,
497+
);
489498
if (cover) return cover;
490499
return `${SITE_ORIGIN}/og/script/${encodeURIComponent(scriptID)}.png?v=${shareRevision(script, scriptID)}`;
491500
}
492501

493502
function socialDescription(script) {
494-
return String(script.ai_summary || script.summary || DEFAULT_DESCRIPTION).trim().slice(0, 150) || DEFAULT_DESCRIPTION;
503+
return String(script.ai_summary || script.summary || script.body || DEFAULT_DESCRIPTION)
504+
.trim().slice(0, 150) || DEFAULT_DESCRIPTION;
495505
}
496506

497507
function hostnameFor(value) {
@@ -792,17 +802,20 @@
792802

793803
function renderAuthor(script) {
794804
const fallbackAuthor = language === 'en' ? 'Community creator' : '社区创作者';
795-
const author = String(script.author_name || fallbackAuthor).trim() || fallbackAuthor;
796-
const date = formatDate(script.updated_at || script.approved_at || script.created_at);
805+
const author = String(script.author?.name || script.authorName || script.author_name || fallbackAuthor)
806+
.trim() || fallbackAuthor;
807+
const date = formatDate(script.updatedAt || script.updated_at || script.approved_at || script.createdAt || script.created_at);
797808
setText(el.authorName, author);
798809
setText(el.authorDetail, tr('updated')(date));
799810
setText(el.authorInitial, Array.from(author)[0] || 'P');
800811
el.authorRow.hidden = false;
801812
el.authorAvatar.hidden = true;
802813
el.authorInitial.hidden = false;
803814

804-
const avatarID = String(script.author_avatar_id || '').trim();
805-
const explicitAvatar = safeImageURL(script.author_avatar_url || script.avatar_url || script.user_avatar_url);
815+
const avatarID = String(script.author?.avatarSeed || script.author_avatar_id || '').trim();
816+
const explicitAvatar = safeImageURL(
817+
script.author?.avatarURL || script.author_avatar_url || script.avatar_url || script.user_avatar_url,
818+
);
806819
const avatarURL = explicitAvatar || (avatarID
807820
? `https://api.dicebear.com/9.x/adventurer/png?seed=${encodeURIComponent(avatarID)}&size=160`
808821
: '');
@@ -862,11 +875,13 @@
862875

863876
function renderProject(script, presentation) {
864877
const facts = [
865-
script.entry_file ? `${language === 'en' ? 'Entry' : '入口'} ${script.entry_file}` : '',
866-
Number(script.package_file_count) > 0
867-
? `${script.package_file_count} ${language === 'en' ? 'files' : '个文件'}`
878+
(script.entryFile || script.entry_file)
879+
? `${language === 'en' ? 'Entry' : '入口'} ${script.entryFile || script.entry_file}`
868880
: '',
869-
formatBytes(script.package_size_bytes),
881+
Number(script.packageFileCount ?? script.package_file_count) > 0
882+
? `${script.packageFileCount ?? script.package_file_count} ${language === 'en' ? 'files' : '个文件'}`
883+
: '',
884+
formatBytes(script.packageSizeBytes ?? script.package_size_bytes),
870885
].filter(Boolean);
871886
renderPoster(presentation, facts);
872887
}
@@ -891,10 +906,10 @@
891906
function renderScriptStats(script) {
892907
const locale = language === 'en' ? 'en-US' : 'zh-CN';
893908
renderStats([
894-
{ value: formatCount(script.view_count, locale), label: tr('views') },
895-
{ value: formatCount(script.like_count, locale), label: tr('likes') },
896-
{ value: formatCount(script.run_count, locale), label: tr('runs') },
897-
{ value: formatCount(script.download_count, locale), label: tr('imports') },
909+
{ value: formatCount(script.viewCount ?? script.view_count, locale), label: tr('views') },
910+
{ value: formatCount(script.likeCount ?? script.like_count, locale), label: tr('likes') },
911+
{ value: formatCount(script.runCount ?? script.run_count, locale), label: tr('runs') },
912+
{ value: formatCount(script.downloadCount ?? script.download_count, locale), label: tr('imports') },
898913
]);
899914
}
900915

@@ -907,8 +922,10 @@
907922
const title = String(script.title || tr('community')).trim() || tr('community');
908923
const description = socialDescription(script);
909924
const presentation = typePresentation(script, language);
910-
const coverURL = safeImageURL(script.cover_image_url || script.preview_image_url || script.thumbnail_url);
911-
const lines = previewLines(script.content, 34, 8000);
925+
const coverURL = safeImageURL(
926+
script.coverURL || script.cover_image_url || script.preview_image_url || script.thumbnail_url,
927+
);
928+
const lines = previewLines(script.sourcePreview || script.content, 34, 8000);
912929

913930
setText(el.eyebrow, tr('communityWork'));
914931
setText(el.title, title);
@@ -946,7 +963,7 @@
946963
renderScriptStats(script);
947964

948965
updatePageMetadata(title, description, shareImageURL(script, route.id));
949-
setText(el.actionDescription, script.ai_usage_hint || tr('actionDescription'));
966+
setText(el.actionDescription, script.aiUsageHint || script.ai_usage_hint || tr('actionDescription'));
950967
if (!coverURL) setLoading(false);
951968
}
952969

@@ -986,7 +1003,7 @@
9861003
if (!node) return null;
9871004
try {
9881005
const script = JSON.parse(node.textContent || '{}');
989-
return String(script?.script_id || '') === String(scriptID || '') ? script : null;
1006+
return String(script?.id || script?.script_id || '') === String(scriptID || '') ? script : null;
9901007
} catch {
9911008
return null;
9921009
}
@@ -1005,7 +1022,7 @@
10051022
}
10061023
}
10071024

1008-
async function loadScript(initialScript = null) {
1025+
async function loadScript(initialScript = null, forceRemote = false) {
10091026
currentView = 'script-loading';
10101027
hideStatus();
10111028
el.coverImage.onload = null;
@@ -1014,18 +1031,24 @@
10141031
const hasInitialScript = Boolean(initialScript);
10151032
if (hasInitialScript) renderScript(initialScript);
10161033
else setLoading(true);
1017-
retryAction = () => loadScript(initialScript);
1034+
retryAction = () => loadScript(null, true);
1035+
if (hasInitialScript && !forceRemote) return;
10181036
const controller = new AbortController();
10191037
const timeout = setTimeout(() => controller.abort(), 20000);
10201038
try {
1021-
const response = await fetch(`${API_BASE}/v1/scripts/${encodeURIComponent(route.id)}`, {
1039+
const response = await fetch(
1040+
`${COMMUNITY_API_BASE}/works/${encodeURIComponent(route.id)}/share`,
1041+
{
10221042
headers: { Accept: 'application/json' },
10231043
signal: controller.signal,
1024-
});
1044+
},
1045+
);
10251046
if (!response.ok) throw new Error(`HTTP ${response.status}`);
10261047
const payload = await response.json();
1027-
if (!payload || !payload.script) throw new Error('Missing script');
1028-
renderScript(payload.script);
1048+
if (!payload?.data || String(payload.data.id || '') !== String(route.id || '')) {
1049+
throw new Error('Missing work share payload');
1050+
}
1051+
renderScript(payload.data);
10291052
} catch {
10301053
if (!hasInitialScript) renderScriptError();
10311054
} finally {

edge/README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Python IDE link edge renderer
22

33
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 the AASA endpoint, `/s/*`,
4+
cards to the existing `link.pythonide.xin` GitHub Pages site. It only reads the
5+
restricted Community V2 public projections and intercepts the AASA endpoint, `/s/*`,
66
`/community/*`, and `/og/*`.
77

88
The same module also supports Cloudflare Pages advanced mode. When the Pages
@@ -23,10 +23,11 @@ The AASA route serves the reviewed association document directly with an
2323
that contract before deployment.
2424

2525
The worker fetches the published `index.html` from the `pythonide-link`
26-
repository, reads public script details from the existing community API, and
27-
replaces the `edge:meta` block before returning the page. Social crawlers such
28-
as WeChat therefore receive the script title and description without executing
29-
JavaScript.
26+
repository, reads public work details from
27+
`GET /v2/community/works/:scriptID/share`, and replaces the `edge:meta` block
28+
before returning the page. That projection never contains a project package
29+
URL, OSS key, or complete ZIP. Social crawlers such as WeChat therefore receive
30+
the work title and description without executing JavaScript.
3031

3132
Run `npm test` before deploying. Deploy with Wrangler after authenticating the
3233
Cloudflare account that owns `pythonide.xin`.

edge/tests/worker.test.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,27 @@ test('renders valid post and profile social cards without storing public records
301301

302302
test('server-renders metadata and first-paint data before a client receives the page', async () => {
303303
const staticHTML = '<html><head><!-- edge:meta-start --><meta name="description" content="old"><!-- edge:meta-end --><title>Old</title><script id="initial-script-data" type="application/json">{}</script></head><body>Share page</body></html>';
304+
const v2Script = {
305+
id: 'scr_123',
306+
title: script.title,
307+
summary: script.summary,
308+
category: 'python',
309+
attachmentKind: 'python',
310+
runtimeKind: 'python',
311+
fileName: 'main.py',
312+
contentMode: 'single_file',
313+
sourcePreview: script.content,
314+
author: { name: '社区作者' },
315+
likeCount: 12,
316+
runCount: 34,
317+
updatedAt: '2026-08-17T01:00:00Z',
318+
revision: 2,
319+
};
320+
const requests = [];
304321
const fetcher = async (url) => {
305-
if (String(url).includes('/v1/scripts/')) {
306-
return new Response(JSON.stringify({ script }), { status: 200, headers: { 'Content-Type': 'application/json' } });
322+
requests.push(String(url));
323+
if (String(url).includes('/v2/community/works/scr_123/share')) {
324+
return new Response(JSON.stringify({ data: v2Script }), { status: 200, headers: { 'Content-Type': 'application/json' } });
307325
}
308326
return new Response(staticHTML, { status: 200, headers: { 'Content-Type': 'text/html' } });
309327
};
@@ -319,4 +337,6 @@ test('server-renders metadata and first-paint data before a client receives the
319337
assert.match(html, / &lt;&gt;/);
320338
assert.match(html, /https:\/\/link\.pythonide\.xin\/og\/script\/scr_123\.png/);
321339
assert.match(html, /"title":" \\u003c>"/);
340+
assert.ok(requests.some((url) => url.includes('/v2/community/works/scr_123/share')));
341+
assert.ok(requests.every((url) => !url.includes('/v1/scripts')));
322342
});

0 commit comments

Comments
 (0)