Why one repo, two sites
A Chinese blog already lived at the domain root: example.com/. The goal was an English sibling at example.com/en/ without a second repository, a second domain, or a second deploy pipeline. That constraint drives every decision below.
The layout that ended up working:
repo/
├── CNAME # single domain for both languages
├── published/ # Chinese article manifests (one JSON per post)
├── published_en/ # English article manifests
├── templates/ # shared design system, Jinja2
├── static/ # shared CSS, JS, fonts
├── build.py # generator for the root site
├── build_en.py # generator for /en
└── docs/ # GitHub Pages output (root = Chinese, /en = English)
├── index.html
├── sitemap.xml
├── rss.xml
└── en/
├── index.html
├── sitemap.xml
└── rss.xml
GitHub Pages serves docs/ from the branch, so the directory tree is the URL tree. The Chinese site is the root; the English site is a subdirectory. One CNAME file, one certificate, one git push.
Separate manifests prevent cross-language idempotency bugs
The first version kept one manifest directory with a lang field per article. Generation was supposed to be idempotent: run build.py twice, get identical output. It wasn't. The bug came from how the generator picked slugs and resolved "previous/next" links.
With a single pool, the Chinese generator saw English articles as candidates for adjacency and for slug-collision checks. A Chinese post at 2024/03/foo and an English post at 2024/03/foo (same slug, different language) would race for the same output path. Whichever ran second silently overwrote the first. Worse, rerunning the build could change which one won if the manifest glob order shifted. Non-deterministic output from deterministic input is the worst class of build bug: it looks fine locally, then a post disappears in production a week later.
The fix is boring and total: two manifest directories, published/ and published_en/, and each generator reads only its own. No lang filter, no shared slug namespace, no cross-language adjacency. The English generator cannot see Chinese manifests, so it cannot be affected by them.
from pathlib import Path
import json
MANIFEST_DIRS = {
"zh": Path("published"),
"en": Path("published_en"),
}
def load_manifests(lang: str) -> list[dict]:
"""Load only this language's manifests, sorted newest first."""
posts = []
for path in MANIFEST_DIRS[lang].glob("*.json"):
with path.open(encoding="utf-8") as f:
post = json.load(f)
post["_source"] = path.name
posts.append(post)
posts.sort(key=lambda p: p["date"], reverse=True)
slugs = [p["slug"] for p in posts]
if len(slugs) != len(set(slugs)):
raise ValueError(f"duplicate slug in {lang} manifests")
return posts
The duplicate-slug check now guards a single language, which is exactly the invariant you want: slugs must be unique within a site, not across sites, because the two sites live at different URL prefixes.
The generators share the design system but nothing else. build.py and build_en.py both import templates/ and static/, both write into docs/, but each owns its manifest set and its own URL prefix. If the English site needs a different layout tweak later, it can diverge without touching the Chinese build.
One prefix constant keeps links consistent
Every internal link needs two pieces: the site root ("" for Chinese, "en" for English) and the article path. Hardcoding either one is how you end up with an English page linking to a Chinese article, or a link that works locally (/en/...) but 404s under a project-page base path.
The fix is a single constant per build plus a helper that joins them. Nothing else in the codebase constructs internal URLs by string concatenation.
# In build.py
SITE_PREFIX = "" # Chinese site at the domain root
LANG = "zh"
# In build_en.py
SITE_PREFIX = "en" # English site under /en
LANG = "en"
Both files import one shared module for URL construction:
# urls.py
def site_url(prefix: str, path: str = "") -> str:
"""Absolute-path URL for an internal page.
site_url("", "about/") -> "/about/"
site_url("en", "about/") -> "/en/about/"
"""
parts = [p.strip("/") for p in (prefix, path) if p.strip("/")]
return "/" + "/".join(parts) + ("/" if path.endswith("/") or not path else "")
Then the templates never see raw paths. The build passes a url function into the Jinja2 environment:
from jinja2 import Environment, FileSystemLoader
from urls import site_url
def make_env(prefix: str) -> Environment:
env = Environment(loader=FileSystemLoader("templates"), autoescape=True)
env.globals["url"] = lambda path="": site_url(prefix, path)
return env
In templates, links become {{ url('posts/' + post.slug + '/') }} and assets become {{ url('static/main.css') }}. Because url closes over the prefix, the same template renders correct links for both languages. A Chinese page emits /posts/foo/; an English page emits /en/posts/foo/. There is no branch in the template and no opportunity to forget the prefix.
The asset trap
Shared assets under static/ are the one place where a prefix-relative link is wrong. If the English page links to /en/static/main.css but the file only exists at /static/main.css, you get a 404 on every English page. Two options: emit absolute asset URLs from the domain root, or mirror the assets under both prefixes. Mirroring doubles the files and the cache entries. The cleaner choice is a separate helper for assets that ignores the language prefix:
env.globals["asset"] = lambda path: "/static/" + path.lstrip("/")
Templates use {{ asset('main.css') }} for CSS/JS/fonts and {{ url(...) }} for anything that belongs to a language. One rule, applied consistently, and the asset 404s stop.
Cross-language navigation in both navs
Each nav needs a link to the equivalent page in the other language, not just the other homepage. On an article page, "English" should go to that article's English version if it exists; otherwise fall back to the English index.
That requires a mapping between the two manifest sets. The simplest reliable key is an explicit translation_key field present in both manifests for paired articles:
def build_translation_map(zh_posts, en_posts):
"""Map (lang, translation_key) -> slug for cross-language links."""
mapping = {}
for lang, posts in (("zh", zh_posts), ("en", en_posts)):
for post in posts:
key = post.get("translation_key")
if key:
mapping[(lang, key)] = post["slug"]
return mapping
When rendering a Chinese post with translation_key: "gh-pages-bilingual", the nav looks up ("en", "gh-pages-bilingual"). If found, the link points to /en/posts/<slug>/; if not, it points to /en/. Same logic in reverse for the English site. Articles without a translation still get a working language switch, just at the section root.
def alt_lang_href(post, mapping, target_lang, target_prefix):
key = post.get("translation_key")
slug = mapping.get((target_lang, key)) if key else None
if slug:
return site_url(target_prefix, f"posts/{slug}/")
return site_url(target_prefix)
The hreflang tags in <head> use the same function, so the machine-readable and human-readable links can't drift apart:
def hreflang_links(post, mapping):
links = [("zh", site_url("", f"posts/{post['slug']}/") if post["lang"] == "zh"
else site_url("", f"posts/{mapping[('zh', post['translation_key'])]}/"))]
# ... same for en, plus x-default pointing at the Chinese page
In practice it's cleaner to compute both URLs once and pass a small dict into the template, but the principle holds: cross-language links are derived from the translation map, never typed by hand.
Per-language sitemaps and feeds
A single root sitemap.xml listing both languages is legal, but it makes Search Console reporting muddy and forces every URL to carry hreflang annotations in one file. Since the two sites already have separate manifests and separate generators, each generator writes its own sitemap and feed under its own prefix:
-
docs/sitemap.xmlanddocs/rss.xml— Chinese -
docs/en/sitemap.xmlanddocs/en/rss.xml— English
from xml.sax.saxutils import escape
from datetime import datetime
def write_sitemap(posts, prefix: str, out_path: Path, base: str = "https://example.com"):
urls = [f"{base}{site_url(prefix)}"]
urls += [f"{base}{site_url(prefix, f'posts/{p[\"slug\"]}/')}" for p in posts]
body = "\n".join(
f" <url><loc>{escape(u)}</loc></url>" for u in urls
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}\n</urlset>\n"
)
out_path.write_text(xml, encoding="utf-8")
The RSS feed follows the same shape, reading <link> values from site_url(prefix, ...). Because both use the same prefix constant, a feed can never contain a URL from the wrong language.
If you want the sitemaps discoverable without submitting each one manually, add a sitemap index at the root:
def write_sitemap_index(base: str, out_path: Path):
entries = [
f"{base}/sitemap.xml",
f"{base}/en/sitemap.xml",
]
body = "\n".join(
f" <sitemap><loc>{escape(u)}</loc></sitemap>" for u in entries
)
out_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}\n</sitemapindex>\n",
encoding="utf-8",
)
After the domain switch: submit the second sitemap
The domain move is where this layout earns its keep. When the site moved to a custom domain, the CNAME file changed once and both languages moved together. GitHub Pages issued the certificate for the new domain, and the root site's URLs redirected cleanly because they were always relative to the domain root.
The English site needed one extra step. Search Console properties are per-domain (or per-URL-prefix), and the new domain was verified fresh. The root sitemap.xml was submitted immediately, but /en/sitemap.xml sat unsubmitted for a week. English pages were indexed slowly and with stale titles. The fix was to open the new property's Sitemaps report and add https://example.com/en/sitemap.xml as a second sitemap. Within days the English pages were crawled at the same rate as the Chinese ones.
Two practical notes from that episode. First, submit the sitemap index if you wrote one; Search Console will follow it. Second, the hreflang annotations matter more after a domain change, because the crawler is re-evaluating which URLs are alternates of which. If your language switch links were broken by the move, the annotations would point at dead URLs and the English pages would be treated as orphans. Keeping the language switch derived from the translation map, as above, means a domain change doesn't touch link logic at all.
What the layout buys you
The whole design rests on three invariants: one manifest directory per language, one prefix constant per build, one URL helper used everywhere. Everything else — sitemaps, feeds, hreflang, cross-language nav — falls out of those. The build is idempotent because the two generators can't see each other's inputs. Links are consistent because no template constructs a URL by hand. And adding a third language later is a matter of a new manifest directory, a new prefix, and a new generator file, not a refactor.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-24 08:47:12
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| BIRB | 弱做多 | $0.1706 | $0.1835 | $0.1621 | 1:1.5 |
1 picks selected. Scanner runs every 15 minutes.
Top comments (0)