@@ -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+
148199def 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