Skip to content

Commit d497575

Browse files
author
art.tseng
committed
Add bilingual support (zh-Hant / en) with auto-detect
- New --lang flag with auto / zh-Hant / en choices (default: auto) - detect_lang() picks zh-Hant when articles contain substantial CJK, otherwise en - Localize site chrome: index hero, post page navigation, footer, NFT embed labels, <html lang> - Localize AGENT.md and site README inside the zip - Workflow exposes a 'lang' choice input alongside 'username'
1 parent 711e80f commit d497575

3 files changed

Lines changed: 163 additions & 26 deletions

File tree

.github/workflows/backup.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ on:
77
description: "fxhash username (e.g. aquaponics.kana)"
88
required: true
99
type: string
10+
lang:
11+
description: "Site UI language"
12+
required: false
13+
default: "auto"
14+
type: choice
15+
options:
16+
- auto
17+
- zh-Hant
18+
- en
1019

1120
permissions:
1221
contents: read
@@ -32,7 +41,7 @@ jobs:
3241
- name: Run backup
3342
run: |
3443
mkdir -p out
35-
python fxhash_backup.py "${{ inputs.username }}" --output-dir out
44+
python fxhash_backup.py "${{ inputs.username }}" --lang "${{ inputs.lang }}" --output-dir out
3645
3746
- name: Upload zip
3847
uses: actions/upload-artifact@v4

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ It also handles the fxhash-specific markdown directives:
2020
Tezos NFT), then rendered as a clickable thumbnail figure
2121
- `::video[]{src=...}` → rendered as a `<video controls>` tag
2222

23+
Site UI supports **English and Traditional Chinese**. The chrome (back
24+
button, footer, "View on fxhash", etc.) is auto-picked from the article
25+
content, or you can force it with `--lang en` / `--lang zh-Hant`.
26+
2327
---
2428

2529
## Use it (two ways)
@@ -48,10 +52,12 @@ python fxhash_backup.py <username>
4852

4953
The zip is written to the current directory as `<username>-fxhash.zip`.
5054

51-
Optional flag:
55+
Optional flags:
5256

5357
```bash
5458
python fxhash_backup.py <username> --output-dir ./out
59+
python fxhash_backup.py <username> --lang en # force English UI
60+
python fxhash_backup.py <username> --lang zh-Hant # force Traditional Chinese UI
5561
```
5662

5763
---

fxhash_backup.py

Lines changed: 146 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,57 @@ def ext_from_ct(ct: str, default: str = ".bin") -> str:
145145

146146

147147
# ---------- helpers ----------
148+
# ---------- i18n ----------
149+
STRINGS: dict[str, dict[str, str]] = {
150+
"zh-Hant": {
151+
"html_lang": "zh-Hant",
152+
"site_title": "{username} 的 fxhash 文集",
153+
"post_title_suffix": " — {username} 的 fxhash 文集",
154+
"back": "← 回到文集",
155+
"by_author": "作者",
156+
"view_original": "在 fxhash 看原文",
157+
"editions_label": "版次",
158+
"ipfs": "IPFS",
159+
"footer_post": "由 fxhash GraphQL API 備份 · 原文 © {author}",
160+
"footer_index": "備份自 fxhash · 原文 © {username}",
161+
"hero_summary": ('共 {count} 篇。原發表於 <a href="{profile}">fxhash @{username}</a>,'
162+
'由 <a href="{repo}">fxhash-articles-backup</a> 備份。'),
163+
"embed_open_on": "在 {label} 開啟",
164+
"embed_by": "by", # keep English; works in CJK context too
165+
},
166+
"en": {
167+
"html_lang": "en",
168+
"site_title": "{username}'s fxhash articles",
169+
"post_title_suffix": " — {username}'s fxhash articles",
170+
"back": "← Back to articles",
171+
"by_author": "by",
172+
"view_original": "View on fxhash",
173+
"editions_label": "editions",
174+
"ipfs": "IPFS",
175+
"footer_post": "Backed up via fxhash GraphQL · © {author}",
176+
"footer_index": "Backed up from fxhash · © {username}",
177+
"hero_summary": ('{count} articles, originally published at '
178+
'<a href="{profile}">fxhash @{username}</a>. '
179+
'Backed up by <a href="{repo}">fxhash-articles-backup</a>.'),
180+
"embed_open_on": "open on {label}",
181+
"embed_by": "by",
182+
},
183+
}
184+
185+
CJK_RE = re.compile(r'[一-鿿㐀-䶿]')
186+
187+
188+
def detect_lang(articles: list[dict]) -> str:
189+
"""Heuristic: if articles' bodies have substantial CJK content, return 'zh-Hant'."""
190+
cjk_count = 0
191+
ascii_letter_count = 0
192+
for a in articles:
193+
body = a.get("body") or ""
194+
cjk_count += len(CJK_RE.findall(body))
195+
ascii_letter_count += sum(1 for c in body if c.isascii() and c.isalpha())
196+
return "zh-Hant" if cjk_count >= 200 or cjk_count > ascii_letter_count // 6 else "en"
197+
198+
148199
def slugify_filename(s: str) -> str:
149200
s = s.lower()
150201
s = re.sub(r"[^a-z0-9._-]+", "-", s)
@@ -187,7 +238,8 @@ def render_video_directive(m: re.Match[str]) -> str:
187238
f'controls playsinline preload="metadata"></video>')
188239

189240

190-
def make_render_tezos_pointer(nft_info: dict[tuple[str, str], dict]):
241+
def make_render_tezos_pointer(nft_info: dict[tuple[str, str], dict],
242+
s: dict[str, str]):
191243
def render(m: re.Match[str]) -> str:
192244
parsed = parse_tezos_pointer(m)
193245
if not parsed:
@@ -201,7 +253,7 @@ def render(m: re.Match[str]) -> str:
201253
name = info.get("name") or f"#{token_id}"
202254
creator = info.get("creator") or ""
203255
thumb = info.get("thumb")
204-
creator_html = (f' <span class="meta">by {html.escape(creator)}</span>'
256+
creator_html = (f' <span class="meta">{s["embed_by"]} {html.escape(creator)}</span>'
205257
if creator else "")
206258
if thumb:
207259
return (
@@ -214,9 +266,10 @@ def render(m: re.Match[str]) -> str:
214266
f'<span class="meta">· {label}</span></figcaption>'
215267
f'</figure>'
216268
)
269+
open_on = s["embed_open_on"].format(label=label)
217270
return (
218271
f'<div class="embed-tezos"><p>📦 <a href="{link_url}" '
219-
f'target="_blank" rel="noopener">{html.escape(name)} ({label} 開啟)</a>'
272+
f'target="_blank" rel="noopener">{html.escape(name)} ({open_on})</a>'
220273
f'{creator_html}</p></div>'
221274
)
222275
return render
@@ -252,7 +305,7 @@ def stash_video(m: re.Match[str]) -> str:
252305

253306

254307
# ---------- main pipeline ----------
255-
def build(username: str, output_dir: Path) -> Path:
308+
def build(username: str, output_dir: Path, lang: str = "auto") -> Path:
256309
work = Path(tempfile.mkdtemp(prefix="fxh_build_"))
257310
site = work / "site"
258311
(site / "posts").mkdir(parents=True)
@@ -279,6 +332,15 @@ def build(username: str, output_dir: Path) -> Path:
279332
sys.stderr.write("All articles failed to fetch.\n")
280333
sys.exit(2)
281334

335+
# Resolve language
336+
if lang == "auto":
337+
lang = detect_lang(articles)
338+
if lang not in STRINGS:
339+
sys.stderr.write(f"Unknown lang '{lang}', falling back to 'en'.\n")
340+
lang = "en"
341+
s = STRINGS[lang]
342+
safe_print(f" site language: {lang}")
343+
282344
asset_map: dict[str, str] = {} # ipfs_uri -> "assets/xxx.ext"
283345
asset_bytes_total = 0
284346

@@ -368,7 +430,7 @@ def fetch_to_assets(uri: str | None, name_prefix: str = "img") -> tuple[str, int
368430
safe_print(f" ! NFT {contract}#{token_id} FAILED: {e}")
369431

370432
safe_print("[4/4] Rendering HTML & writing manifest…")
371-
render_tezos_pointer = make_render_tezos_pointer(nft_info)
433+
render_tezos_pointer = make_render_tezos_pointer(nft_info, s)
372434

373435
def rewrite_ipfs_in_md(body_md: str) -> str:
374436
def repl(m: re.Match[str]) -> str:
@@ -410,28 +472,31 @@ def repl(m: re.Match[str]) -> str:
410472
editions = a.get("editions", 0)
411473
author = (a.get("author") or {}).get("name") or username
412474

475+
post_title_suffix = s["post_title_suffix"].format(username=html.escape(username))
476+
editions_str = (f"{s['editions_label']} {editions}" if lang == "zh-Hant"
477+
else f"{editions} {s['editions_label']}")
413478
page = f"""<!doctype html>
414-
<html lang="zh-Hant">
479+
<html lang="{s['html_lang']}">
415480
<head>
416481
<meta charset="utf-8" />
417482
<meta name="viewport" content="width=device-width, initial-scale=1" />
418-
<title>{title_esc}{html.escape(username)} 的 fxhash 文集</title>
483+
<title>{title_esc}{post_title_suffix}</title>
419484
<meta name="description" content="{desc_esc}" />
420485
<link rel="stylesheet" href="../style.css" />
421486
</head>
422487
<body>
423488
<main><article>
424-
<p><a href="../index.html">← 回到文集</a></p>
489+
<p><a href="../index.html">{s['back']}</a></p>
425490
<h1>{title_esc}</h1>
426491
<p class="meta"><time datetime="{a['createdAt']}">{created}</time>
427-
· 作者 <a href="https://www.fxhash.xyz/u/{html.escape(author)}">@{html.escape(author)}</a>
428-
· <a href="https://old.fxhash.xyz/article/{html.escape(a['slug'])}" target="_blank" rel="noopener">在 fxhash 看原文</a>{gateways_html}
429-
· 版次 {editions}</p>
492+
· {s['by_author']} <a href="https://www.fxhash.xyz/u/{html.escape(author)}">@{html.escape(author)}</a>
493+
· <a href="https://old.fxhash.xyz/article/{html.escape(a['slug'])}" target="_blank" rel="noopener">{s['view_original']}</a>{gateways_html}
494+
· {editions_str}</p>
430495
{tags_html}{thumb_html}<div class="content">
431496
{body_html}
432497
</div>
433498
</article></main>
434-
<footer>由 fxhash GraphQL API 備份 · 原文 © {html.escape(author)}</footer>
499+
<footer>{s['footer_post'].format(author=html.escape(author))}</footer>
435500
</body>
436501
</html>
437502
"""
@@ -473,25 +538,32 @@ def repl(m: re.Match[str]) -> str:
473538
</div>
474539
<time datetime="{a['createdAt']}">{a['createdAt'][:10]}</time>
475540
</li>""")
541+
site_title = s["site_title"].format(username=html.escape(username))
542+
profile_url = f"https://www.fxhash.xyz/u/{html.escape(username)}/articles"
543+
repo_url = "https://github.com/javaing/fxhash-articles-backup"
544+
hero_summary = s["hero_summary"].format(
545+
count=len(articles), profile=profile_url,
546+
username=html.escape(username), repo=repo_url,
547+
)
476548
index_html = f"""<!doctype html>
477-
<html lang="zh-Hant">
549+
<html lang="{s['html_lang']}">
478550
<head>
479551
<meta charset="utf-8" />
480552
<meta name="viewport" content="width=device-width, initial-scale=1" />
481-
<title>{html.escape(username)} 的 fxhash 文集</title>
553+
<title>{site_title}</title>
482554
<link rel="stylesheet" href="style.css" />
483555
</head>
484556
<body>
485557
<main>
486558
<section class="hero">
487-
<h1>{html.escape(username)} 的 fxhash 文集</h1>
488-
<p>{len(articles)} 篇。原發表於 <a href="https://www.fxhash.xyz/u/{html.escape(username)}/articles">fxhash @{html.escape(username)}</a>,由 <a href="https://github.com/javaing/fxhash-articles-backup">fxhash-articles-backup</a> 備份。</p>
559+
<h1>{site_title}</h1>
560+
<p>{hero_summary}</p>
489561
</section>
490562
<ol class="post-list">
491563
{chr(10).join(items_html)}
492564
</ol>
493565
</main>
494-
<footer>備份自 fxhash · 原文 © {html.escape(username)}</footer>
566+
<footer>{s['footer_index'].format(username=html.escape(username))}</footer>
495567
</body>
496568
</html>
497569
"""
@@ -529,9 +601,9 @@ def repl(m: re.Match[str]) -> str:
529601
)
530602

531603
# ---- AGENT.md, README.md ----
532-
(site / "AGENT.md").write_text(AGENT_TMPL.format(username=username), encoding="utf-8")
604+
(site / "AGENT.md").write_text(AGENT_TMPL[lang].format(username=username), encoding="utf-8")
533605
(site / "README.md").write_text(
534-
SITE_README_TMPL.format(
606+
SITE_README_TMPL[lang].format(
535607
username=username, count=len(articles),
536608
graphql=GRAPHQL,
537609
profile=f"https://old.fxhash.xyz/u/{username}/articles",
@@ -667,7 +739,8 @@ def repl(m: re.Match[str]) -> str:
667739
}
668740
"""
669741

670-
AGENT_TMPL = """# AGENT.md
742+
AGENT_TMPL = {
743+
"zh-Hant": """# AGENT.md
671744
672745
這是 {username} 的 fxhash 文章靜態網站包。
673746
@@ -676,9 +749,21 @@ def repl(m: re.Match[str]) -> str:
676749
- 不需要 npm install。
677750
- 不需要 build command。
678751
- 若使用 GitHub Pages、Netlify、Vercel static upload,也請把根目錄當 publish directory。
679-
"""
752+
""",
753+
"en": """# AGENT.md
680754
681-
SITE_README_TMPL = """# {username}-fxhash
755+
This is a static-site bundle of @{username}'s fxhash articles.
756+
757+
Deploy rules:
758+
- Drag the whole folder into Cloudflare Workers & Pages → Upload your static files.
759+
- No `npm install` required.
760+
- No build command required.
761+
- For GitHub Pages, Netlify, or Vercel static upload, treat the root folder as the publish directory.
762+
""",
763+
}
764+
765+
SITE_README_TMPL = {
766+
"zh-Hant": """# {username}-fxhash
682767
683768
@{username} 的 fxhash 靜態文章備份站,共 {count} 篇。
684769
@@ -708,7 +793,39 @@ def repl(m: re.Match[str]) -> str:
708793
## 重新產生
709794
710795
由 [fxhash-articles-backup](https://github.com/javaing/fxhash-articles-backup) 工具產出。
711-
"""
796+
""",
797+
"en": """# {username}-fxhash
798+
799+
A static-site backup of @{username}'s fxhash articles — {count} posts.
800+
801+
## Easiest deploy
802+
803+
1. Unzip this archive.
804+
2. Open Cloudflare Workers & Pages → Upload your static files.
805+
3. Drag the unzipped folder in.
806+
4. After the file list appears, click Deploy.
807+
808+
No Node.js, no GitHub, no build command required.
809+
810+
## Contents
811+
812+
- `index.html` — landing page, newest first
813+
- `posts/` — one HTML file per article
814+
- `assets/` — article cover thumbnails, inline images, videos, and embedded NFT thumbnails (downloaded from IPFS)
815+
- `manifest.json` — machine-readable backup index
816+
- `style.css` — site styles
817+
818+
## Source & license
819+
820+
- Source: fxhash GraphQL API (`{graphql}`)
821+
- Original posts: <{profile}>
822+
- Tezos NFT embeds (`tezos-storage-pointer`) in articles have been resolved to fxhash / objkt.com links with a representative thumbnail downloaded; the NFT artifacts themselves are not bundled.
823+
824+
## Regenerate
825+
826+
Produced by [fxhash-articles-backup](https://github.com/javaing/fxhash-articles-backup).
827+
""",
828+
}
712829

713830

714831
# ---------- CLI ----------
@@ -722,8 +839,13 @@ def main(argv: list[str] | None = None) -> int:
722839
"-o", "--output-dir", default=".",
723840
help="Where to write the resulting zip (default: current dir)",
724841
)
842+
p.add_argument(
843+
"-l", "--lang", default="auto", choices=["auto", "zh-Hant", "en"],
844+
help="Site UI language. 'auto' picks zh-Hant if articles contain "
845+
"substantial Chinese, otherwise 'en'. (default: auto)",
846+
)
725847
args = p.parse_args(argv)
726-
out_zip = build(args.username, Path(args.output_dir).resolve())
848+
build(args.username, Path(args.output_dir).resolve(), lang=args.lang)
727849
return 0
728850

729851

0 commit comments

Comments
 (0)