calcomblock/phone_region.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

124 lines
4.2 KiB
Go

package main
import "strings"
// regionFromLocale extracts the ISO 3166-1 alpha-2 region from a BCP-47 locale
// tag such as the value of navigator.language ("en-AU" -> "AU", "zh-Hans-CN" ->
// "CN"). The first subtag is the language unless it is itself an uppercase
// 2-letter region (a bare "AU"). Returns "" when no region subtag is present
// ("en" -> "").
func regionFromLocale(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
parts := strings.FieldsFunc(locale, func(r rune) bool { return r == '-' || r == '_' })
for i, part := range parts {
if len(part) != 2 || !isASCIILetters(part) {
continue // language (handled below), script ("Hans"), or variant
}
// Subtag 0 is the language unless it is already an uppercase region.
if i == 0 && part != strings.ToUpper(part) {
continue
}
return strings.ToUpper(part)
}
return ""
}
// isASCIILetters reports whether s is non-empty and all ASCII letters.
func isASCIILetters(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
return false
}
}
return true
}
// tzRegions maps IANA timezones to ISO 3166-1 alpha-2 regions for the common,
// unambiguous cases. The device timezone is a stronger locator than the UI
// locale (an en-US browser in Australia still reports Australia/*), so it is
// preferred. Ambiguous zones (most America/*) are intentionally omitted — those
// fall through to the locale. Australia/* is matched by prefix in
// regionFromTimezone.
var tzRegions = map[string]string{
"Pacific/Auckland": "NZ", "Pacific/Chatham": "NZ",
"Europe/London": "GB", "Europe/Dublin": "IE", "Europe/Paris": "FR",
"Europe/Berlin": "DE", "Europe/Madrid": "ES", "Europe/Rome": "IT",
"Europe/Amsterdam": "NL", "Europe/Brussels": "BE", "Europe/Zurich": "CH",
"Europe/Stockholm": "SE", "Europe/Oslo": "NO", "Europe/Copenhagen": "DK",
"Asia/Singapore": "SG", "Asia/Kolkata": "IN", "Asia/Calcutta": "IN",
"Asia/Tokyo": "JP", "Asia/Hong_Kong": "HK", "Asia/Dubai": "AE",
"Asia/Shanghai": "CN", "Asia/Seoul": "KR", "Africa/Johannesburg": "ZA",
}
// regionFromTimezone maps an IANA timezone (navigator-detected, e.g.
// "Australia/Perth") to a region for the common cases. Returns "" for
// unknown/ambiguous zones (including the UTC fallback some browsers report).
func regionFromTimezone(tz string) string {
tz = strings.TrimSpace(tz)
if tz == "" {
return ""
}
if strings.HasPrefix(tz, "Australia/") {
return "AU"
}
return tzRegions[tz]
}
// resolveRegion picks the ISO region used to pre-select the phone country
// combobox and to seed E.164 normalization when the visitor has not explicitly
// picked a country. It prefers the timezone region (strongest locator), then the
// browser locale region, validated against the known country list. Returns ""
// when neither is known — the combobox then has no default and normalizePhone
// only handles already-international numbers.
func resolveRegion(locale, timezone string) string {
for _, r := range []string{regionFromTimezone(timezone), regionFromLocale(locale)} {
if r != "" {
if _, ok := countryByCode(r); ok {
return r
}
}
}
return ""
}
// countryByCode returns the country with the given ISO alpha-2 code, or false.
func countryByCode(code string) (Country, bool) {
code = strings.ToUpper(strings.TrimSpace(code))
for _, c := range countries {
if c.Code == code {
return c, true
}
}
return Country{}, false
}
// countryFlag returns the emoji flag for a 2-letter ISO region code by mapping
// each letter to its Unicode regional-indicator symbol. "" for invalid codes.
func countryFlag(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if len(code) != 2 || !isASCIILetters(code) {
return ""
}
r := []rune(code)
return string(rune(0x1F1E6+(r[0]-'A'))) + string(rune(0x1F1E6+(r[1]-'A')))
}
// countryLabel renders the human label shown in the combobox and its pre-fill,
// e.g. "🇦🇺 Australia (+61)". Returns "" when the code is unknown.
func countryLabel(code string) string {
c, ok := countryByCode(code)
if !ok {
return ""
}
if flag := countryFlag(c.Code); flag != "" {
return flag + " " + c.Name + " (" + c.Dial + ")"
}
return c.Name + " (" + c.Dial + ")"
}