@hashicorp/react-components 0.0.0-canary.0299c5d

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.
Files changed (34) hide show
  1. package/.eslintrc.js +4 -0
  2. package/.stylelintrc.js +3 -0
  3. package/.test/setup-vitest.js +1 -0
  4. package/README.md +3 -0
  5. package/global.d.ts +6 -0
  6. package/package.json +30 -0
  7. package/postcss.config.js +21 -0
  8. package/src/components/nav-panel/components/flight-icon/index.tsx +51 -0
  9. package/src/components/nav-panel/components/flight-icon/style.module.css +3 -0
  10. package/src/components/nav-panel/components/link-list/index.tsx +94 -0
  11. package/src/components/nav-panel/components/link-list/style.module.css +57 -0
  12. package/src/components/nav-panel/components/panel/column/index.tsx +24 -0
  13. package/src/components/nav-panel/components/panel/column/style.module.css +24 -0
  14. package/src/components/nav-panel/components/panel/index.tsx +4 -0
  15. package/src/components/nav-panel/components/panel/root/index.tsx +20 -0
  16. package/src/components/nav-panel/components/panel/root/style.module.css +30 -0
  17. package/src/components/nav-panel/components/promo/index.tsx +92 -0
  18. package/src/components/nav-panel/components/promo/style.module.css +128 -0
  19. package/src/components/nav-panel/components/text/index.tsx +34 -0
  20. package/src/components/nav-panel/index.tsx +6 -0
  21. package/src/components/nav-panel/product-panel/index.tsx +185 -0
  22. package/src/components/nav-panel/product-panel/style.module.css +355 -0
  23. package/src/components/nav-panel/standard-panel/index.tsx +47 -0
  24. package/src/components/nav-panel/standard-panel/style.module.css +11 -0
  25. package/src/lib/get-localized-link-props/index.test.ts +30 -0
  26. package/src/lib/get-localized-link-props/index.ts +45 -0
  27. package/src/lib/parse-url/index.test.ts +69 -0
  28. package/src/lib/parse-url/index.ts +98 -0
  29. package/src/lib/send-posthog-event/index.ts +22 -0
  30. package/src/lib/supported-locales/index.ts +1 -0
  31. package/src/lib/track-nav-click-event/index.ts +22 -0
  32. package/src/lib/v/index.ts +10 -0
  33. package/tsconfig.json +14 -0
  34. package/vitest.config.mts +12 -0
@@ -0,0 +1,185 @@
1
+ import classNames from 'classnames'
2
+ import Link from 'next/link'
3
+ import * as NavPanel from '../components/panel'
4
+ import Promo, { type PromoProps } from '../components/promo'
5
+ import { getLocalizedLinkProps } from '../../../lib/get-localized-link-props'
6
+ import { Products } from '@hashicorp/platform-product-meta'
7
+ import FlightIcon from '../components/flight-icon'
8
+ import { Text } from '../components/text'
9
+ import { trackNavClickEvent } from '../../../lib/track-nav-click-event'
10
+ import * as NavigationMenu from '@radix-ui/react-navigation-menu'
11
+ import s from './style.module.css'
12
+
13
+ const PRODUCT_LOGO_MAP = new Map([
14
+ ['Terraform', 'terraform-fill'],
15
+ ['Packer', 'packer-fill-color'],
16
+ ['Vault', 'vault-fill-color'],
17
+ ['Boundary', 'boundary-fill-color'],
18
+ ['Consul', 'consul-fill-color'],
19
+ ['Nomad', 'nomad-fill-color'],
20
+ ['Waypoint', 'waypoint-fill-color'],
21
+ ['Vagrant', 'vagrant-fill-color'],
22
+ ['HCP Vault Secrets', 'vault-secrets'],
23
+ ['HCP Vault Radar', 'vault-radar'],
24
+ ])
25
+
26
+ const PRODUCT_LOGO_EXCEPTION_CLASSNAMES = {
27
+ Terraform: 'terraform',
28
+ 'HCP Vault Secrets': 'hcpVaultSecrets',
29
+ 'HCP Vault Radar': 'hcpVaultRadar',
30
+ }
31
+
32
+ export interface ProductsPanelProps {
33
+ locale?: string
34
+ productCategories: any
35
+ promo?: PromoProps
36
+ sidePanel?: {
37
+ label: string
38
+ navItems: {
39
+ icon: string
40
+ title: string
41
+ description: string
42
+ url: string
43
+ }[]
44
+ }
45
+ isPromoOnTop?: boolean
46
+ }
47
+
48
+ const ProductPanel = ({
49
+ locale,
50
+ productCategories,
51
+ promo,
52
+ sidePanel,
53
+ isPromoOnTop = false,
54
+ }: ProductsPanelProps) => {
55
+ return (
56
+ <NavPanel.Root
57
+ className={classNames(s.productPanel, {
58
+ [s.hasSide]: sidePanel,
59
+ [s.noSide]: !sidePanel,
60
+ [s.topPromo]: isPromoOnTop,
61
+ })}
62
+ hasSidePanel={Boolean(sidePanel)}
63
+ >
64
+ <div className={s.mainPanel}>
65
+ {promo && isPromoOnTop ? (
66
+ <Promo
67
+ {...promo}
68
+ hasSidePanel={Boolean(sidePanel)}
69
+ isPromoOnTop={isPromoOnTop}
70
+ />
71
+ ) : null}
72
+ <NavPanel.Column hasSidePanel={Boolean(sidePanel)}>
73
+ <ul className={s.productCategories}>
74
+ {productCategories.map(({ title, products }) => {
75
+ return (
76
+ <li key={title} className={s.productCategory}>
77
+ <Text className={s.eyebrow}>{title}</Text>
78
+ <ul className={s.productList}>
79
+ {products.map((product) => {
80
+ return (
81
+ <li key={product.url}>
82
+ <NavProduct locale={locale} {...product} />
83
+ </li>
84
+ )
85
+ })}
86
+ </ul>
87
+ </li>
88
+ )
89
+ })}
90
+ </ul>
91
+ </NavPanel.Column>
92
+ {promo && !isPromoOnTop ? (
93
+ <Promo {...promo} hasSidePanel={Boolean(sidePanel)} />
94
+ ) : null}
95
+ </div>
96
+
97
+ {sidePanel ? (
98
+ <NavPanel.Column className={s.sidePanel}>
99
+ <Text size="200" weight="semibold" className={s.sidePanelText}>
100
+ {sidePanel.label}
101
+ </Text>
102
+
103
+ {/* ! TODO Replace the below list with the <LinkList /> component,
104
+ That component will require a refactor to support greater customization
105
+ Ticket:
106
+ https://app.asana.com/0/1206176289707208/1208162145034615/f
107
+ */}
108
+ <ul className={s.sidePanelItems}>
109
+ {sidePanel.navItems.map(({ icon, title, description, url }) => {
110
+ return (
111
+ <li key={title}>
112
+ <Link
113
+ aria-label={`${title} - ${description}`}
114
+ {...getLocalizedLinkProps(url, locale)}
115
+ locale={locale ? locale : undefined}
116
+ className={classNames(s.sidePanelItem, s.focusIndicator)}
117
+ >
118
+ <div className={s.iconBackground}>
119
+ <FlightIcon
120
+ name={icon}
121
+ color="var(--token-color-foreground-faint)"
122
+ />
123
+ </div>
124
+ <div className={s.sidePanelItemContent}>
125
+ <Text
126
+ size="200"
127
+ weight="semibold"
128
+ className={s.sidePanelText}
129
+ >
130
+ {title}
131
+ </Text>
132
+ <span className={s.sidePanelItemDesc}>{description}</span>
133
+ </div>
134
+ </Link>
135
+ </li>
136
+ )
137
+ })}
138
+ </ul>
139
+ </NavPanel.Column>
140
+ ) : null}
141
+ </NavPanel.Root>
142
+ )
143
+ }
144
+
145
+ export interface NavProductProps {
146
+ locale?: string
147
+ url: string
148
+ product: Products | string
149
+ description: string
150
+ }
151
+
152
+ function NavProduct({ locale, url, product, description }: NavProductProps) {
153
+ return (
154
+ <NavigationMenu.Link asChild>
155
+ <Link
156
+ aria-label={`${product} - ${description}`}
157
+ {...getLocalizedLinkProps(url, locale)}
158
+ locale={locale ? locale : undefined}
159
+ onClickCapture={() => {
160
+ trackNavClickEvent(product, url, 'products')
161
+ }}
162
+ className={s.focusIndicator}
163
+ >
164
+ <div className={s.productWrapper}>
165
+ <div className={s.productLogo}>
166
+ <FlightIcon
167
+ name={PRODUCT_LOGO_MAP.get(product) ?? 'hashicorp-fill-color'}
168
+ className={
169
+ PRODUCT_LOGO_EXCEPTION_CLASSNAMES[product]
170
+ ? s[PRODUCT_LOGO_EXCEPTION_CLASSNAMES[product]]
171
+ : undefined
172
+ }
173
+ />
174
+ </div>
175
+ <div className={s.productTextContent}>
176
+ <span>{product}</span>
177
+ <span className={s.productDesc}>{description}</span>
178
+ </div>
179
+ </div>
180
+ </Link>
181
+ </NavigationMenu.Link>
182
+ )
183
+ }
184
+
185
+ export default ProductPanel
@@ -0,0 +1,355 @@
1
+ @custom-media --dev-dot-viewport (min-width: 924px);
2
+
3
+ .productPanel {
4
+ height: 100%;
5
+
6
+ /* Temporarily included for devdot compatability
7
+ https://app.asana.com/1/90955849329269/project/1209320521286316/task/1210514680913728?focus=true */
8
+ --mds-spacing-01: 0.25rem;
9
+ --mds-spacing-02: 0.375rem;
10
+ --mds-spacing-03: 0.5rem;
11
+ --mds-spacing-04: 0.75rem;
12
+ --mds-spacing-05: 1rem;
13
+ --mds-spacing-06: 1.25rem;
14
+ --mds-spacing-07: 1.5rem;
15
+ --mds-spacing-08: 2rem;
16
+ --mds-spacing-09: 2.5rem;
17
+ --mds-spacing-10: 3rem;
18
+ --mds-spacing-11: 3.5rem;
19
+ --mds-spacing-12: 4rem;
20
+ --mds-spacing-13: 4.5rem;
21
+ --mds-spacing-14: 5.5rem;
22
+ --mds-spacing-15: 6rem;
23
+ --mds-spacing-16: 8rem;
24
+ --mds-spacing-17: 8.5rem;
25
+ --mds-typography-body-200-font-size: 0.875rem;
26
+ --mds-typography-font-weight-regular: 400;
27
+ --mds-typography-font-weight-semibold: 600;
28
+ --mds-typography-font-stack-text: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
29
+ --mds-typography-body-200-line-height: 1.7142857142857142; /* 20px */
30
+
31
+ @media (--large) {
32
+ &.noSide {
33
+ display: grid;
34
+ grid-template-rows: 1fr;
35
+ grid-template-columns: max-content max-content;
36
+ padding: 0;
37
+ }
38
+ }
39
+
40
+ @media (--dev-dot-viewport) {
41
+ &.hasSide {
42
+ display: grid;
43
+ grid-template-rows: 1fr;
44
+ grid-template-columns: max-content max-content;
45
+ padding: 0;
46
+ }
47
+ }
48
+ }
49
+
50
+ .productCategories {
51
+ list-style: none;
52
+ padding: 0;
53
+ display: grid;
54
+ margin: var(--mds-spacing-07) 0 0;
55
+
56
+ @nest .topPromo & {
57
+ margin-bottom: var(--mds-spacing-07);
58
+ }
59
+
60
+ @media (--large) {
61
+ @nest .noSide & {
62
+ margin: 0;
63
+ }
64
+ }
65
+
66
+ @media (--dev-dot-viewport) {
67
+ @nest .hasSide & {
68
+ margin: 0;
69
+ }
70
+ }
71
+ }
72
+
73
+ .productCategory {
74
+ display: flex;
75
+ flex-direction: column;
76
+ min-width: 160px;
77
+
78
+ &:first-child {
79
+ border-bottom: 1px solid var(--token-color-border-primary);
80
+ margin-bottom: var(--mds-spacing-05);
81
+ padding-bottom: var(--mds-spacing-05);
82
+ }
83
+ }
84
+
85
+ .eyebrow {
86
+ padding-bottom: var(--mds-spacing-04);
87
+ color: var(--token-color-foreground-strong);
88
+ font-weight: var(--mds-typography-font-weight-semibold);
89
+
90
+ @media (--large) {
91
+ @nest .noSide & {
92
+ padding-bottom: 0;
93
+ padding-left: var(--mds-spacing-06);
94
+ }
95
+ }
96
+
97
+ @media (--dev-dot-viewport) {
98
+ @nest .hasSide & {
99
+ padding-bottom: 0;
100
+ padding-left: var(--mds-spacing-06);
101
+ }
102
+ }
103
+ }
104
+
105
+ .productList {
106
+ display: grid;
107
+ grid-template-columns: 1fr;
108
+ list-style: none;
109
+ margin: 0;
110
+ padding: 0;
111
+
112
+ @media (--large) {
113
+ @nest .noSide & {
114
+ padding: var(--mds-spacing-06) var(--mds-spacing-05) var(--mds-spacing-04);
115
+ grid-auto-flow: row;
116
+ grid-template-columns: repeat(2, 300px);
117
+ grid-template-rows: repeat(2, max-content);
118
+ gap: 0;
119
+ }
120
+ }
121
+
122
+ @media (--dev-dot-viewport) {
123
+ @nest .hasSide & {
124
+ padding: var(--mds-spacing-06) var(--mds-spacing-05) var(--mds-spacing-04);
125
+ grid-auto-flow: row;
126
+ grid-template-columns: repeat(2, 300px);
127
+ grid-template-rows: repeat(2, max-content);
128
+ gap: 0;
129
+ }
130
+ }
131
+
132
+ & li:last-child {
133
+ & .productWrapper {
134
+ margin-bottom: 0;
135
+ }
136
+ }
137
+ }
138
+
139
+ /* NavProduct styles */
140
+
141
+ .productWrapper {
142
+ display: flex;
143
+ margin-bottom: var(--mds-spacing-05);
144
+ gap: var(--mds-spacing-05);
145
+
146
+ @media (--large) {
147
+ @nest .noSide & {
148
+ padding: var(--mds-spacing-03) var(--mds-spacing-05);
149
+ margin-bottom: 0;
150
+
151
+ &:hover {
152
+ background-color: var(--token-color-surface-interactive-hover);
153
+ border-radius: var(--mds-spacing-01);
154
+ }
155
+ }
156
+ }
157
+
158
+ @media (--dev-dot-viewport) {
159
+ @nest .hasSide & {
160
+ padding: var(--mds-spacing-03) var(--mds-spacing-05);
161
+ margin-bottom: 0;
162
+
163
+ &:hover {
164
+ background-color: var(--token-color-surface-interactive-hover);
165
+ border-radius: var(--mds-spacing-01);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ .productLogo {
172
+ margin-top: 3px;
173
+
174
+ & svg {
175
+ height: 42px;
176
+ width: 42px;
177
+ }
178
+
179
+ @nest .noSide & {
180
+ & svg {
181
+ @media (--large) {
182
+ height: var(--mds-spacing-08);
183
+ width: var(--mds-spacing-08);
184
+ }
185
+ }
186
+ }
187
+
188
+ @nest .hasSide & {
189
+ & svg {
190
+ @media (--dev-dot-viewport) {
191
+ height: var(--mds-spacing-08);
192
+ width: var(--mds-spacing-08);
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ .productTextContent {
199
+ display: flex;
200
+ flex-direction: column;
201
+ color: var(--token-color-foreground-strong);
202
+ }
203
+
204
+ .productDesc {
205
+ font-weight: 400;
206
+ font-size: 14px;
207
+ line-height: 20px;
208
+ letter-spacing: 0.0125em;
209
+ color: var(--token-color-foreground-faint);
210
+ }
211
+
212
+ .focusIndicator {
213
+ &:focus {
214
+ outline: 2px solid var(--token-color-focus-action-internal);
215
+ outline-style: auto;
216
+ }
217
+ }
218
+
219
+ .mainPanel {
220
+ height: 100%;
221
+ display: flex;
222
+ justify-content: space-between;
223
+ flex-direction: column;
224
+
225
+ @media (--large) {
226
+ @nest .noSide & {
227
+ padding: var(--mds-spacing-07) 0 var(--mds-spacing-09)
228
+ var(--mds-spacing-04);
229
+ }
230
+
231
+ @nest .topPromo & {
232
+ padding-bottom: var(--mds-spacing-04);
233
+ }
234
+ }
235
+
236
+ @media (--dev-dot-viewport) {
237
+ @nest .hasSide & {
238
+ padding: var(--mds-spacing-07) 0 var(--mds-spacing-09)
239
+ var(--mds-spacing-04);
240
+ }
241
+
242
+ @nest .topPromo & {
243
+ padding-bottom: var(--mds-spacing-04);
244
+ }
245
+ }
246
+ }
247
+
248
+ .sidePanel {
249
+ height: 100%;
250
+ background: var(--token-color-surface-faint);
251
+ display: flex;
252
+ flex-direction: column;
253
+ gap: var(--mds-spacing-05);
254
+ margin: 0;
255
+ padding: var(--mds-spacing-07) var(--mds-spacing-04) var(--mds-spacing-09)
256
+ var(--mds-spacing-07);
257
+ }
258
+
259
+ .sidePanelItems {
260
+ list-style: none;
261
+ margin: 0;
262
+ padding: 0;
263
+ display: grid;
264
+ grid-template-columns: 1fr;
265
+ gap: var(--mds-spacing-07);
266
+
267
+ @media (--dev-dot-viewport) {
268
+ gap: var(--mds-spacing-05);
269
+ }
270
+ }
271
+
272
+ /* Lifted from .productWrapper */
273
+ .sidePanelItem {
274
+ display: flex;
275
+ gap: var(--mds-spacing-05);
276
+
277
+ @media (--dev-dot-viewport) {
278
+ padding: var(--mds-spacing-03) var(--mds-spacing-05);
279
+
280
+ &:hover {
281
+ background-color: var(--token-color-surface-interactive-hover);
282
+ border-radius: var(--mds-spacing-01);
283
+ }
284
+ }
285
+ }
286
+
287
+ /* Lifted from:
288
+ packages/react-components/src/components/nav-panel/components/link-list/style.module.css */
289
+ .iconBackground {
290
+ background: var(--token-color-foreground-high-contrast);
291
+ height: var(--mds-spacing-08);
292
+ width: var(--mds-spacing-08);
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ border-radius: 3px;
297
+ flex: none;
298
+ margin-top: 3px;
299
+ border: 1px solid var(--token-color-border-primary);
300
+ }
301
+
302
+ /* Lifted from .productTextContent */
303
+ .sidePanelItemContent {
304
+ display: flex;
305
+ flex-direction: column;
306
+ color: var(--token-color-foreground-strong);
307
+ }
308
+
309
+ .sidePanelItemDesc {
310
+ font-weight: 400;
311
+ font-size: 13px;
312
+ line-height: 18px;
313
+ color: var(--token-color-foreground-primary);
314
+ }
315
+
316
+ /* Branding exception classnames */
317
+
318
+ /* Allows switching to a dark-mode-specific color for Terraform */
319
+ .terraform {
320
+ & use {
321
+ fill: var(--token-color-terraform-brand);
322
+
323
+ @nest [data-theme='dark'] & {
324
+ fill: var(--token-color-terraform-foreground);
325
+ }
326
+ }
327
+
328
+ & path {
329
+ fill: inherit;
330
+ }
331
+ }
332
+
333
+ /* Styles for HCP Vault mimic the spec for `product-square` icons,
334
+ while allowing the symbol inside the icon border to change color
335
+ based on the site's theme
336
+ */
337
+ .hcpVaultSecrets,
338
+ .hcpVaultRadar {
339
+ padding: var(--mds-spacing-01);
340
+ border: 2px solid var(--token-color-vault-brand);
341
+ border-radius: 3px;
342
+
343
+ & use {
344
+ fill: var(--token-color-foreground-strong);
345
+ }
346
+
347
+ & path {
348
+ fill: inherit;
349
+ }
350
+ }
351
+
352
+ /* Copied from packages/mds-tokens/src/helper-classes/typography.css */
353
+ .sidePanelText {
354
+ font-weight: var(--mds-typography-font-weight-semibold);
355
+ }
@@ -0,0 +1,47 @@
1
+ import { trackNavClickEvent } from '../../../lib/track-nav-click-event'
2
+ import * as NavPanel from '../components/panel'
3
+ import LinkList, { type LinkListProps } from '../components/link-list'
4
+ import Promo, { type PromoProps } from '../components/promo'
5
+ import s from './style.module.css'
6
+
7
+ export interface NavItem {
8
+ navItems: LinkListProps['links']
9
+ }
10
+
11
+ interface StandardPanelProps {
12
+ navData: NavItem[]
13
+ promo?: PromoProps
14
+ sectionName?: string
15
+ locale?: any
16
+ }
17
+
18
+ const StandardPanel = ({
19
+ navData,
20
+ promo,
21
+ sectionName = '',
22
+ locale,
23
+ }: StandardPanelProps) => {
24
+ return (
25
+ <NavPanel.Root>
26
+ <div className={s.columns}>
27
+ {navData.map(({ navItems }, stableIdx) => {
28
+ return (
29
+ <NavPanel.Column key={stableIdx}>
30
+ <LinkList
31
+ locale={locale}
32
+ links={navItems}
33
+ linkClickHandler={(text, href) => {
34
+ trackNavClickEvent(text, href, sectionName)
35
+ }}
36
+ />
37
+ </NavPanel.Column>
38
+ )
39
+ })}
40
+ </div>
41
+
42
+ {promo ? <Promo {...promo} /> : null}
43
+ </NavPanel.Root>
44
+ )
45
+ }
46
+
47
+ export default StandardPanel
@@ -0,0 +1,11 @@
1
+ .columns {
2
+ display: flex;
3
+ gap: var(--mds-spacing-03);
4
+ flex-direction: column;
5
+ margin-top: var(--mds-spacing-07);
6
+
7
+ @media (--large) {
8
+ flex-direction: row;
9
+ margin-top: 0;
10
+ }
11
+ }
@@ -0,0 +1,30 @@
1
+ import { getLocalizedLinkProps } from '.'
2
+
3
+ describe('getLocalizedLinkProps', () => {
4
+ it('does not format localized homepage', () => {
5
+ const localizedLinkProps = getLocalizedLinkProps('/en', 'ja')
6
+ expect(localizedLinkProps.href).toBe('/en')
7
+ expect(localizedLinkProps.hrefLang).toBe('en')
8
+ })
9
+
10
+ it('does not format localized path hrefs', () => {
11
+ const localizedLinkProps = getLocalizedLinkProps('/ja/solutions', 'de')
12
+ expect(localizedLinkProps.href).toBe('/ja/solutions')
13
+ expect(localizedLinkProps.hrefLang).toBe('ja')
14
+ })
15
+
16
+ it('does format paths that start with a locale string', () => {
17
+ const localizedLinkProps = getLocalizedLinkProps(
18
+ '/deconstructing-cloud',
19
+ 'en'
20
+ )
21
+ expect(localizedLinkProps.href).toBe('/en/deconstructing-cloud')
22
+ expect(localizedLinkProps.hrefLang).toBe('en')
23
+ })
24
+
25
+ it('does not format external paths', () => {
26
+ const localizedLinkProps = getLocalizedLinkProps('https://google.com', 'ja')
27
+ expect(localizedLinkProps.href).toBe('https://google.com')
28
+ expect(localizedLinkProps.hrefLang).toBe(undefined)
29
+ })
30
+ })
@@ -0,0 +1,45 @@
1
+ import { parseUrl } from '../parse-url'
2
+ import { allSupportedLocales } from '../supported-locales'
3
+
4
+ export const getLocalizedLinkProps = (
5
+ url: string,
6
+ locale?: string
7
+ ): {
8
+ href: string
9
+ hrefLang: string | undefined
10
+ } => {
11
+ const parsedHref = parseUrl(url).href
12
+
13
+ // example: /en
14
+ const hrefIsLocalizedHomepage = allSupportedLocales.includes(
15
+ parsedHref.substring(1)
16
+ )
17
+
18
+ // true example: /en/solutions
19
+ // false example: /entry-point
20
+ const hrefIsLocalized =
21
+ allSupportedLocales.includes(parsedHref.substring(1, 3)) &&
22
+ parsedHref[3] === '/'
23
+
24
+ // If the href is already localized:
25
+ // - do not format the href
26
+ // - use the locale from the href for the hrefLang
27
+ if (hrefIsLocalizedHomepage || hrefIsLocalized) {
28
+ return {
29
+ href: parsedHref,
30
+ hrefLang: parsedHref.substring(1, 3),
31
+ }
32
+ }
33
+
34
+ if (parsedHref.startsWith('/') && locale) {
35
+ return {
36
+ href: `/${locale}${parsedHref}`,
37
+ hrefLang: locale,
38
+ }
39
+ }
40
+
41
+ return {
42
+ href: parsedHref,
43
+ hrefLang: undefined,
44
+ }
45
+ }