-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.py
More file actions
342 lines (306 loc) · 20.8 KB
/
Copy pathbuild.py
File metadata and controls
342 lines (306 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# TestnetHub static-site builder. Wraps each hand-written content fragment in
# `content/<slug>.html` with the shared chrome (head, nav, footer) and writes a
# flat static `<slug>.html` to the repo root, which is what deploys. Run
# `python3 build.py` after editing any fragment or the PAGES manifest. Stdlib only.
#
# The whole point: nav/footer/head live here ONCE, so every page matches and a
# new guide is just a manifest entry + a fragment file. The output is plain
# static HTML with no runtime build step.
import os
import re
import json
ROOT = os.path.dirname(os.path.abspath(__file__))
ORIGIN = "https://testnethub.com"
GITHUB = "https://github.com/Tech1k/testnethub.com"
BUILD_DATE = os.environ.get("BUILD_DATE", "2026-08-07")
# Site-wide structured data (Organization + WebSite). Emitted in every page head.
# JSON-LD in a <script type="application/ld+json"> is a data block, not executed
# script, so the strict CSP (script-src 'self') does not block it.
JSONLD = (
'{"@context":"https://schema.org","@graph":['
'{"@type":"Organization","@id":"' + ORIGIN + '/#org","name":"TestnetHub",'
'"url":"' + ORIGIN + '/","logo":"' + ORIGIN + '/favicon.svg"},'
'{"@type":"WebSite","@id":"' + ORIGIN + '/#website","name":"TestnetHub",'
'"url":"' + ORIGIN + '/","publisher":{"@id":"' + ORIGIN + '/#org"},'
'"description":"Learn the Bitcoin, Litecoin, and Monero testnets with free coins and real protocols."}'
']}'
)
# Live ecosystem services (used by the footer + tools/router pages).
SUITE = {
"faucet": ("CypherFaucet", "https://cypherfaucet.com"),
"pool": ("TestnetPool", "https://testnetpool.com"),
"wallet": ("TestnetWallet", "https://testnetwallet.net"),
"explorer": ("TestnetScan", "https://testnetscan.com"),
"swap": ("TestnetSwap", "https://testnetswap.com"),
}
# Top-level navigation (order = display order).
NAV = [
("index", "Home"),
("testnets", "Testnets"),
("guides", "Guides"),
("tools", "Tools"),
("status", "Status"),
("donate", "Donate"),
]
# Full page manifest: (slug, path, title, description).
PAGES = [
("index", "/", "TestnetHub: Learn crypto with fake coins and real protocols",
"The beginner-friendly command center for the Bitcoin, Litecoin, and Monero testnets. Get free test coins, send transactions, explore, mine, and swap."),
("testnets", "/testnets", "What is a testnet? · TestnetHub",
"Practice networks with worthless coins but real protocols. Learn what a testnet is, why it exists, and how the Bitcoin, Litecoin, and Monero testnets differ."),
("bitcoin", "/bitcoin", "Bitcoin testnet guide · TestnetHub",
"A beginner's guide to the Bitcoin test networks: testnet3, testnet4, and signet. Get free tBTC, understand addresses and confirmations, and build safely."),
("litecoin", "/litecoin", "Litecoin testnet guide (with MWEB) · TestnetHub",
"How the Litecoin testnet works, including MWEB peg-ins and peg-outs. Get free tLTC, learn Litecoin addresses, and try confidential MWEB transactions."),
("monero", "/monero", "Monero testnet & stagenet guide · TestnetHub",
"Understand the difference between Monero testnet and stagenet, get free test XMR, and see how rings, stealth addresses, and view keys behave off mainnet."),
("wallet", "/wallet", "How to use TestnetWallet · TestnetHub",
"Create a testnet wallet in your browser, receive coins, and broadcast fake-money transactions for Bitcoin, Litecoin, and Monero with TestnetWallet."),
("faucet", "/faucet", "How to get testnet coins from CypherFaucet · TestnetHub",
"Step-by-step: claim free Bitcoin testnet, Litecoin testnet, and Monero stagenet/testnet coins from CypherFaucet, and what to do if a faucet is dry."),
("mining", "/mining", "How to mine testnet coins with TestnetPool · TestnetHub",
"Learn how mining and pools work by pointing a miner at TestnetPool on the Bitcoin, Litecoin, and Monero testnets, with no costly hardware or real money needed."),
("explorer", "/explorer", "How to read a block explorer with TestnetScan · TestnetHub",
"Use TestnetScan to inspect blocks, transactions, addresses, confirmations, and Litecoin MWEB activity on the Bitcoin, Litecoin, and Monero testnets."),
("swaps", "/swaps", "How atomic swaps work with TestnetSwap · TestnetHub",
"Try non-custodial atomic swaps between testnet coins with TestnetSwap, and learn how HTLCs and adaptor-signature swaps let two chains trade without a middleman."),
("guides", "/guides", "Guides · TestnetHub",
"Practical, beginner-first tutorials for the Bitcoin, Litecoin, and Monero testnets: getting coins, confirmations, fees, UTXOs, nodes, atomic swaps, and more."),
("tools", "/tools", "The testnet toolset · TestnetHub",
"The Tech1k testnet ecosystem in one place: CypherFaucet, TestnetPool, TestnetWallet, TestnetScan, and TestnetSwap. What each tool does, and when to use it."),
("status", "/status", "Service status · TestnetHub",
"Live health and links for every service in the testnet ecosystem: faucet, pool, wallet, explorer, and swap."),
("donate", "/donate", "Donate · TestnetHub",
"Support TestnetHub with a voluntary mainnet BTC, LTC, or XMR tip. It is free, open-source, ad-free, and only ever deals in worthless testnet coins."),
("first-transaction", "/first-transaction", "Your first testnet transaction · TestnetHub",
"Make a wallet, get free coins, send a transaction, and watch it confirm. A complete first testnet payment in about 15 minutes, with no real money."),
("keys-and-seeds", "/keys-and-seeds", "Keys, seeds, and backups · TestnetHub",
"What a private key, public key, address, and seed phrase actually are, how HD wallets and backups work, and why testnet is the safe place to practice."),
("confirmations", "/confirmations", "How confirmations work · TestnetHub",
"What a confirmation is, why more confirmations means more security, and how many to wait for on the Bitcoin, Litecoin, and Monero test networks."),
("fees", "/fees", "How transaction fees work · TestnetHub",
"Why blockchain transactions pay fees, how fee rates and mempools decide what gets mined next, and how fees behave on testnet."),
("utxos", "/utxos", "Understanding UTXOs · TestnetHub",
"The UTXO model explained: how Bitcoin and Litecoin track balances as unspent outputs, what \"change\" really is, and why coin selection matters."),
("why-no-value", "/why-no-value", "Why testnet coins have no value · TestnetHub",
"Testnet coins are free and intentionally worthless. Here is why they must stay that way, and why you should never buy or sell them."),
("scams", "/scams", "Testnet scams and staying safe · TestnetHub",
"Recognize fake testnet coin sales, seed-phrase phishing, trojaned downloads, and address poisoning. The safety habits that protect real money later."),
("broadcast-raw-tx", "/broadcast-raw-tx", "Broadcast a raw transaction · TestnetHub",
"What a raw transaction is, how to build and sign one, and how to push it to the network on the Bitcoin and Litecoin testnets."),
("run-node", "/run-node", "Run a Litecoin testnet node · TestnetHub",
"Spin up your own Litecoin testnet full node with litecoind, sync the chain, and use the RPC interface to explore and broadcast."),
("rpc-safety", "/rpc-safety", "Use node RPC safely · TestnetHub",
"The RPC interface is powerful and dangerous. How to configure cookie auth, bind locally, avoid exposing your node, and keep wallet RPC off the public internet."),
("debug-stuck-tx", "/debug-stuck-tx", "Debug a stuck transaction · TestnetHub",
"Why a transaction gets stuck unconfirmed, and how to fix it with RBF, CPFP, or by waiting it out on testnet."),
("build-app", "/build-app", "Build a testnet app · TestnetHub",
"A capstone developer guide: reach a chain over RPC or an explorer API, build and broadcast a transaction, and watch it confirm, all on testnet."),
("regtest", "/regtest", "Bitcoin regtest: your private testnet · TestnetHub",
"Run bitcoind in regtest mode for a private local chain where you mine blocks on demand and confirmations are instant, ideal for automated tests."),
("glossary", "/glossary", "Glossary of testnet terms · TestnetHub",
"Plain-English definitions of the crypto and testnet terms used across the guides: UTXO, mempool, confirmation, MWEB, stealth address, RPC, and more."),
("faq", "/faq", "Testnet FAQ · TestnetHub",
"Quick answers to common beginner questions: are testnet coins worth anything, how to get them, is it safe, and which coin to start with."),
]
NOTFOUND = ("404", "/404", "Page not found · TestnetHub", "That page does not exist on TestnetHub.")
def attr(s):
return str(s).replace("&", "&").replace('"', """)
def strip_tags(s):
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"\s+", " ", s)
return s.replace("&", "&").strip()
def nav_html(current):
rows = []
for slug, label in NAV:
href = "/" if slug == "index" else f"/{slug}.html"
idattr = ' id="curpage"' if slug == current else ""
rows.append(f' <a href="{href}"{idattr}>{label}</a>')
links = "\n".join(rows)
return f'''<nav class="navbar">
<a href="/" class="brand" aria-label="TestnetHub home">
<svg class="brand-logo" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect class="mark-bg" width="64" height="64" rx="14"/><g stroke="currentColor" stroke-width="3.4" stroke-linecap="round"><line x1="32" y1="32" x2="19" y2="19"/><line x1="32" y1="32" x2="45" y2="19"/><line x1="32" y1="32" x2="19" y2="45"/><line x1="32" y1="32" x2="45" y2="45"/></g><g fill="currentColor"><circle cx="19" cy="19" r="5"/><circle cx="45" cy="19" r="5"/><circle cx="19" cy="45" r="5"/><circle cx="45" cy="45" r="5"/><circle cx="32" cy="32" r="6.5"/></g></svg>
<span class="brand-name">Testnet<b>Hub</b></span>
</a>
<input type="checkbox" class="menu-toggle" id="menu-toggle" aria-label="Open the navigation menu" aria-controls="nav-links">
<label for="menu-toggle" class="hamburger"><div></div><div></div><div></div></label>
<div class="nav-links" id="nav-links">
{links}
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light / dark theme" title="Toggle theme"><svg class="ico ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg><svg class="ico ico-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
</div>
</nav>'''
def footer_html():
f = SUITE["faucet"][1]; p = SUITE["pool"][1]; w = SUITE["wallet"][1]
e = SUITE["explorer"][1]; s = SUITE["swap"][1]
return f''' <footer>
<div class="foot-inner">
<a class="ext" href="{f}" target="_blank" rel="noopener">Faucet</a>
<a class="ext" href="{p}" target="_blank" rel="noopener">Pool</a>
<a class="ext" href="{w}" target="_blank" rel="noopener">Wallet</a>
<a class="ext" href="{e}" target="_blank" rel="noopener">Explorer</a>
<a class="ext" href="{s}" target="_blank" rel="noopener">Swap</a>
<a href="/guides.html">Guides</a>
<a href="/status.html">Status</a>
<a href="/donate.html">Donate</a>
<span class="faint">·</span>
<span>Part of the <a class="ext" href="https://tech1k.com" target="_blank" rel="noopener">Tech1k</a> testnet ecosystem</span>
<span class="faint">·</span>
<a class="ext" href="{GITHUB}" target="_blank" rel="noopener">Source</a>
<a class="ext" href="{GITHUB}/blob/HEAD/LICENSE" target="_blank" rel="noopener">Code AGPL-3.0</a>
<a class="ext" href="{GITHUB}/blob/HEAD/LICENSE-CONTENT" target="_blank" rel="noopener">Content CC-BY-SA-4.0</a>
<span class="faint">· testnet only · coins have no value</span>
</div>
</footer>'''
def page(slug, path, title, desc, fragment):
canonical = ORIGIN + path
is_home = slug == "index"
current = "index" if is_home else slug
robots = "noindex, follow" if slug == "404" else "index, follow"
# Per-page scripts (self-hosted, so script-src stays 'self'): status fetches
# the live service APIs; copy-to-clipboard loads wherever there is something to
# copy (donate addresses, or any guide with a code block).
SCRIPT_VER = {"copy.js": "2", "status.js": "1"}
scripts = []
if slug == "status":
scripts.append("status.js")
if slug == "donate" or "<pre><code" in fragment:
scripts.append("copy.js")
page_scripts = "".join(
f'\n<script src="/assets/{s}?v={SCRIPT_VER[s]}"></script>' for s in scripts)
# Per-page connect-src: only the status page reaches the ecosystem APIs; every
# other page stays connect-src 'self'. Keep this in sync with _headers.
PAGE_CONNECT = {"status": "https://api.testnetswap.com https://testnetpool.com https://cypherfaucet.com https://testnetscan.com"}
csp_connect = "'self'" + ((" " + PAGE_CONNECT[slug]) if slug in PAGE_CONNECT else "")
# Per-page structured data. Guide subpages (those with a visible crumb) get a
# BreadcrumbList; the article-style guides also get TechArticle + og:article. The
# FAQ and glossary keep the crumb but use their own primary types (FAQPage /
# DefinedTermSet) and stay og:website. All are data blocks (type=application/ld+json).
m = re.search(r'<p class="crumb"><a href="/guides\.html">Guides</a> / ([^<]+)</p>', fragment)
is_article = bool(m) and slug not in ("faq", "glossary")
og_type = "article" if is_article else "website"
extra_ld = ""
if m:
headline = m.group(1).strip()
trail = [("Home", "/"), ("Guides", "/guides"), (headline, path)]
items = ",".join(
'{"@type":"ListItem","position":%d,"name":%s,"item":"%s"}' % (i + 1, json.dumps(n), ORIGIN + p)
for i, (n, p) in enumerate(trail))
extra_ld += ('\n<script type="application/ld+json">'
'{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":['
+ items + ']}</script>')
if is_article:
article = {"@context": "https://schema.org", "@type": "TechArticle",
"headline": headline, "description": desc, "inLanguage": "en",
"isPartOf": {"@id": ORIGIN + "/#website"},
"publisher": {"@id": ORIGIN + "/#org"},
"author": {"@id": ORIGIN + "/#org"},
"dateModified": BUILD_DATE, "mainEntityOfPage": canonical}
extra_ld += ('\n<script type="application/ld+json">'
+ json.dumps(article, separators=(",", ":")) + '</script>')
if slug == "faq":
qas = re.findall(r'<h3 id="[^"]*">(.*?)</h3>\s*<p>(.*?)</p>', fragment, flags=re.S)
main = [{"@type": "Question", "name": strip_tags(q),
"acceptedAnswer": {"@type": "Answer", "text": strip_tags(a)}} for q, a in qas]
extra_ld += ('\n<script type="application/ld+json">'
+ json.dumps({"@context": "https://schema.org", "@type": "FAQPage",
"mainEntity": main}, separators=(",", ":")) + '</script>')
if slug == "glossary":
rows = re.findall(r'<tr><th scope="row">(.*?)</th><td>(.*?)</td></tr>', fragment, flags=re.S)
terms = [{"@type": "DefinedTerm", "name": strip_tags(nm),
"description": re.sub(r"\s*See [^.]*\.?\s*$", "", strip_tags(dfn)).strip()}
for nm, dfn in rows]
extra_ld += ('\n<script type="application/ld+json">'
+ json.dumps({"@context": "https://schema.org", "@type": "DefinedTermSet",
"name": "TestnetHub glossary", "hasDefinedTerm": terms},
separators=(",", ":")) + '</script>')
html = f'''<!DOCTYPE html>
<!-- SPDX-License-Identifier: AGPL-3.0-or-later | TestnetHub © 2026 Tech1k -->
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{attr(desc)}">
<link rel="canonical" href="{canonical}">
<meta name="robots" content="{robots}">
<meta name="referrer" content="no-referrer">
<meta name="theme-color" content="#14161b">
<meta name="author" content="Tech1k">
<meta property="og:type" content="{og_type}">
<meta property="og:site_name" content="TestnetHub">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(desc)}">
<meta property="og:url" content="{canonical}">
<meta property="og:image" content="{ORIGIN}/assets/og.png?v=2">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="TestnetHub: learn crypto with fake coins and real protocols">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(desc)}">
<meta name="twitter:image" content="{ORIGIN}/assets/og.png?v=2">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src {csp_connect}; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
<script src="/assets/theme-init.js?v=1"></script>
<link rel="stylesheet" href="/assets/style.css?v=5">
<script type="application/ld+json">{JSONLD}</script>{extra_ld}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<span id="copy-status" role="status" class="visually-hidden"></span>
{nav_html(current)}
<main id="main" class="filled" tabindex="-1">
{fragment.strip()}
</main>
{footer_html()}
{page_scripts}
</body>
</html>
'''
# One canonical URL form: link to the clean, extensionless path everywhere
# (matching the canonical + sitemap), while the deployed files stay <slug>.html.
html = re.sub(r'href="/([a-z0-9-]+)\.html"', r'href="/\1"', html)
# Keep the decorative heading permalinks (aria-hidden) out of the tab order.
html = html.replace('<a class="anchor" href', '<a class="anchor" tabindex="-1" href')
# Add scope="col" to <thead> header cells (row headers use <th scope="row"> in fragments).
html = re.sub(r'<thead>.*?</thead>', lambda m: m.group(0).replace('<th>', '<th scope="col">'), html, flags=re.S)
# Wrap each code block with a copy button (copy.js reads the <pre> text). Done at
# build time so fragments stay plain <pre><code> and any new one gets it for free.
html = re.sub(
r'<pre><code([^>]*)>(.*?)</code></pre>',
lambda m: ('<div class="codeblock"><button class="copybtn code-copy" type="button" '
'data-copy-code aria-label="Copy code to clipboard">Copy</button>'
'<pre><code' + m.group(1) + '>' + m.group(2) + '</code></pre></div>'),
html, flags=re.S)
return html
def read_fragment(slug):
fp = os.path.join(ROOT, "content", f"{slug}.html")
try:
with open(fp, encoding="utf-8") as fh:
return fh.read()
except FileNotFoundError:
return (f' <div class="card"><div class="card-body"><p class="muted">Content for '
f'<code>{slug}</code> is not written yet.</p></div></div>')
def build():
built = 0
for slug, path, title, desc in PAGES + [NOTFOUND]:
out = "index.html" if slug == "index" else f"{slug}.html"
with open(os.path.join(ROOT, out), "w", encoding="utf-8") as fh:
fh.write(page(slug, path, title, desc, read_fragment(slug)))
built += 1
urls = []
for slug, path, *_ in PAGES:
pri = "1.0" if slug == "index" else ("0.8" if slug in ("testnets", "guides", "tools") else "0.6")
urls.append(f" <url><loc>{ORIGIN}{path}</loc><lastmod>{BUILD_DATE}</lastmod><priority>{pri}</priority></url>")
sitemap = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(urls) + "\n</urlset>\n")
with open(os.path.join(ROOT, "sitemap.xml"), "w", encoding="utf-8") as fh:
fh.write(sitemap)
print(f"Built {built} pages + sitemap.xml")
if __name__ == "__main__":
build()