@bettercms-ai/astro 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,25 @@ or on-demand-revalidation hook.
132
132
 
133
133
  Markup is class-driven and unstyled — you own the CSS.
134
134
 
135
+ ### Phone fields with a country code picker
136
+
137
+ A phone field whose author enabled **Country code picker** in the builder renders a country
138
+ control beside the number. Whatever the visitor picks, the field still submits **one value**,
139
+ under its own key, in E.164 (`+14155550123`) — there is no second key to handle.
140
+
141
+ Two classes are yours to style:
142
+
143
+ | Class | Element |
144
+ |---|---|
145
+ | `bcms-phone` | the wrapper around the country control and the number |
146
+ | `bcms-phone-country` | the country control itself |
147
+
148
+ The country control is a native `<select>` built at build/SSR time, so **no phone library
149
+ reaches the visitor's browser**. The trade is that the number is not reformatted as it is
150
+ typed — the value is still composed correctly on submit. `@bettercms-ai/next` makes the other
151
+ choice and ships a formatter in a lazy chunk.
152
+
153
+
135
154
  ## Live editing on a static site — `<BcmsLiveBlocks>`
136
155
 
137
156
  When the Visual Editor frames a static build it can only show what was built, so editing a
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import config from "bettercms:config";
11
11
  import type { DeliveryForm, DeliveryFormField } from "@bettercms-ai/sdk";
12
+ import { getCountries, getCountryCallingCode, isSupportedCountry, getExampleNumber } from "libphonenumber-js/min";
13
+ import phoneExamples from "libphonenumber-js/mobile/examples";
12
14
 
13
15
  interface Props {
14
16
  form: DeliveryForm;
@@ -32,6 +34,46 @@ const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
32
34
  url: "url",
33
35
  };
34
36
 
37
+ /**
38
+ * Calling-code options for a phone picker. Frontmatter only — this runs at build/SSR time and
39
+ * ships ZERO bytes of libphonenumber to the visitor; the inline script below never sees it.
40
+ *
41
+ * Every libphonenumber helper is wrapped in an arrow rather than passed as a bare callback:
42
+ * `.filter`/`.map` hand it the array INDEX as its `metadata` argument and it throws.
43
+ */
44
+ const REGION_NAMES =
45
+ typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function"
46
+ ? new Intl.DisplayNames(["en"], { type: "region" })
47
+ : null;
48
+
49
+ let countryCache: { iso: string; name: string; code: string; trunk: boolean }[] | null = null;
50
+ const countryList = () => {
51
+ if (countryCache) return countryCache;
52
+ countryCache = getCountries()
53
+ .filter((iso) => isSupportedCountry(iso))
54
+ .map((iso) => {
55
+ let name = iso as string;
56
+ try {
57
+ name = REGION_NAMES?.of(iso) ?? iso;
58
+ } catch {
59
+ name = iso;
60
+ }
61
+ let trunk = false;
62
+ try {
63
+ const ex = getExampleNumber(iso, phoneExamples);
64
+ if (ex) {
65
+ const national = ex.formatNational().replace(/\D/g, "");
66
+ trunk = national.length > String(ex.nationalNumber).length && national.startsWith("0");
67
+ }
68
+ } catch {
69
+ trunk = false;
70
+ }
71
+ return { iso: iso as string, name, code: getCountryCallingCode(iso), trunk };
72
+ })
73
+ .sort((a, b) => a.name.localeCompare(b.name));
74
+ return countryCache;
75
+ };
76
+
35
77
  const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
36
78
  ---
37
79
 
@@ -98,6 +140,29 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
98
140
  <select id={`bcms-f-${f.key}`} name={f.key} required={f.required}>
99
141
  {(f.options ?? []).map((o) => <option value={o} data-bcms-form-part="option">{o}</option>)}
100
142
  </select>
143
+ ) : f.type === "phone" && f.ui?.countryPicker ? (
144
+ /* A country select plus a tel input, submitting ONE E.164 value. `required` belongs
145
+ on the number only — a select always has a value and would never block. */
146
+ <span class="bcms-phone">
147
+ <select class="bcms-phone-country" name={`${f.key}__country`} aria-label="Country code">
148
+ <option value="" selected={!f.ui.defaultCountry}>International</option>
149
+ {countryList().map((c) => (
150
+ <option value={c.iso} data-cc={c.code} data-trunk={c.trunk ? "1" : undefined} selected={c.iso === f.ui!.defaultCountry}>
151
+ {c.name} +{c.code}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ <input
156
+ type="tel"
157
+ id={`bcms-f-${f.key}`}
158
+ name={f.key}
159
+ placeholder={f.placeholder}
160
+ value={f.defaultValue}
161
+ required={f.required}
162
+ autocomplete="tel"
163
+ dir="ltr"
164
+ />
165
+ </span>
101
166
  ) : (
102
167
  <input
103
168
  type={INPUT_TYPE[f.type] ?? "text"}
@@ -126,6 +191,13 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
126
191
  {showTurnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
127
192
 
128
193
  <script is:inline>
194
+ // Compose one E.164 value from a country-picker phone field. CANONICAL COPY is
195
+ // PHONE_COMPOSE_JS in the backend's src/lib/sites/render-page.ts, and this file is pinned
196
+ // against it byte for byte by src/__tests__/content/form-script-pin.test.ts. All three
197
+ // vanilla lanes are inline script text on someone else's page and cannot import at runtime,
198
+ // so the rule genuinely exists more than once.
199
+ function bcmsPhone(cc,raw,trunk){var d=String(raw||'').trim();if(!d)return '';if(d.charAt(0)==='+'){var i=d.replace(/\D/g,'');return i?'+'+i:'';}if(!cc)return d;var n=d.replace(/\D/g,'');if(trunk&&n.charAt(0)==='0')n=n.slice(1);return n?'+'+cc+n:'';}
200
+
129
201
  // One copy of this script ships per <BcmsForm> instance, and each copy sees EVERY
130
202
  // form on the page — so two forms meant four bindings and a double submit. The
131
203
  // `bcmsFormBound` flag makes binding idempotent no matter how many copies run,
@@ -177,6 +249,14 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
177
249
  if (k in data) data[k] = Array.isArray(data[k]) ? [...data[k], v] : [data[k], v];
178
250
  else data[k] = v;
179
251
  });
252
+ // Compose AFTER the FormData loop. A showIf pointing at a phone key must keep comparing
253
+ // the raw digits, and applyConds runs on every input event.
254
+ form.querySelectorAll('select[name$="__country"]').forEach((sel) => {
255
+ const k = sel.name.slice(0, -9);
256
+ const o = sel.options[sel.selectedIndex];
257
+ data[k] = bcmsPhone(o ? (o.getAttribute("data-cc") || "") : "", data[k], o ? o.getAttribute("data-trunk") === "1" : false);
258
+ delete data[k + "__country"];
259
+ });
180
260
  const tsEl = form.querySelector('[name="cf-turnstile-response"]');
181
261
  const body = { data };
182
262
  if (tsEl?.value) body["cf-turnstile-response"] = tsEl.value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "The BetterCMS adapter for Astro — a `bettercms()` integration, a `bettercms:client` virtual module, typed content loaders, draft preview, and native .astro rendering components.",
6
6
  "main": "./dist/index.js",
@@ -54,11 +54,12 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@bettercms-ai/image-url": "^0.3.0",
57
- "@bettercms-ai/sdk": "^1.20.0",
57
+ "@bettercms-ai/next": "^0.13.0",
58
+ "@bettercms-ai/richtext": "^0.3.0",
59
+ "@bettercms-ai/sdk": "^1.21.0",
58
60
  "@bettercms-ai/types": "^1.11.0",
59
61
  "@bettercms-ai/ui": "^0.7.0",
60
- "@bettercms-ai/richtext": "^0.3.0",
61
- "@bettercms-ai/next": "^0.13.0"
62
+ "libphonenumber-js": "^1.13.13"
62
63
  },
63
64
  "peerDependencies": {
64
65
  "astro": ">=4",