@sevenfold/setto-client 0.16.0 → 0.17.0

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The control a visitor uses to read the site in their own language.
3
+ *
4
+ * Mounted by `SettoProvider` rather than placed by the agent, and only when
5
+ * the site actually speaks more than one language — so it costs a Norwegian
6
+ * plumber's site nothing, and a site that gains a second language grows the
7
+ * control without anybody editing a header.
8
+ *
9
+ * That decision is worth stating because it is a trade. Auto-mounting means
10
+ * the switcher cannot sit inside a design it knows nothing about, so it sits
11
+ * in a corner instead: quiet, small, and out of the way of whatever the agent
12
+ * built. A site that wants it in its own header can render `<SettoLanguages>`
13
+ * anywhere and pass `inline`, and the floating one stands down.
14
+ *
15
+ * Styling is inline for the same reason the editing chrome's is: this renders
16
+ * inside a page whose stylesheet Setto did not write, and most of these sites
17
+ * ship Tailwind's Preflight, which would otherwise strip a bare `<button>`
18
+ * back to nothing.
19
+ */
20
+ export interface SettoLanguagesProps {
21
+ /** Every language the site speaks, primary first. */
22
+ languages: readonly string[];
23
+ /**
24
+ * Rendered in the flow of the page rather than fixed in a corner.
25
+ *
26
+ * Also tells the auto-mounted copy to stand down, so a site that places its
27
+ * own switcher does not end up with two.
28
+ */
29
+ inline?: boolean;
30
+ }
31
+ export declare function SettoLanguages({ languages, inline }: SettoLanguagesProps): import("react/jsx-runtime").JSX.Element | null;
32
+ /**
33
+ * The floating copy, mounted by the provider.
34
+ *
35
+ * Renders nothing when the site places its own, and nothing when there is only
36
+ * one language to be in.
37
+ */
38
+ export declare function AutoLanguageSwitcher({ languages }: {
39
+ languages: readonly string[];
40
+ }): import("react/jsx-runtime").JSX.Element | null;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { SettoLanguages } from './LanguageSwitcher';
2
+ export { DEFAULT_LANGUAGE, languageName, pickInitialLanguage, siteLanguages, } from './lib/languages';
1
3
  export { SettoProvider, useSetto } from './provider';
2
4
  export { GuestEditProvider, useGuestEdit } from './guest-edit';
3
5
  export { SettoChrome, useSettoChromeSlot } from './chrome';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which languages a site speaks, and which one a visitor is reading.
3
+ *
4
+ * Every Setto site has been Norwegian-only, not because the machinery could
5
+ * not hold more — `I18nStore` has taken a language argument since the
6
+ * beginning and the publish path already serialises one bundle per language —
7
+ * but because nothing ever told a site it had a second one, and nothing gave a
8
+ * visitor a way to ask for it.
9
+ *
10
+ * The choice is deliberately per browser and not per URL. A path prefix
11
+ * (`/en/…`) is the better answer for search engines and the wrong answer for
12
+ * this pipeline today: every site is a single-page app whose routes the agent
13
+ * writes, and quietly doubling every route is not a thing to do to thirteen
14
+ * live sites in one release. Recorded here rather than hidden: when these
15
+ * sites want to be found in two languages, this is the decision to revisit.
16
+ */
17
+ /** Norwegian is the floor: every site has it, and it is what the agent writes. */
18
+ export declare const DEFAULT_LANGUAGE = "no";
19
+ /** «Norsk», or the tag itself when we have no name for it. */
20
+ export declare function languageName(tag: string): string;
21
+ /**
22
+ * The languages this site actually speaks, primary first, no duplicates.
23
+ *
24
+ * A configured list that forgets to include the primary language is a site
25
+ * whose own copy is unreachable, so the primary is inserted rather than
26
+ * trusted.
27
+ */
28
+ export declare function siteLanguages(configured: readonly string[] | undefined, primary?: string): string[];
29
+ /** What this browser last chose here, if anything. */
30
+ export declare function readStoredLanguage(): string | null;
31
+ export declare function storeLanguage(tag: string): void;
32
+ /**
33
+ * The language to open in.
34
+ *
35
+ * Their own previous choice first — it is the only signal that is definitely
36
+ * about this site. Then what the browser asks for, matched on the base tag so
37
+ * `en-GB` finds `en`. Then the site's primary, which is always present.
38
+ *
39
+ * Note what is *not* here: no geolocation, and no guessing from a domain. A
40
+ * Norwegian abroad is still Norwegian, and a site that decides it knows better
41
+ * than `Accept-Language` is a site people fight with.
42
+ */
43
+ export declare function pickInitialLanguage(available: readonly string[], browserLanguages?: readonly string[], stored?: string | null): string;
@@ -1,9 +1,242 @@
1
- import { jsx, jsxs, Fragment } from "react/jsx-runtime";
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { useState, useRef, useEffect, useMemo, createContext, useContext, useCallback, useLayoutEffect, useSyncExternalStore, forwardRef, Fragment as Fragment$1, useId, useInsertionEffect } from "react";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import * as ReactDOM from "react-dom";
6
6
  import { createPortal } from "react-dom";
7
+ const STORAGE_KEY$1 = "setto.lang";
8
+ const DEFAULT_LANGUAGE = "no";
9
+ const ENDONYMS = {
10
+ no: "Norsk",
11
+ nb: "Norsk",
12
+ nn: "Nynorsk",
13
+ en: "English",
14
+ sv: "Svenska",
15
+ da: "Dansk",
16
+ de: "Deutsch",
17
+ fr: "Français",
18
+ es: "Español",
19
+ it: "Italiano",
20
+ nl: "Nederlands",
21
+ pl: "Polski",
22
+ pt: "Português",
23
+ fi: "Suomi",
24
+ is: "Íslenska",
25
+ lt: "Lietuvių",
26
+ uk: "Українська",
27
+ ru: "Русский",
28
+ ar: "العربية",
29
+ tr: "Türkçe",
30
+ so: "Soomaali",
31
+ ti: "ትግርኛ",
32
+ th: "ไทย",
33
+ vi: "Tiếng Việt",
34
+ zh: "中文",
35
+ ja: "日本語"
36
+ };
37
+ function languageName(tag) {
38
+ const base = tag.toLowerCase().split("-")[0] ?? tag;
39
+ return ENDONYMS[base] ?? tag.toUpperCase();
40
+ }
41
+ function siteLanguages(configured, primary = DEFAULT_LANGUAGE) {
42
+ const out = [primary];
43
+ for (const tag of configured ?? []) {
44
+ if (tag && !out.includes(tag)) out.push(tag);
45
+ }
46
+ return out;
47
+ }
48
+ function readStoredLanguage() {
49
+ try {
50
+ return window.localStorage.getItem(STORAGE_KEY$1);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function storeLanguage(tag) {
56
+ try {
57
+ window.localStorage.setItem(STORAGE_KEY$1, tag);
58
+ } catch {
59
+ }
60
+ }
61
+ function pickInitialLanguage(available, browserLanguages = typeof navigator === "undefined" ? [] : navigator.languages ?? [navigator.language], stored = null) {
62
+ const fallback = available[0] ?? DEFAULT_LANGUAGE;
63
+ if (stored && available.includes(stored)) return stored;
64
+ for (const wanted of browserLanguages) {
65
+ const base = wanted.toLowerCase().split("-")[0];
66
+ if (!base) continue;
67
+ const hit = available.find((tag) => tag.toLowerCase().split("-")[0] === base);
68
+ if (hit) return hit;
69
+ if ((base === "nb" || base === "nn" || base === "no") && available.includes("no")) {
70
+ return "no";
71
+ }
72
+ }
73
+ return fallback;
74
+ }
75
+ const INLINE_MARKER = "data-setto-languages-inline";
76
+ function SettoLanguages({ languages, inline = false }) {
77
+ const { i18n } = useTranslation();
78
+ const [open, setOpen] = useState(false);
79
+ const ref = useRef(null);
80
+ useEffect(() => {
81
+ if (!inline) return;
82
+ const root = document.documentElement;
83
+ const count = Number(root.getAttribute(INLINE_MARKER) ?? "0") + 1;
84
+ root.setAttribute(INLINE_MARKER, String(count));
85
+ return () => {
86
+ const left = Number(root.getAttribute(INLINE_MARKER) ?? "1") - 1;
87
+ if (left > 0) root.setAttribute(INLINE_MARKER, String(left));
88
+ else root.removeAttribute(INLINE_MARKER);
89
+ };
90
+ }, [inline]);
91
+ useEffect(() => {
92
+ if (!open) return;
93
+ const onDown = (e) => {
94
+ if (!ref.current?.contains(e.target)) setOpen(false);
95
+ };
96
+ const onKey = (e) => {
97
+ if (e.key === "Escape") setOpen(false);
98
+ };
99
+ document.addEventListener("mousedown", onDown);
100
+ document.addEventListener("keydown", onKey);
101
+ return () => {
102
+ document.removeEventListener("mousedown", onDown);
103
+ document.removeEventListener("keydown", onKey);
104
+ };
105
+ }, [open]);
106
+ if (languages.length < 2) return null;
107
+ const current = i18n.language;
108
+ const choose = (tag) => {
109
+ setOpen(false);
110
+ if (tag === current) return;
111
+ storeLanguage(tag);
112
+ void i18n.changeLanguage(tag);
113
+ document.documentElement.lang = tag;
114
+ };
115
+ return /* @__PURE__ */ jsxs("div", { ref, style: inline ? inlineWrapStyle : floatingWrapStyle, children: [
116
+ /* @__PURE__ */ jsxs(
117
+ "button",
118
+ {
119
+ type: "button",
120
+ onClick: () => setOpen((o) => !o),
121
+ "aria-expanded": open,
122
+ "aria-haspopup": "listbox",
123
+ "aria-label": `Språk: ${languageName(current)}`,
124
+ style: triggerStyle,
125
+ children: [
126
+ /* @__PURE__ */ jsx(GlobeIcon, {}),
127
+ languageName(current)
128
+ ]
129
+ }
130
+ ),
131
+ open ? /* @__PURE__ */ jsx("ul", { role: "listbox", style: inline ? inlineListStyle : floatingListStyle, children: languages.map((tag) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
132
+ "button",
133
+ {
134
+ type: "button",
135
+ role: "option",
136
+ "aria-selected": tag === current,
137
+ onClick: () => choose(tag),
138
+ style: {
139
+ ...optionStyle,
140
+ fontWeight: tag === current ? 600 : 400
141
+ },
142
+ children: languageName(tag)
143
+ }
144
+ ) }, tag)) }) : null
145
+ ] });
146
+ }
147
+ function AutoLanguageSwitcher({ languages }) {
148
+ const [inlineElsewhere, setInlineElsewhere] = useState(false);
149
+ useEffect(() => {
150
+ const check = () => setInlineElsewhere(document.documentElement.hasAttribute(INLINE_MARKER));
151
+ check();
152
+ const observer = new MutationObserver(check);
153
+ observer.observe(document.documentElement, {
154
+ attributes: true,
155
+ attributeFilter: [INLINE_MARKER]
156
+ });
157
+ return () => observer.disconnect();
158
+ }, []);
159
+ if (inlineElsewhere || languages.length < 2) return null;
160
+ return /* @__PURE__ */ jsx(SettoLanguages, { languages });
161
+ }
162
+ function GlobeIcon() {
163
+ return /* @__PURE__ */ jsxs(
164
+ "svg",
165
+ {
166
+ width: "15",
167
+ height: "15",
168
+ viewBox: "0 0 24 24",
169
+ fill: "none",
170
+ stroke: "currentColor",
171
+ strokeWidth: "1.8",
172
+ strokeLinecap: "round",
173
+ strokeLinejoin: "round",
174
+ "aria-hidden": "true",
175
+ children: [
176
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "9" }),
177
+ /* @__PURE__ */ jsx("path", { d: "M3 12h18M12 3c2.4 2.6 3.6 5.6 3.6 9s-1.2 6.4-3.6 9c-2.4-2.6-3.6-5.6-3.6-9S9.6 5.6 12 3z" })
178
+ ]
179
+ }
180
+ );
181
+ }
182
+ const floatingWrapStyle = {
183
+ position: "fixed",
184
+ left: "max(16px, env(safe-area-inset-left))",
185
+ bottom: "max(16px, env(safe-area-inset-bottom))",
186
+ zIndex: 2147482e3,
187
+ fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif'
188
+ };
189
+ const inlineWrapStyle = {
190
+ position: "relative",
191
+ display: "inline-block",
192
+ fontFamily: "inherit"
193
+ };
194
+ const triggerStyle = {
195
+ display: "inline-flex",
196
+ alignItems: "center",
197
+ gap: 7,
198
+ minHeight: 40,
199
+ padding: "8px 14px",
200
+ borderRadius: 999,
201
+ border: "1px solid rgba(127, 127, 127, 0.32)",
202
+ // Translucent over whatever the site put behind it, and legible either way.
203
+ background: "rgba(255, 255, 255, 0.82)",
204
+ backdropFilter: "blur(8px)",
205
+ WebkitBackdropFilter: "blur(8px)",
206
+ color: "#1a1a1a",
207
+ fontSize: 13.5,
208
+ fontWeight: 500,
209
+ lineHeight: 1,
210
+ cursor: "pointer",
211
+ boxShadow: "0 4px 14px rgba(0, 0, 0, 0.12)"
212
+ };
213
+ const listBase = {
214
+ position: "absolute",
215
+ margin: 0,
216
+ padding: 4,
217
+ listStyle: "none",
218
+ minWidth: 150,
219
+ borderRadius: 12,
220
+ border: "1px solid rgba(127, 127, 127, 0.24)",
221
+ background: "#ffffff",
222
+ boxShadow: "0 12px 32px rgba(0, 0, 0, 0.18)",
223
+ zIndex: 1
224
+ };
225
+ const floatingListStyle = { ...listBase, left: 0, bottom: "calc(100% + 8px)" };
226
+ const inlineListStyle = { ...listBase, left: 0, top: "calc(100% + 8px)" };
227
+ const optionStyle = {
228
+ display: "block",
229
+ width: "100%",
230
+ minHeight: 40,
231
+ padding: "9px 12px",
232
+ border: 0,
233
+ borderRadius: 8,
234
+ background: "transparent",
235
+ color: "#1a1a1a",
236
+ fontSize: 13.5,
237
+ textAlign: "left",
238
+ cursor: "pointer"
239
+ };
7
240
  function __rest(s, e) {
8
241
  var t = {};
9
242
  for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
@@ -21767,6 +22000,16 @@ function SettoProvider({ config, children }) {
21767
22000
  }, []);
21768
22001
  const access = useSiteAccess(supabase, session, config.siteId);
21769
22002
  const { i18n } = useTranslation();
22003
+ const languages = useMemo(
22004
+ () => siteLanguages(config.languages, config.languages?.[0]),
22005
+ [config.languages]
22006
+ );
22007
+ useEffect(() => {
22008
+ if (!i18n || languages.length < 2) return;
22009
+ const wanted = pickInitialLanguage(languages, void 0, readStoredLanguage());
22010
+ if (wanted && wanted !== i18n.language) void i18n.changeLanguage(wanted);
22011
+ document.documentElement.lang = wanted;
22012
+ }, [i18n, languages]);
21770
22013
  const storeRef = useRef(null);
21771
22014
  const themeStoreRef = useRef(null);
21772
22015
  const assetStoreRef = useRef(null);
@@ -21809,7 +22052,8 @@ function SettoProvider({ config, children }) {
21809
22052
  const editShellActive = editMode && !!value.store;
21810
22053
  return /* @__PURE__ */ jsx(SettoContext.Provider, { value, children: /* @__PURE__ */ jsxs(Fragment, { children: [
21811
22054
  /* @__PURE__ */ jsx(EditModeShell, { active: editShellActive, themeStore: value.themeStore, children }),
21812
- /* @__PURE__ */ jsx(SettoChrome, {})
22055
+ /* @__PURE__ */ jsx(SettoChrome, {}),
22056
+ /* @__PURE__ */ jsx(AutoLanguageSwitcher, { languages })
21813
22057
  ] }) });
21814
22058
  }
21815
22059
  function useSetto() {
@@ -24603,6 +24847,7 @@ export {
24603
24847
  BLOCK_REGISTRY,
24604
24848
  BLOCK_TYPES,
24605
24849
  CHECKOUT_INTENT,
24850
+ DEFAULT_LANGUAGE,
24606
24851
  EMPTY_I18N_STORE_SNAPSHOT,
24607
24852
  FAB_INSET,
24608
24853
  FAB_SIZE,
@@ -24621,6 +24866,7 @@ export {
24621
24866
  SettoForm,
24622
24867
  SettoIcon,
24623
24868
  SettoImage,
24869
+ SettoLanguages,
24624
24870
  SettoMark,
24625
24871
  SettoProvider,
24626
24872
  SettoRepeater,
@@ -24640,11 +24886,14 @@ export {
24640
24886
  isAdminRoute,
24641
24887
  isFormFieldType,
24642
24888
  isLinkContext,
24889
+ languageName,
24643
24890
  nodeChildren,
24644
24891
  parseBlockTree,
24892
+ pickInitialLanguage,
24645
24893
  readLocaleFields,
24646
24894
  settoSubmissionUrl,
24647
24895
  siteHomeUrl,
24896
+ siteLanguages,
24648
24897
  useCoarsePointer,
24649
24898
  useGuestEdit,
24650
24899
  useSectionTheme,