@autobusal/common 1.26.0 → 1.27.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/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## 1.27.0
5
+
6
+ ### Added
7
+
8
+ - **`localeHref`** — the one URL a page has in a given language, once a brand
9
+ answers on more than one domain. A language either has a **domain of its
10
+ own** and lives at that domain's bare paths (`busmagus.ro/city/tirana`), or
11
+ it does not and keeps its `/{locale}/` prefix on the primary. Which of the
12
+ two is a property of the *language*, not of the page, so this is one
13
+ function every caller shares. The deliberate twin of obtapi's
14
+ `Libraries\Labels\LocaleUrl::href`. See `magus/MULTI_TLD_PLAN.md` item 4.
15
+
16
+ With no domain list — every brand today — it resolves to `origin + path`
17
+ and the prefix convention, exactly as before.
18
+
19
+ ### Changed
20
+
21
+ - **`Meta`'s canonical is resolved through the domain that owns the page's
22
+ language**, not off `window.location.origin`. Three things follow that the
23
+ browser's own host cannot say: an **alias** (a `www.` host, a legacy domain
24
+ kept for old links) now canonicalises to the real site instead of declaring
25
+ itself the original; `/it/city/tirana` on the `.com` canonicalises to
26
+ `busmagus.it/city/tirana` once Italian has a domain, so the prefixed form
27
+ stops competing with the ccTLD that replaced it; and the canonical is built
28
+ by the same function as the hreflang set, so it is always one of its own
29
+ alternates — a canonical that is not is discarded by Google outright, and
30
+ nothing on the page would have shown it.
31
+
32
+ - **`Meta`'s hreflang alternates point at domains** where a language has one
33
+ and keep their prefix where it does not. The set stays whole either way,
34
+ which is the part that matters: an hreflang cluster is treated as
35
+ reciprocal only when every member declares every other. `x-default`
36
+ follows the **primary's** language rather than the current origin's — a
37
+ `.ro` build announcing itself as x-default would offer Romanian to
38
+ everybody a search engine could not place.
39
+
40
+ - **`ChangeLanguage` navigates across domains.** Choosing a language that has
41
+ its own domain leaves the site: another origin, its own bundle, its own
42
+ settings payload, so it cannot go through the router. The i18n change and
43
+ the stored preference are deliberately skipped on that path — they would
44
+ apply to the site being left, and storing the language you are walking away
45
+ from is how somebody returns to the `.com` later and finds it rendering
46
+ Italian at English URLs.
47
+
48
+ ## 1.26.1
49
+
50
+ ### Fixed
51
+
52
+ - **`Table` drew an empty "Options" column when a table had only
53
+ top-of-table controls.** `create`, `search` and `extra` all live *above* the
54
+ table — `Actions/Get` already returns null for each — but `Header` and
55
+ `Rows` decided whether to draw the options column from `actions.length > 0`,
56
+ which counted them. A table using the component purely for sorting, search
57
+ and paging therefore got an "Options" heading with an empty cell under every
58
+ row.
59
+
60
+ Invisible on the admin screens, where every table has row actions anyway;
61
+ visible the moment the public bus-company directory used it. The row/top
62
+ split is one shared list now (`Table/rowActions.ts`) rather than a condition
63
+ repeated in three files that could drift.
64
+
3
65
  ## 1.26.0
4
66
 
5
67
  ### Added
@@ -3,7 +3,7 @@ import { TFunction, i18n } from 'i18next';
3
3
  import { useLocation, useNavigate } from 'react-router-dom';
4
4
  import { useOutside } from '@autobusal/hooks';
5
5
  import Flag from './Flag';
6
- import { withLocale } from '../Locale/locale';
6
+ import { withLocale, localeHref } from '../Locale/locale';
7
7
  import { Container, Title, ContainerButtons, ButtonChange } from './styles';
8
8
  import { useGetSettings } from '@autobusal/providers/services';
9
9
 
@@ -55,6 +55,27 @@ const ChangeLanguage = ({ type, t, i18n, localised = false, onClose }: Props): J
55
55
  * language in place and stay put.
56
56
  */
57
57
  const onChange = (language: string): void => {
58
+ const tail = `${ location.search }${ location.hash }`;
59
+
60
+ // Edited: Ferjolt Ozuni - Date: 2026-08-06
61
+ // A language with a DOMAIN of its own lives on that domain, so choosing
62
+ // it leaves this site. That cannot go through the router - another
63
+ // origin, its own bundle, its own settings payload - so it is a real
64
+ // navigation, and the two state changes below are deliberately skipped:
65
+ // they would apply to the site being left rather than the one being
66
+ // opened, and storing the language we are walking away from is how
67
+ // somebody returns to the .com later and finds it rendering Italian at
68
+ // English URLs.
69
+ if (localised) {
70
+ const href = localeHref(location.pathname, language, fallback, available, data.domains ?? [], window.location.origin);
71
+
72
+ if (!href.startsWith(window.location.origin)) {
73
+ window.location.assign(`${ href }${ tail }`);
74
+
75
+ return;
76
+ }
77
+ }
78
+
58
79
  i18n.changeLanguage(language);
59
80
  localStorage.setItem('language', language);
60
81
 
@@ -62,7 +83,7 @@ const ChangeLanguage = ({ type, t, i18n, localised = false, onClose }: Props): J
62
83
  const to = withLocale(location.pathname, language, fallback, available);
63
84
 
64
85
  if (to !== location.pathname) {
65
- navigate(`${ to }${ location.search }${ location.hash }`);
86
+ navigate(`${ to }${ tail }`);
66
87
  }
67
88
  }
68
89
 
package/Locale/locale.ts CHANGED
@@ -58,6 +58,72 @@ export const withLocale = (pathname: string, locale: string, defaultLocale: stri
58
58
  return clean === '/' ? `/${ locale }` : `/${ locale }${ clean }`;
59
59
  };
60
60
 
61
+ /**
62
+ * One origin a brand answers on, and the language it serves there.
63
+ *
64
+ * Ferjolt Ozuni - Date: 2026-08-06
65
+ * Mirrors obtapi's Settings\ViewController::domains(). `locale` may be null:
66
+ * an alias (a www. host, a legacy domain kept alive for old links) answers
67
+ * without claiming a language, so nothing points hreflang at it.
68
+ */
69
+ export interface LocaleDomain {
70
+ domain: string
71
+ locale?: string | null
72
+ primary?: boolean
73
+ }
74
+
75
+ /**
76
+ * The ONE URL a page has in a given language.
77
+ *
78
+ * Ferjolt Ozuni - Date: 2026-08-06 - see magus/MULTI_TLD_PLAN.md item 4.
79
+ *
80
+ * A language reaches its own URL one of two ways, and which one is a
81
+ * property of the language rather than of the page:
82
+ *
83
+ * - it has a DOMAIN of its own, and lives at that domain's bare paths -
84
+ * busmagus.ro/city/tirana, not busmagus.com/ro/city/tirana;
85
+ * - it does not, and lives under a /{locale}/ prefix on the primary.
86
+ *
87
+ * Both forms have to be derivable from anywhere, because the canonical, the
88
+ * hreflang set and the language switcher must agree to the character. They
89
+ * are the same claim about the same page said three times, and a canonical
90
+ * that is not itself in its own hreflang set is discarded outright.
91
+ *
92
+ * `fallbackOrigin` is what to call "here" when the brand has no domain list
93
+ * at all - an older API, or a payload that has not arrived. It resolves to
94
+ * the previous single-origin behaviour exactly, which is why this is safe to
95
+ * put on every page before any brand has a second domain.
96
+ */
97
+ export const localeHref = (
98
+ pathname: string,
99
+ locale: string,
100
+ defaultLocale: string,
101
+ available: string[],
102
+ domains: LocaleDomain[],
103
+ fallbackOrigin: string
104
+ ): string => {
105
+ const { path } = splitLocale(pathname, available);
106
+
107
+ const clean = path === '' ? '/' : path;
108
+
109
+ const owner = domains.find(item => item.locale === locale);
110
+
111
+ if (owner) {
112
+ return owner.domain + clean;
113
+ }
114
+
115
+ const primary = domains.find(item => item.primary);
116
+
117
+ // Which language is unprefixed on the primary - NOT `defaultLocale`, which
118
+ // on a ccTLD is that ccTLD's own language (obtapi answers the per-origin
119
+ // question there). Reading it from the primary row is what stops the .ro
120
+ // build announcing English as busmagus.com/en/... when English is the
121
+ // thing that lives at busmagus.com/... itself.
122
+ const unprefixed = primary?.locale ?? defaultLocale;
123
+
124
+ return (primary?.domain ?? fallbackOrigin) + withLocale(clean, locale, unprefixed, available);
125
+ };
126
+
61
127
  /**
62
128
  * Which language a URL is asking for, falling back to the stored preference
63
129
  * and then the brand default.
package/Meta.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useLocation } from 'react-router-dom';
3
- import { splitLocale, withLocale } from './Locale/locale';
3
+ import { splitLocale, localeHref } from './Locale/locale';
4
4
  import { pageview } from '@autobusal/providers/Setup/analytics';
5
5
  import { useGetSettings } from '@autobusal/providers/services';
6
6
 
@@ -155,10 +155,45 @@ const normalisePath = (pathname: string): string => (
155
155
  pathname.length > 1 && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname
156
156
  );
157
157
 
158
+ /**
159
+ * Edited: Ferjolt Ozuni - Date: 2026-08-06
160
+ * Resolved through the domain that OWNS this page's language rather than off
161
+ * whichever host the visitor happens to be on - see localeHref and
162
+ * magus/MULTI_TLD_PLAN.md item 4.
163
+ *
164
+ * Three things fall out of that which `window.location.origin` cannot say:
165
+ *
166
+ * - an ALIAS (a www. host, a legacy domain kept alive for old links) now
167
+ * canonicalises to the real site instead of declaring itself the
168
+ * original, which is the whole job of a canonical and the one case where
169
+ * taking it from the browser is actively wrong;
170
+ * - /it/city/tirana served from the .com canonicalises to
171
+ * busmagus.it/city/tirana once Italian has a domain, so the prefixed form
172
+ * stops competing with the ccTLD that replaced it;
173
+ * - the canonical is built by the same function as the hreflang set, so it
174
+ * is always one of its own alternates. A canonical that is not is
175
+ * discarded by Google outright, and nothing on the page would show it.
176
+ *
177
+ * With no domain list this is `origin + path`, exactly as before.
178
+ */
158
179
  const useCanonicalUrl = (explicit?: string): string => {
159
180
  const location = useLocation();
160
181
 
161
- return explicit ?? `${ window.location.origin }${ normalisePath(location.pathname) }`;
182
+ const { data } = useGetSettings();
183
+
184
+ if (explicit) {
185
+ return explicit;
186
+ }
187
+
188
+ const available = data?.preferences?.languages?.available ?? [];
189
+
190
+ const fallback = data?.preferences?.languages?.default ?? 'en';
191
+
192
+ const path = normalisePath(location.pathname);
193
+
194
+ const { locale } = splitLocale(path, available);
195
+
196
+ return localeHref(path, locale ?? fallback, fallback, available, data?.domains ?? [], window.location.origin);
162
197
  };
163
198
 
164
199
  // A single-page app never reloads, so GTM's page-load trigger fires exactly
@@ -207,6 +242,19 @@ const usePageviewTracking = (path: string, title: string): void => {
207
242
  * Suppressed for noindex pages: they have nothing to offer alternates for,
208
243
  * and telling a crawler about language variants of a page it has just been
209
244
  * asked not to index is noise at best.
245
+ *
246
+ * Edited: Ferjolt Ozuni - Date: 2026-08-06
247
+ * Each language now resolves to the DOMAIN that owns it where it has one,
248
+ * and keeps its /{locale}/ prefix on the primary where it does not - see
249
+ * localeHref. The set stays whole either way, which is the part that
250
+ * matters: Google treats an hreflang cluster as reciprocal only when every
251
+ * member declares every other, so a page cannot list "the domains" and leave
252
+ * out the languages that have none.
253
+ *
254
+ * x-default follows the PRIMARY's own language rather than this origin's.
255
+ * The .com is the answer when there is no signal about the visitor; a .ro
256
+ * build announcing itself as x-default would be offering Romanian to
257
+ * everyone the search engine could not place.
210
258
  */
211
259
  const useAlternates = (noIndex: boolean, explicitUrl?: string): { locale: string, href: string }[] => {
212
260
  const location = useLocation();
@@ -226,13 +274,20 @@ const useAlternates = (noIndex: boolean, explicitUrl?: string): { locale: string
226
274
 
227
275
  const origin = window.location.origin;
228
276
 
277
+ const domains = data?.domains ?? [];
278
+
229
279
  const { path } = splitLocale(normalisePath(location.pathname), available);
230
280
 
281
+ // The primary's own language, so x-default lands on the .com. Falls back
282
+ // to this origin's default when there is no domain list at all, which is
283
+ // the single-domain case and exactly the previous behaviour.
284
+ const neutral = domains.find(item => item.primary)?.locale ?? fallback;
285
+
231
286
  return [
232
- { locale: 'x-default', href: `${ origin }${ withLocale(path, fallback, fallback, available) }` },
287
+ { locale: 'x-default', href: localeHref(path, neutral, fallback, available, domains, origin) },
233
288
  ...available.map(locale => ({
234
289
  locale,
235
- href: `${ origin }${ withLocale(path, locale, fallback, available) }`
290
+ href: localeHref(path, locale, fallback, available, domains, origin)
236
291
  }))
237
292
  ];
238
293
  };
@@ -1,6 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import Sort from './Sort';
3
3
  import { getUrl } from '../browseUrl';
4
+ import { rowActions } from '../rowActions';
4
5
  import { Container, Td, Column, Text } from './styles';
5
6
  import { ColumnData, SortingData } from '../types';
6
7
 
@@ -31,7 +32,7 @@ const Header = ({ url, data, sorting, t, actions }: Props): JSX.Element => {
31
32
  <tr>
32
33
  { columns }
33
34
 
34
- { (actions !== undefined && actions.length > 0) && (
35
+ { rowActions(actions).length > 0 && (
35
36
  <Td $width={ 10 }>
36
37
  <Column>
37
38
  <Text>{ t('table.options', { ns: 'common' }) }</Text>
@@ -3,6 +3,7 @@ import { NavigateFunction } from 'react-router-dom';
3
3
  import Loading from './Loading';
4
4
  import NotFound from './NotFound';
5
5
  import Actions from '../Actions/Actions';
6
+ import { rowActions } from '../rowActions';
6
7
  import { Tbody, Tr } from './styles';
7
8
 
8
9
  interface Props {
@@ -39,7 +40,7 @@ const Rows = ({ url, urlWith, data, loading, fetching, colLength, t, actions, ha
39
40
  <Tr key={ index } $viewable={ isViewable } onClick={ () => onClick(item.props.id) }>
40
41
  { item }
41
42
 
42
- { (actions !== undefined && actions.length > 0) && (
43
+ { rowActions(actions).length > 0 && (
43
44
  <Actions
44
45
  id={ item.props.id }
45
46
  url={ url }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which of a table's actions belong to a ROW rather than to the top of the
3
+ * table.
4
+ *
5
+ * Ferjolt Ozuni - Date: 2026-08-06
6
+ *
7
+ * `create`, `search` and `extra` are all controls that live ABOVE the table -
8
+ * Top renders them and Actions/Get already returns null for each. But Header
9
+ * and Rows both decided whether to draw the options column from
10
+ * `actions.length > 0`, counting those three, so a table with only
11
+ * top-of-table controls got an "Options" heading with an empty cell under
12
+ * every row. Harmless on the admin screens, where every table has row actions
13
+ * anyway - visible the moment a PUBLIC page used the component for its
14
+ * sorting and paging alone.
15
+ *
16
+ * One list, shared by the three places that need it, so it cannot drift.
17
+ */
18
+ export const TOP_ACTIONS = ['create', 'search', 'extra'];
19
+
20
+ export const rowActions = (actions?: string[]): string[] => (
21
+ (actions ?? []).filter(action => !TOP_ACTIONS.includes(action))
22
+ );
package/index.ts CHANGED
@@ -36,7 +36,7 @@ import LocaleGate from './Locale/LocaleGate';
36
36
  import SeoOverride from './SeoOverride/SeoOverride';
37
37
  import HandoffBanner from './HandoffBanner/HandoffBanner';
38
38
  import localiseRoutes from './Locale/routes';
39
- import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
39
+ import { splitLocale, withLocale, localeFromPath, localeHref } from './Locale/locale';
40
40
  import RouteItem from './RouteItem/RouteItem';
41
41
  import Row from './Table/Row';
42
42
  import Table from './Table/Table';
@@ -91,10 +91,13 @@ export {
91
91
  splitLocale,
92
92
  withLocale,
93
93
  localeFromPath,
94
+ localeHref,
94
95
  RouteItem,
95
96
  Row,
96
97
  Table,
97
98
  UnderConstruction,
98
99
  useUnderConstruction,
99
100
  Viewer
100
- };
101
+ };
102
+
103
+ export type { LocaleDomain } from './Locale/locale';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"