@autobusal/providers 1.29.1 → 1.29.3
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 +34 -35
- package/Errors/BootstrapError.tsx +107 -0
- package/Providers.tsx +13 -0
- package/Queries/Queries.tsx +48 -2
- package/Setup/Setup.tsx +1 -22
- package/Setup/languages.ts +2 -42
- package/package.json +1 -1
- package/types/settings.ts +0 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,50 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.29.
|
|
3
|
+
## 1.29.3
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
- **A failed settings fetch white-screened the entire app.** `useGetSettings`
|
|
8
|
+
is a *suspense* query, so a failure throws during render rather than
|
|
9
|
+
resolving to an error state — and nothing in `Providers` caught it, so the
|
|
10
|
+
whole tree unmounted and left a blank page with no message, no retry and
|
|
11
|
+
nothing in the console. A rate limit, a brief API blip or a restart
|
|
12
|
+
mid-deploy all produced the same silent nothing. Reproduced in a browser,
|
|
13
|
+
not deduced.
|
|
14
|
+
|
|
15
|
+
Fixed with a new **`BootstrapError`** boundary wrapping everything. It
|
|
16
|
+
cannot be the existing `ErrorBoundary`: that renders `Errors/Message`,
|
|
17
|
+
which calls `useGetSettings()` itself and `useNavigate` — above the
|
|
18
|
+
settings fetch it would rethrow inside its own fallback. An error screen
|
|
19
|
+
for "nothing loaded" cannot depend on anything having loaded, so this one
|
|
20
|
+
uses no settings, no i18next, no router and no theme — inline styles and
|
|
21
|
+
the static `@lang/errors` map, with a reload button.
|
|
19
22
|
|
|
20
23
|
### Changed
|
|
21
24
|
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
to anybody testing in a clean browser.
|
|
25
|
+
- **Queries retry what can plausibly differ, and only that.** `retry: false`
|
|
26
|
+
was wrong in both directions: a single dropped connection or 500 was fatal
|
|
27
|
+
(and on the suspense bootstrap path, fatal meant the whole app), while
|
|
28
|
+
nothing distinguished that from a 401/403/404 — a decided answer that
|
|
29
|
+
repeats identically while spending the rate-limit budget a 429 exists to
|
|
30
|
+
protect. Now 5xx, 408, 425, 429 and network-level failures get two retries
|
|
31
|
+
at 700ms then 1400ms; everything else fails at once, so a real 403 surfaces
|
|
32
|
+
as a real 403 instead of three seconds of spinner.
|
|
31
33
|
|
|
32
|
-
It defaults to whatever `languages()` was told, so the two call sites that
|
|
33
|
-
ask outside of setup (the 500-error text in `apiClient`, the error page in
|
|
34
|
-
`Errors/Message`) answer in the language actually being read rather than
|
|
35
|
-
showing English error copy on a Romanian page.
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
leaves the ordering exactly as it was.
|
|
35
|
+
## 1.29.2
|
|
39
36
|
|
|
40
|
-
###
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- **Everything 1.29.0/1.29.1 added for multi-TLD** — `getLanguage`'s
|
|
40
|
+
`domainLocale` argument and the module-level origin it defaulted to, the
|
|
41
|
+
argument `Setup` passed it, and `SettingsData.domain` / `.domains` /
|
|
42
|
+
`.locale`. The stored language preference is once again the only thing that
|
|
43
|
+
outranks the brand default on an unprefixed URL.
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`domains` is what turns hreflang from locale-to-path-prefix into
|
|
45
|
-
locale-to-**domain** (see `@autobusal/common`'s `localeHref`); its first
|
|
46
|
-
entry is the primary. All three optional, so an older API cannot break a
|
|
47
|
-
newer build.
|
|
45
|
+
See `@autobusal/common@1.27.1` for why. Published forward rather than
|
|
46
|
+
downgraded, for the same reason.
|
|
48
47
|
|
|
49
48
|
## 1.28.1
|
|
50
49
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { getLanguage } from '../Setup/languages';
|
|
3
|
+
import errors from '@lang/errors';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
children: JSX.Element[] | JSX.Element
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The last thing standing when the app cannot start at all.
|
|
11
|
+
*
|
|
12
|
+
* Ferjolt Ozuni - Date: 2026-08-06
|
|
13
|
+
*
|
|
14
|
+
* `useGetSettings` is a SUSPENSE query, so a failure throws during render
|
|
15
|
+
* rather than resolving to an error state. It sits above everything in
|
|
16
|
+
* Providers, and there was no error boundary over it - so any failure of
|
|
17
|
+
* /api/settings/get unmounted the entire tree and left a **blank white page**
|
|
18
|
+
* with no message, no retry and nothing in the console. A rate limit, a
|
|
19
|
+
* five-second API blip or a restart mid-deploy all produced the same silent
|
|
20
|
+
* nothing. Reproduced in the browser, not deduced.
|
|
21
|
+
*
|
|
22
|
+
* Why this is not @autobusal/providers' own ErrorBoundary: that one renders
|
|
23
|
+
* `Errors/Message`, and Message calls `useGetSettings()` itself - the very
|
|
24
|
+
* query that just failed - plus `useNavigate`, which needs a Router. Putting
|
|
25
|
+
* it above the settings fetch would rethrow inside the fallback. An error
|
|
26
|
+
* screen for "nothing loaded" cannot depend on anything having loaded.
|
|
27
|
+
*
|
|
28
|
+
* So this deliberately uses NO settings, NO i18next, NO router and NO
|
|
29
|
+
* styled-components theme - inline styles and the static `@lang/errors` map,
|
|
30
|
+
* which is a plain object kept for exactly this purpose (it is what the
|
|
31
|
+
* toast and Message already read before i18next is guaranteed to exist).
|
|
32
|
+
*
|
|
33
|
+
* Reload rather than a retry button wired to react-query: whatever failed
|
|
34
|
+
* happened during bootstrap, so there is no reliable app state left to retry
|
|
35
|
+
* *into*. A full reload is the honest recovery, and it is what a visitor
|
|
36
|
+
* would do anyway.
|
|
37
|
+
*/
|
|
38
|
+
class BootstrapError extends React.Component<Props, { failed: boolean }> {
|
|
39
|
+
state = { failed: false };
|
|
40
|
+
|
|
41
|
+
static getDerivedStateFromError() {
|
|
42
|
+
return { failed: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
|
46
|
+
// Kept as console.error, not console.log: this is the one place that
|
|
47
|
+
// knows the app failed to start, and a white page with a `log` entry is
|
|
48
|
+
// easy to miss when somebody finally does open the console.
|
|
49
|
+
console.error('[bootstrap] the application could not start', error, info);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
render() {
|
|
53
|
+
if (!this.state.failed) {
|
|
54
|
+
return this.props.children;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Same fallback rule as Message: any language without an entry reads
|
|
58
|
+
// English rather than rendering `undefined`.
|
|
59
|
+
const language = getLanguage('en');
|
|
60
|
+
const copy = errors[language as keyof typeof errors] ?? errors.en;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
role="alert"
|
|
65
|
+
style={ {
|
|
66
|
+
minHeight: '100vh',
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
justifyContent: 'center',
|
|
70
|
+
padding: '24px',
|
|
71
|
+
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
|
72
|
+
background: '#1c1c1c',
|
|
73
|
+
color: '#f5f5f5'
|
|
74
|
+
} }
|
|
75
|
+
>
|
|
76
|
+
<div style={ { maxWidth: '460px', textAlign: 'center' } }>
|
|
77
|
+
<h1 style={ { fontSize: '20px', lineHeight: 1.4, margin: '0 0 12px' } }>
|
|
78
|
+
{ copy.system }
|
|
79
|
+
</h1>
|
|
80
|
+
|
|
81
|
+
<p style={ { fontSize: '15px', lineHeight: 1.6, margin: '0 0 24px', opacity: 0.8 } }>
|
|
82
|
+
{ copy.system_detail }
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={ () => window.location.reload() }
|
|
88
|
+
style={ {
|
|
89
|
+
font: 'inherit',
|
|
90
|
+
fontWeight: 600,
|
|
91
|
+
padding: '10px 24px',
|
|
92
|
+
borderRadius: '999px',
|
|
93
|
+
border: 0,
|
|
94
|
+
cursor: 'pointer',
|
|
95
|
+
background: '#f0b323',
|
|
96
|
+
color: '#1c1c1c'
|
|
97
|
+
} }
|
|
98
|
+
>
|
|
99
|
+
{ copy.back }
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default BootstrapError;
|
package/Providers.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Suspense, useEffect } from 'react';
|
|
2
2
|
import Queries from './Queries/Queries';
|
|
3
|
+
import BootstrapError from './Errors/BootstrapError';
|
|
3
4
|
import Styles from './Styles/Styles';
|
|
4
5
|
import Notifications from './Notifications/Notifications';
|
|
5
6
|
import Setup from './Setup/Setup';
|
|
@@ -78,6 +79,17 @@ const Providers = ({ type, loading, children }: Props): JSX.Element => {
|
|
|
78
79
|
// remount it once ready - harmless for Styles/Notifications themselves,
|
|
79
80
|
// but a needless remount of anything stateful sitting there is worth
|
|
80
81
|
// avoiding on principle.
|
|
82
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
83
|
+
// OUTERMOST, above the settings fetch. useGetSettings is a suspense
|
|
84
|
+
// query, so a failure throws during render rather than resolving to an
|
|
85
|
+
// error state - and with nothing catching it the whole tree unmounted
|
|
86
|
+
// and left a blank white page. A rate limit, a brief API blip or a
|
|
87
|
+
// restart mid-deploy all looked identical: nothing at all.
|
|
88
|
+
//
|
|
89
|
+
// Not providers' own ErrorBoundary, which renders Errors/Message -
|
|
90
|
+
// Message calls useGetSettings() itself and useNavigate, so above this
|
|
91
|
+
// point it would rethrow inside its own fallback. See BootstrapError.
|
|
92
|
+
<BootstrapError>
|
|
81
93
|
<Suspense fallback={ loading }>
|
|
82
94
|
<Queries>
|
|
83
95
|
<Styles>
|
|
@@ -93,6 +105,7 @@ const Providers = ({ type, loading, children }: Props): JSX.Element => {
|
|
|
93
105
|
</Styles>
|
|
94
106
|
</Queries>
|
|
95
107
|
</Suspense>
|
|
108
|
+
</BootstrapError>
|
|
96
109
|
);
|
|
97
110
|
};
|
|
98
111
|
|
package/Queries/Queries.tsx
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { AxiosError } from 'axios';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
6
|
+
*
|
|
7
|
+
* Was `retry: false` for everything, which is wrong in both directions.
|
|
8
|
+
*
|
|
9
|
+
* Too strict: a single blip - a restart mid-deploy, a dropped connection, a
|
|
10
|
+
* 500 - was fatal to any query, and for the SUSPENSE queries on the bootstrap
|
|
11
|
+
* path (settings, menu) fatal meant the whole app. One unlucky request and
|
|
12
|
+
* the visitor got nothing.
|
|
13
|
+
*
|
|
14
|
+
* Too loose: it also said nothing about WHICH failures are worth repeating. A
|
|
15
|
+
* 401, 403 or 404 is a decided answer - the server has considered the request
|
|
16
|
+
* and refused it, and asking again produces the same refusal while spending
|
|
17
|
+
* the rate-limit budget that a 429 exists to protect.
|
|
18
|
+
*
|
|
19
|
+
* So: repeat only what can plausibly differ next time - a 5xx, a timeout, a
|
|
20
|
+
* rate limit, or a network error with no response at all - and back off
|
|
21
|
+
* between attempts so a burst is not answered with more burst. Everything
|
|
22
|
+
* else fails immediately, which is what makes a real 403 surface as a real
|
|
23
|
+
* 403 rather than as three seconds of spinner.
|
|
24
|
+
*/
|
|
25
|
+
const RETRYABLE = [ 408, 425, 429, 500, 502, 503, 504 ];
|
|
26
|
+
|
|
27
|
+
const retry = (failureCount: number, error: Error): boolean => {
|
|
28
|
+
if (failureCount >= 2) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const status = (error as AxiosError)?.response?.status;
|
|
33
|
+
|
|
34
|
+
// No response at all - a dropped connection or a DNS hiccup, not an answer.
|
|
35
|
+
if (status === undefined) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return RETRYABLE.includes(status);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// 700ms, then 1400ms. Deliberately short: these sit behind a suspense
|
|
43
|
+
// fallback the visitor is watching, so the ceiling on "how long may this look
|
|
44
|
+
// broken before it gives up and says so" is about two seconds, not the ~30
|
|
45
|
+
// an unbounded exponential backoff reaches.
|
|
46
|
+
const retryDelay = (attempt: number): number => 700 * 2 ** attempt;
|
|
2
47
|
|
|
3
48
|
const queryClient = new QueryClient({
|
|
4
49
|
defaultOptions: {
|
|
5
50
|
queries: {
|
|
6
51
|
refetchOnWindowFocus: false,
|
|
7
|
-
retry
|
|
52
|
+
retry,
|
|
53
|
+
retryDelay
|
|
8
54
|
}
|
|
9
55
|
}
|
|
10
56
|
});
|
|
@@ -19,4 +65,4 @@ const Queries = ({ children }: Props): JSX.Element => (
|
|
|
19
65
|
</QueryClientProvider>
|
|
20
66
|
);
|
|
21
67
|
|
|
22
|
-
export default Queries;
|
|
68
|
+
export default Queries;
|
package/Setup/Setup.tsx
CHANGED
|
@@ -15,28 +15,7 @@ const Setup = ({ type, children }: Props): JSX.Element => {
|
|
|
15
15
|
useGetMenu();
|
|
16
16
|
useUserBootstrap();
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
// The language this origin is about, but ONLY when it is a ccTLD - a
|
|
20
|
-
// domain that exists in order to be that language. It outranks the stored
|
|
21
|
-
// preference there; see getLanguage.
|
|
22
|
-
//
|
|
23
|
-
// Not on the PRIMARY, even though it declares a locale too (every brand's
|
|
24
|
-
// primary does - the backfill gave it one). The primary is the brand's
|
|
25
|
-
// address before it is a language, and the stored preference winning at
|
|
26
|
-
// its unprefixed URLs is deliberate and already shipped: a returning
|
|
27
|
-
// visitor who last read the site in Albanian types busmagus.com and gets
|
|
28
|
-
// Albanian. Passing the primary's locale here would have quietly ended
|
|
29
|
-
// that for every brand on the platform, none of which has a second domain
|
|
30
|
-
// yet - a change nobody asked for, arriving with a feature nobody has
|
|
31
|
-
// switched on.
|
|
32
|
-
const origin = data.domains?.find(item => item.domain === data.domain);
|
|
33
|
-
|
|
34
|
-
languages(
|
|
35
|
-
type,
|
|
36
|
-
data.preferences.languages.default,
|
|
37
|
-
data.preferences.languages.available,
|
|
38
|
-
origin && !origin.primary ? origin.locale : null
|
|
39
|
-
);
|
|
18
|
+
languages(type, data.preferences.languages.default, data.preferences.languages.available);
|
|
40
19
|
|
|
41
20
|
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
42
21
|
// The brand's GTM container, from labels_settings via the admin GUI.
|
package/Setup/languages.ts
CHANGED
|
@@ -2,13 +2,6 @@ import i18next from 'i18next';
|
|
|
2
2
|
import Backend from 'i18next-http-backend';
|
|
3
3
|
import { initReactI18next } from 'react-i18next';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The language this ORIGIN is about, remembered from the one place that is
|
|
7
|
-
* told it (`languages()`, from the settings payload) so the call sites that
|
|
8
|
-
* ask outside of setup do not each have to be handed it. See getLanguage.
|
|
9
|
-
*/
|
|
10
|
-
let originLocale: string | null | undefined;
|
|
11
|
-
|
|
12
5
|
/**
|
|
13
6
|
* The language to start in.
|
|
14
7
|
*
|
|
@@ -23,42 +16,14 @@ let originLocale: string | null | undefined;
|
|
|
23
16
|
* `available` is passed in rather than read from anywhere global so this
|
|
24
17
|
* stays a pure function of its inputs, and so an unrecognised first segment
|
|
25
18
|
* (/city/tirana) is never mistaken for a language.
|
|
26
|
-
*
|
|
27
|
-
* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
28
|
-
* `domainLocale` - the language THIS origin is about, when it is about one
|
|
29
|
-
* (obtapi's settings payload, `locale`). It outranks the stored preference,
|
|
30
|
-
* which nothing else does.
|
|
31
|
-
*
|
|
32
|
-
* The stored preference exists so a returning visitor who last read the site
|
|
33
|
-
* in Albanian gets Albanian at busmagus.com/, where the URL expresses no
|
|
34
|
-
* preference of its own. On busmagus.ro the URL expresses nothing BUT a
|
|
35
|
-
* preference: the domain is the request, and honouring a two-month-old
|
|
36
|
-
* localStorage entry over it would render Albanian at a Romanian URL whose
|
|
37
|
-
* own canonical says it is Romanian - the mismatch localised URLs exist to
|
|
38
|
-
* remove, reintroduced one returning visitor at a time and invisible to
|
|
39
|
-
* anybody testing in a clean browser.
|
|
40
|
-
*
|
|
41
|
-
* Undefined (a single-domain brand, or an alias claiming no language) leaves
|
|
42
|
-
* the ordering exactly as it was.
|
|
43
|
-
*
|
|
44
|
-
* It DEFAULTS to whatever `languages()` was told, so the two call sites that
|
|
45
|
-
* ask this question outside of setup - the 500-error text in apiClient and
|
|
46
|
-
* the error page in Errors/Message, both of which pass only 'en' - answer in
|
|
47
|
-
* the language the visitor is actually reading rather than dropping back to
|
|
48
|
-
* a stale localStorage entry and showing English error copy on a Romanian
|
|
49
|
-
* page.
|
|
50
19
|
*/
|
|
51
|
-
export const getLanguage = (defaultLanguage: string, available: string[] = []
|
|
20
|
+
export const getLanguage = (defaultLanguage: string, available: string[] = []): string => {
|
|
52
21
|
const [ , first ] = window.location.pathname.split('/');
|
|
53
22
|
|
|
54
23
|
if (first && available.includes(first)) {
|
|
55
24
|
return first;
|
|
56
25
|
}
|
|
57
26
|
|
|
58
|
-
if (domainLocale && (available.length === 0 || available.includes(domainLocale))) {
|
|
59
|
-
return domainLocale;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
27
|
const stored = localStorage.getItem('language');
|
|
63
28
|
|
|
64
29
|
return (stored && (available.length === 0 || available.includes(stored)))
|
|
@@ -78,12 +43,7 @@ export const getLanguage = (defaultLanguage: string, available: string[] = [], d
|
|
|
78
43
|
// consumer that hasn't set VITE_BUILD_ID yet (no worse than before).
|
|
79
44
|
const buildId = import.meta.env.VITE_BUILD_ID ?? 'dev';
|
|
80
45
|
|
|
81
|
-
const languages = (type: string, defaultLanguage: string, available: string[] = []
|
|
82
|
-
// Before the early return, not after: the guard is about not initialising
|
|
83
|
-
// i18next twice, and getLanguage's callers need this on every render of
|
|
84
|
-
// the app's life, not only the first one.
|
|
85
|
-
originLocale = domainLocale;
|
|
86
|
-
|
|
46
|
+
const languages = (type: string, defaultLanguage: string, available: string[] = []): void => {
|
|
87
47
|
if (i18next.isInitialized) {
|
|
88
48
|
return;
|
|
89
49
|
}
|
package/package.json
CHANGED
package/types/settings.ts
CHANGED
|
@@ -7,26 +7,6 @@ export interface SettingsData {
|
|
|
7
7
|
|
|
8
8
|
url: string
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
12
|
-
* Multi-TLD: which origin answered this request, every origin this brand
|
|
13
|
-
* answers on, and the language this one is about.
|
|
14
|
-
*
|
|
15
|
-
* `domains` is what turns hreflang from locale-to-path-prefix into
|
|
16
|
-
* locale-to-DOMAIN (see @autobusal/common's localeHref). Its first entry
|
|
17
|
-
* is the primary - the origin registered with the payment gateways, and
|
|
18
|
-
* the one that hosts any language without a domain of its own under a
|
|
19
|
-
* /{locale}/ prefix.
|
|
20
|
-
*
|
|
21
|
-
* `locale` is null for a single-domain brand and for an alias that claims
|
|
22
|
-
* no language; everything derived from these then resolves to exactly the
|
|
23
|
-
* previous single-origin behaviour. All three are optional so an older API
|
|
24
|
-
* cannot break a newer build.
|
|
25
|
-
*/
|
|
26
|
-
domain?: string | null
|
|
27
|
-
domains?: { domain: string, locale?: string | null, primary?: boolean }[]
|
|
28
|
-
locale?: string | null
|
|
29
|
-
|
|
30
10
|
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
31
11
|
// Present ONLY while the brand is under construction - obtapi omits it
|
|
32
12
|
// entirely on a live site, so its presence is the switch itself and there
|