@sevenfold/setto-client 0.17.0 → 0.18.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { SettoLanguages } from './LanguageSwitcher';
2
2
  export { DEFAULT_LANGUAGE, languageName, pickInitialLanguage, siteLanguages, } from './lib/languages';
3
+ export { alternateLinks, languageFromPath, pathForLanguage, redirectForVisitor, type LanguageRoute, } from './lib/language-routing';
3
4
  export { SettoProvider, useSetto } from './provider';
4
5
  export { GuestEditProvider, useGuestEdit } from './guest-edit';
5
6
  export { SettoChrome, useSettoChromeSlot } from './chrome';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The language lives in the URL.
3
+ *
4
+ * It did not, for one release: the choice was kept per browser in
5
+ * `localStorage`, which is invisible to a search engine and unshareable
6
+ * between people. A Norwegian plumber who wants to be found in English cannot
7
+ * be, if the English version has no address. So the primary language stays at
8
+ * the root — `/`, `/priser` — and every other language takes a prefix:
9
+ * `/en`, `/en/priser`.
10
+ *
11
+ * Primary at the root rather than `/no/…` deliberately: every Setto site alive
12
+ * today has its Norwegian pages at the root, they are linked to and indexed
13
+ * there, and moving them would trade a real thing for a tidy one.
14
+ *
15
+ * The routes themselves never learn about this. `BrowserRouter` takes the
16
+ * prefix as its `basename`, so a site's own `/priser` keeps matching whether
17
+ * the visitor is at `/priser` or `/en/priser` — which matters because those
18
+ * routes are written per site by the build agent, and a scheme that required
19
+ * it to think about languages is a scheme that would be got wrong.
20
+ */
21
+ export interface LanguageRoute {
22
+ /** The language this URL is in. */
23
+ language: string;
24
+ /** `''` for the primary language, `/en` otherwise. Feed to `basename`. */
25
+ basename: string;
26
+ /** The path with the prefix removed, always starting with `/`. */
27
+ rest: string;
28
+ }
29
+ /**
30
+ * Read the language out of a path.
31
+ *
32
+ * Only a prefix that is a language this site actually speaks counts — a site
33
+ * with a page at `/no` and no Norwegian in its list keeps that page.
34
+ */
35
+ export declare function languageFromPath(pathname: string, languages: readonly string[], primary?: string): LanguageRoute;
36
+ /** The same page, in another language. */
37
+ export declare function pathForLanguage(pathname: string, target: string, languages: readonly string[], primary?: string): string;
38
+ /**
39
+ * Every address this page has, for `hreflang`.
40
+ *
41
+ * Without these a search engine sees two unrelated pages that happen to say
42
+ * similar things, and picks one. `x-default` points at the primary, which is
43
+ * what a reader with no stated preference should land on.
44
+ */
45
+ export declare function alternateLinks(origin: string, pathname: string, languages: readonly string[], primary?: string): Array<{
46
+ hreflang: string;
47
+ href: string;
48
+ }>;
49
+ /**
50
+ * Where a first-time visitor should be sent, or null to leave them alone.
51
+ *
52
+ * Only ever from the unprefixed root towards a prefix, only when the browser
53
+ * asks for a language this site speaks and the visitor has expressed no
54
+ * preference of their own here before. One direction only: a redirect that can
55
+ * send `/en` back to `/` is a redirect that can loop, and somebody who has
56
+ * clicked «Norsk» on an English page must not be bounced back to English on
57
+ * their next click.
58
+ */
59
+ export declare function redirectForVisitor(pathname: string, languages: readonly string[], browserLanguages: readonly string[], stored: string | null, primary?: string): string | null;
@@ -72,6 +72,59 @@ function pickInitialLanguage(available, browserLanguages = typeof navigator ===
72
72
  }
73
73
  return fallback;
74
74
  }
75
+ function normalise(pathname) {
76
+ const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
77
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
78
+ }
79
+ function languageFromPath(pathname, languages, primary = languages[0] ?? DEFAULT_LANGUAGE) {
80
+ const path = normalise(pathname);
81
+ const first = path.split("/")[1] ?? "";
82
+ const match = languages.find(
83
+ (tag) => tag !== primary && tag.toLowerCase() === first.toLowerCase()
84
+ );
85
+ if (!match) return { language: primary, basename: "", rest: path };
86
+ const rest = path.slice(first.length + 1);
87
+ return { language: match, basename: `/${match}`, rest: rest === "" ? "/" : rest };
88
+ }
89
+ function pathForLanguage(pathname, target, languages, primary = languages[0] ?? DEFAULT_LANGUAGE) {
90
+ const { rest } = languageFromPath(pathname, languages, primary);
91
+ if (target === primary) return rest;
92
+ return rest === "/" ? `/${target}` : `/${target}${rest}`;
93
+ }
94
+ function alternateLinks(origin, pathname, languages, primary = languages[0] ?? DEFAULT_LANGUAGE) {
95
+ if (languages.length < 2) return [];
96
+ const links = languages.map((tag) => ({
97
+ hreflang: tag,
98
+ href: origin + pathForLanguage(pathname, tag, languages, primary)
99
+ }));
100
+ links.push({
101
+ hreflang: "x-default",
102
+ href: origin + pathForLanguage(pathname, primary, languages, primary)
103
+ });
104
+ return links;
105
+ }
106
+ function isReservedPath(pathname) {
107
+ return pathname === "/setto" || pathname.startsWith("/setto/");
108
+ }
109
+ function redirectForVisitor(pathname, languages, browserLanguages, stored, primary = languages[0] ?? DEFAULT_LANGUAGE) {
110
+ if (languages.length < 2) return null;
111
+ if (isReservedPath(pathname)) return null;
112
+ const current = languageFromPath(pathname, languages, primary);
113
+ if (current.basename !== "") return null;
114
+ if (stored) {
115
+ return stored !== primary && languages.includes(stored) ? pathForLanguage(pathname, stored, languages, primary) : null;
116
+ }
117
+ for (const wanted of browserLanguages) {
118
+ const base = wanted.toLowerCase().split("-")[0];
119
+ if (!base) continue;
120
+ if (base === primary.toLowerCase().split("-")[0]) return null;
121
+ const norwegian = ["no", "nb", "nn"];
122
+ if (norwegian.includes(base) && norwegian.includes(primary.toLowerCase())) return null;
123
+ const hit = languages.find((tag) => tag.toLowerCase().split("-")[0] === base);
124
+ if (hit && hit !== primary) return pathForLanguage(pathname, hit, languages, primary);
125
+ }
126
+ return null;
127
+ }
75
128
  const INLINE_MARKER = "data-setto-languages-inline";
76
129
  function SettoLanguages({ languages, inline = false }) {
77
130
  const { i18n } = useTranslation();
@@ -109,8 +162,8 @@ function SettoLanguages({ languages, inline = false }) {
109
162
  setOpen(false);
110
163
  if (tag === current) return;
111
164
  storeLanguage(tag);
112
- void i18n.changeLanguage(tag);
113
- document.documentElement.lang = tag;
165
+ const target = pathForLanguage(window.location.pathname, tag, languages);
166
+ window.location.assign(target + window.location.search + window.location.hash);
114
167
  };
115
168
  return /* @__PURE__ */ jsxs("div", { ref, style: inline ? inlineWrapStyle : floatingWrapStyle, children: [
116
169
  /* @__PURE__ */ jsxs(
@@ -22006,10 +22059,34 @@ function SettoProvider({ config, children }) {
22006
22059
  );
22007
22060
  useEffect(() => {
22008
22061
  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;
22062
+ const redirect = redirectForVisitor(
22063
+ window.location.pathname,
22064
+ languages,
22065
+ navigator.languages ?? [navigator.language],
22066
+ readStoredLanguage()
22067
+ );
22068
+ if (redirect && redirect !== window.location.pathname) {
22069
+ window.location.replace(redirect + window.location.search + window.location.hash);
22070
+ return;
22071
+ }
22072
+ const { language } = languageFromPath(window.location.pathname, languages);
22073
+ if (language !== i18n.language) void i18n.changeLanguage(language);
22074
+ document.documentElement.lang = language;
22012
22075
  }, [i18n, languages]);
22076
+ useEffect(() => {
22077
+ if (languages.length < 2) return;
22078
+ const links = alternateLinks(window.location.origin, window.location.pathname, languages);
22079
+ const added = links.map(({ hreflang, href }) => {
22080
+ const el = document.createElement("link");
22081
+ el.rel = "alternate";
22082
+ el.hreflang = hreflang;
22083
+ el.href = href;
22084
+ el.dataset.settoAlternate = "";
22085
+ document.head.appendChild(el);
22086
+ return el;
22087
+ });
22088
+ return () => added.forEach((el) => el.remove());
22089
+ }, [languages]);
22013
22090
  const storeRef = useRef(null);
22014
22091
  const themeStoreRef = useRef(null);
22015
22092
  const assetStoreRef = useRef(null);
@@ -24875,6 +24952,7 @@ export {
24875
24952
  T,
24876
24953
  adminRedirectUrl,
24877
24954
  adminViewUrl,
24955
+ alternateLinks,
24878
24956
  checkoutIntentUrl,
24879
24957
  currentAdminView,
24880
24958
  extractContentKeys,
@@ -24886,11 +24964,14 @@ export {
24886
24964
  isAdminRoute,
24887
24965
  isFormFieldType,
24888
24966
  isLinkContext,
24967
+ languageFromPath,
24889
24968
  languageName,
24890
24969
  nodeChildren,
24891
24970
  parseBlockTree,
24971
+ pathForLanguage,
24892
24972
  pickInitialLanguage,
24893
24973
  readLocaleFields,
24974
+ redirectForVisitor,
24894
24975
  settoSubmissionUrl,
24895
24976
  siteHomeUrl,
24896
24977
  siteLanguages,