@akinon/pz-theme 2.0.117-rc.0 → 2.0.118-beta.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 +15 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/locale-links.test.tsx +429 -0
- package/src/blocks/account-button-block.tsx +1 -1
- package/src/blocks/account-menu-block.tsx +9 -3
- package/src/blocks/applied-filters-block.tsx +7 -1
- package/src/blocks/auth-form-block.tsx +7 -4
- package/src/blocks/basket-items-block.tsx +1 -1
- package/src/blocks/basket-summary-block.tsx +1 -1
- package/src/blocks/button-block.tsx +5 -1
- package/src/blocks/carousel-item-block.tsx +1 -1
- package/src/blocks/coupon-wallet-block.tsx +3 -1
- package/src/blocks/facet-filters-block.tsx +14 -10
- package/src/blocks/favorite-button-block.tsx +1 -1
- package/src/blocks/favourites-grid-block.tsx +1 -1
- package/src/blocks/group-block.tsx +19 -6
- package/src/blocks/hamburger-menu-block.tsx +1 -1
- package/src/blocks/hotspot-block.tsx +3 -1
- package/src/blocks/hover-showcase-item-block.tsx +3 -1
- package/src/blocks/link-block.tsx +5 -22
- package/src/blocks/listing-breadcrumb-block.tsx +1 -1
- package/src/blocks/listing-pagination-block.tsx +11 -3
- package/src/blocks/mega-menu-block.tsx +9 -3
- package/src/blocks/mini-basket-block.tsx +1 -1
- package/src/blocks/nav-menu-block.tsx +9 -3
- package/src/blocks/oauth-buttons-block.tsx +4 -0
- package/src/blocks/order-detail-block.tsx +1 -1
- package/src/blocks/order-list-block.tsx +1 -1
- package/src/blocks/order-success-block.tsx +1 -1
- package/src/blocks/otp-form-block.tsx +4 -1
- package/src/blocks/payment-badges-block.tsx +1 -1
- package/src/blocks/product-breadcrumb-block.tsx +1 -1
- package/src/blocks/product-grid-block.tsx +1 -1
- package/src/blocks/product-recommendations-block.tsx +1 -1
- package/src/blocks/register-form-block.tsx +6 -3
- package/src/blocks/search-box-block.tsx +7 -1
- package/src/blocks/social-icons-list-block.tsx +1 -1
- package/src/blocks/social-links-block.tsx +1 -1
- package/src/blocks/sort-select-block.tsx +8 -2
- package/src/blocks/store-locator-block.tsx +3 -1
- package/src/blocks/text-block.tsx +8 -1
- package/src/blocks/variant-selector-block.tsx +6 -2
- package/src/blocks/wishlist-button-block.tsx +1 -1
- package/src/components/__tests__/theme-link.test.tsx +169 -0
- package/src/components/theme-link.tsx +36 -0
- package/src/custom/README-sections.md +6 -0
- package/src/custom/README.md +71 -0
- package/src/custom/__tests__/custom-entry-exports.test.tsx +13 -0
- package/src/custom/__tests__/theme-editable.test.tsx +179 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +101 -0
- package/src/custom/index.ts +16 -0
- package/src/custom/theme-editable.tsx +57 -4
- package/src/custom/use-theme-setting.ts +77 -10
- package/src/hooks/use-localize-href.ts +34 -0
- package/src/index.ts +12 -0
- package/src/sections/find-in-store-section.tsx +3 -1
- package/src/sections/hover-showcase-section.tsx +6 -2
- package/src/sections/installment-options-section.tsx +3 -1
- package/src/sections/slider-quick-view.tsx +1 -1
- package/src/utils/__tests__/localize-href.test.ts +328 -0
- package/src/utils/localize-href.ts +192 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRootRelativeHref,
|
|
3
|
+
localizeHref,
|
|
4
|
+
localizeHtmlLinks
|
|
5
|
+
} from '../localize-href';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* zsa-zsa-zsu's shape: Turkish is the default and hidden from the URL, English
|
|
9
|
+
* lives under `/en`. The matcher is what the core builds for that setup — only
|
|
10
|
+
* the non-default locale can appear as a segment.
|
|
11
|
+
*/
|
|
12
|
+
const store = {
|
|
13
|
+
defaultLocaleValue: 'tr',
|
|
14
|
+
localeUrlStrategy: 'hide-default-locale',
|
|
15
|
+
localeMatcher: /^\/(en)(?=\/|$)/
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const onTurkish = { ...store, locale: 'tr' };
|
|
19
|
+
const onEnglish = { ...store, locale: 'en' };
|
|
20
|
+
|
|
21
|
+
describe('localizeHref under HideDefaultLocale', () => {
|
|
22
|
+
it('leaves a path alone on the default locale', () => {
|
|
23
|
+
expect(localizeHref('/x', onTurkish)).toBe('/x');
|
|
24
|
+
expect(localizeHref('/yatak-odasi/', onTurkish)).toBe('/yatak-odasi/');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('prefixes a path on a non-default locale', () => {
|
|
28
|
+
expect(localizeHref('/x', onEnglish)).toBe('/en/x');
|
|
29
|
+
expect(localizeHref('/yatak-odasi/', onEnglish)).toBe('/en/yatak-odasi/');
|
|
30
|
+
expect(localizeHref('/baskets/basket', onEnglish)).toBe(
|
|
31
|
+
'/en/baskets/basket'
|
|
32
|
+
);
|
|
33
|
+
expect(localizeHref('/users/auth?next=%2Fbasket', onEnglish)).toBe(
|
|
34
|
+
'/en/users/auth?next=%2Fbasket'
|
|
35
|
+
);
|
|
36
|
+
expect(localizeHref('/', onEnglish)).toBe('/en/');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not prefix a path that already carries the locale', () => {
|
|
40
|
+
expect(localizeHref('/en/x', onEnglish)).toBe('/en/x');
|
|
41
|
+
expect(localizeHref('/en', onEnglish)).toBe('/en');
|
|
42
|
+
expect(localizeHref(localizeHref('/x', onEnglish), onEnglish)).toBe(
|
|
43
|
+
'/en/x'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps an explicit other-locale link on the default locale', () => {
|
|
48
|
+
expect(localizeHref('/en/x', onTurkish)).toBe('/en/x');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('trims the whitespace a pasted path brings with it', () => {
|
|
52
|
+
expect(localizeHref(' /x ', onEnglish)).toBe('/en/x');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
['#a'],
|
|
57
|
+
['?q=1'],
|
|
58
|
+
[''],
|
|
59
|
+
['https://example.com/x'],
|
|
60
|
+
['http://example.com'],
|
|
61
|
+
['mailto:a@b.com'],
|
|
62
|
+
['tel:+905551112233'],
|
|
63
|
+
['//cdn.example.com/a.png'],
|
|
64
|
+
['relative/path']
|
|
65
|
+
])('leaves %p untouched on a non-default locale', (href) => {
|
|
66
|
+
expect(localizeHref(href, onEnglish)).toBe(href);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The core OAuth middleware only matches these unprefixed.
|
|
70
|
+
it.each([
|
|
71
|
+
['/google/login/'],
|
|
72
|
+
['/apple/login'],
|
|
73
|
+
['/google/login/?next=%2Fbasket'],
|
|
74
|
+
['/google/login/callback/'],
|
|
75
|
+
['/google/login/callback/?code=x']
|
|
76
|
+
])('leaves the OAuth route %p unprefixed on an English page', (href) => {
|
|
77
|
+
expect(localizeHref(href, onEnglish)).toBe(href);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('still prefixes paths that only look like a login route', () => {
|
|
81
|
+
expect(localizeHref('/users/login-help/', onEnglish)).toBe(
|
|
82
|
+
'/en/users/login-help/'
|
|
83
|
+
);
|
|
84
|
+
expect(localizeHref('/account/google/login/', onEnglish)).toBe(
|
|
85
|
+
'/en/account/google/login/'
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The live home composition stores 42 of its links as the full address the
|
|
92
|
+
* merchant copied out of the browser. Next treats an absolute URL on its own
|
|
93
|
+
* origin as an internal route and prefetches it, which is what flips the
|
|
94
|
+
* `pz-locale` cookie back to Turkish.
|
|
95
|
+
*/
|
|
96
|
+
describe('localizeHref on a full URL to this storefront', () => {
|
|
97
|
+
const site = 'https://www.zsazsazsu.com.tr';
|
|
98
|
+
const english = { ...onEnglish, siteUrl: site };
|
|
99
|
+
const turkish = { ...onTurkish, siteUrl: site };
|
|
100
|
+
|
|
101
|
+
it('gives back the localized path, so the router can take it', () => {
|
|
102
|
+
expect(localizeHref(`${site}/yeni-urunler/`, english)).toBe(
|
|
103
|
+
'/en/yeni-urunler/'
|
|
104
|
+
);
|
|
105
|
+
expect(localizeHref(`${site}/liste?sort=new#top`, english)).toBe(
|
|
106
|
+
'/en/liste?sort=new#top'
|
|
107
|
+
);
|
|
108
|
+
expect(localizeHref(site, english)).toBe('/en/');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('matches the same site with or without www, and http or https', () => {
|
|
112
|
+
expect(
|
|
113
|
+
localizeHref('https://zsazsazsu.com.tr/yeni-urunler/', english)
|
|
114
|
+
).toBe('/en/yeni-urunler/');
|
|
115
|
+
expect(
|
|
116
|
+
localizeHref('http://www.zsazsazsu.com.tr/yeni-urunler/', english)
|
|
117
|
+
).toBe('/en/yeni-urunler/');
|
|
118
|
+
expect(
|
|
119
|
+
localizeHref('https://www.zsazsazsu.com.tr/yeni-urunler/', {
|
|
120
|
+
...english,
|
|
121
|
+
siteUrl: 'https://zsazsazsu.com.tr'
|
|
122
|
+
})
|
|
123
|
+
).toBe('/en/yeni-urunler/');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('leaves it exactly as written on the default locale', () => {
|
|
127
|
+
// The Turkish page keeps rendering the link the merchant stored.
|
|
128
|
+
expect(localizeHref(`${site}/yeni-urunler/`, turkish)).toBe(
|
|
129
|
+
`${site}/yeni-urunler/`
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not touch another site, a dev port or a broken URL', () => {
|
|
134
|
+
const elsewhere = 'https://lookbook.example.com/yeni-urunler/';
|
|
135
|
+
expect(localizeHref(elsewhere, english)).toBe(elsewhere);
|
|
136
|
+
|
|
137
|
+
// The port is part of the host: localhost:3000 is not the live store.
|
|
138
|
+
const localhost = 'http://localhost:3000/yeni-urunler/';
|
|
139
|
+
expect(localizeHref(localhost, english)).toBe(localhost);
|
|
140
|
+
expect(
|
|
141
|
+
localizeHref(localhost, { ...english, siteUrl: 'http://localhost:3000' })
|
|
142
|
+
).toBe('/en/yeni-urunler/');
|
|
143
|
+
|
|
144
|
+
expect(localizeHref('https://', english)).toBe('https://');
|
|
145
|
+
expect(
|
|
146
|
+
localizeHref(`${site}/x`, { ...onEnglish, siteUrl: 'www.zsazsazsu' })
|
|
147
|
+
).toBe(`${site}/x`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves every full URL alone when the site address is unknown', () => {
|
|
151
|
+
expect(localizeHref(`${site}/yeni-urunler/`, onEnglish)).toBe(
|
|
152
|
+
`${site}/yeni-urunler/`
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('carries a locale the URL already has rather than stacking it', () => {
|
|
157
|
+
expect(localizeHref(`${site}/en/yeni-urunler/`, english)).toBe(
|
|
158
|
+
'/en/yeni-urunler/'
|
|
159
|
+
);
|
|
160
|
+
expect(localizeHref(localizeHref(`${site}/x`, english), english)).toBe(
|
|
161
|
+
'/en/x'
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The brand middleware's matcher — `/((?!api|_next|…|[\w-/*]+\.\w+).*)` — never
|
|
168
|
+
* sees these, so there is no localized copy to point at: `/en/manifest.json`
|
|
169
|
+
* is a 404.
|
|
170
|
+
*/
|
|
171
|
+
describe('localizeHref on addresses with no localized copy', () => {
|
|
172
|
+
it.each([
|
|
173
|
+
['/api/x'],
|
|
174
|
+
['/api'],
|
|
175
|
+
['/_next/static/chunk.js'],
|
|
176
|
+
['/files/kvkk.pdf'],
|
|
177
|
+
['/manifest.json'],
|
|
178
|
+
['/files/sozlesme.pdf?v=2'],
|
|
179
|
+
['/sitemap.xml#top']
|
|
180
|
+
])('leaves %p unprefixed on an English page', (href) => {
|
|
181
|
+
expect(localizeHref(href, onEnglish)).toBe(href);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('applies the same rule to a full URL of this storefront', () => {
|
|
185
|
+
const site = 'https://www.zsazsazsu.com.tr';
|
|
186
|
+
const english = { ...onEnglish, siteUrl: site };
|
|
187
|
+
|
|
188
|
+
expect(localizeHref(`${site}/files/kvkk.pdf`, english)).toBe(
|
|
189
|
+
`${site}/files/kvkk.pdf`
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('still prefixes paths that only contain a dot', () => {
|
|
194
|
+
expect(localizeHref('/kadin/elbise.ve.etek/', onEnglish)).toBe(
|
|
195
|
+
'/en/kadin/elbise.ve.etek/'
|
|
196
|
+
);
|
|
197
|
+
expect(localizeHref('/apikeys/', onEnglish)).toBe('/en/apikeys/');
|
|
198
|
+
expect(localizeHref('/_nextjs-guide/', onEnglish)).toBe(
|
|
199
|
+
'/en/_nextjs-guide/'
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Blocks localize whatever they are handed, and some of them hand on a value
|
|
206
|
+
* that has been through here already — a stored URL read twice, a `ThemeLink`
|
|
207
|
+
* inside a `ThemeEditable`. The core matcher ends in `(?=/|$)`, so without
|
|
208
|
+
* splitting the query off first `/en?page=2` finds no locale to strip and
|
|
209
|
+
* becomes `/en/en?page=2`.
|
|
210
|
+
*/
|
|
211
|
+
describe('localizeHref is idempotent', () => {
|
|
212
|
+
it.each([
|
|
213
|
+
['/en'],
|
|
214
|
+
['/en/'],
|
|
215
|
+
['/en/x'],
|
|
216
|
+
['/en/x?page=2'],
|
|
217
|
+
['/en?page=2'],
|
|
218
|
+
['/en#top'],
|
|
219
|
+
['/en/x?next=%2Fbasket#reviews']
|
|
220
|
+
])('leaves the already-localized %p alone on an English page', (href) => {
|
|
221
|
+
expect(localizeHref(href, onEnglish)).toBe(href);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it.each([['/x'], ['/'], ['/x?page=2'], ['/x#top'], ['/yatak-odasi/']])(
|
|
225
|
+
'localizes %p once and the second pass changes nothing',
|
|
226
|
+
(href) => {
|
|
227
|
+
const once = localizeHref(href, onEnglish);
|
|
228
|
+
|
|
229
|
+
expect(localizeHref(once, onEnglish)).toBe(once);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('localizeHref under other strategies', () => {
|
|
235
|
+
it('prefixes every locale, the default too, under ShowAllLocales', () => {
|
|
236
|
+
const showAll = {
|
|
237
|
+
defaultLocaleValue: 'tr',
|
|
238
|
+
localeUrlStrategy: 'show-all-locales',
|
|
239
|
+
localeMatcher: /^\/(tr|en)(?=\/|$)/
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
expect(localizeHref('/x', { ...showAll, locale: 'tr' })).toBe('/tr/x');
|
|
243
|
+
expect(localizeHref('/x', { ...showAll, locale: 'en' })).toBe('/en/x');
|
|
244
|
+
// An existing locale is replaced, not stacked.
|
|
245
|
+
expect(localizeHref('/tr/x', { ...showAll, locale: 'en' })).toBe('/en/x');
|
|
246
|
+
expect(localizeHref('#a', { ...showAll, locale: 'en' })).toBe('#a');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it.each([['hide-all-locales'], ['subdomain']])(
|
|
250
|
+
'never prefixes under %s',
|
|
251
|
+
(localeUrlStrategy) => {
|
|
252
|
+
expect(
|
|
253
|
+
localizeHref('/x', { ...store, localeUrlStrategy, locale: 'en' })
|
|
254
|
+
).toBe('/x');
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
it('changes nothing without a locale or a strategy', () => {
|
|
259
|
+
expect(localizeHref('/x', { ...store, locale: undefined })).toBe('/x');
|
|
260
|
+
expect(localizeHref('/x', { locale: 'en' })).toBe('/x');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('prefixes without stripping when no matcher is given', () => {
|
|
264
|
+
expect(
|
|
265
|
+
localizeHref('/x', {
|
|
266
|
+
locale: 'en',
|
|
267
|
+
defaultLocaleValue: 'tr',
|
|
268
|
+
localeUrlStrategy: 'hide-default-locale'
|
|
269
|
+
})
|
|
270
|
+
).toBe('/en/x');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('isRootRelativeHref', () => {
|
|
275
|
+
it('accepts a storefront path only', () => {
|
|
276
|
+
expect(isRootRelativeHref('/x')).toBe(true);
|
|
277
|
+
expect(isRootRelativeHref(' /x')).toBe(true);
|
|
278
|
+
expect(isRootRelativeHref('//cdn.example.com')).toBe(false);
|
|
279
|
+
expect(isRootRelativeHref('#a')).toBe(false);
|
|
280
|
+
expect(isRootRelativeHref('?q=1')).toBe(false);
|
|
281
|
+
expect(isRootRelativeHref('https://example.com')).toBe(false);
|
|
282
|
+
expect(isRootRelativeHref(undefined)).toBe(false);
|
|
283
|
+
expect(isRootRelativeHref({ pathname: '/x' })).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('localizeHtmlLinks', () => {
|
|
288
|
+
const toEnglish = (href: string) => localizeHref(href, onEnglish);
|
|
289
|
+
|
|
290
|
+
it('prefixes the storefront links in authored markup', () => {
|
|
291
|
+
expect(
|
|
292
|
+
localizeHtmlLinks(
|
|
293
|
+
'Detaylar için <a href="/kampanya/">tıklayın</a> ya da ' +
|
|
294
|
+
"<a class='c' href='/yatak-odasi/?sort=new'>göz atın</a>",
|
|
295
|
+
toEnglish
|
|
296
|
+
)
|
|
297
|
+
).toBe(
|
|
298
|
+
'Detaylar için <a href="/en/kampanya/">tıklayın</a> ya da ' +
|
|
299
|
+
"<a class='c' href='/en/yatak-odasi/?sort=new'>göz atın</a>"
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('leaves everything that is not a storefront path byte for byte', () => {
|
|
304
|
+
const html =
|
|
305
|
+
'<p><a href="https://example.com/x" target="_blank">x</a>' +
|
|
306
|
+
'<a href="#faq">faq</a><a href="mailto:a@b.com">mail</a>' +
|
|
307
|
+
'<a href="/google/login/">google</a><a data-href="/x">no href</a>' +
|
|
308
|
+
'<link href="/x"><img src="/x.png"></p>';
|
|
309
|
+
|
|
310
|
+
expect(localizeHtmlLinks(html, toEnglish)).toBe(html);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('keeps an apostrophe inside a double-quoted href', () => {
|
|
314
|
+
expect(localizeHtmlLinks(`<a href="/kadin's/">x</a>`, toEnglish)).toBe(
|
|
315
|
+
`<a href="/en/kadin's/">x</a>`
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('changes nothing on the default locale or without markup', () => {
|
|
320
|
+
const html = '<a href="/kampanya/">x</a>';
|
|
321
|
+
|
|
322
|
+
expect(
|
|
323
|
+
localizeHtmlLinks(html, (href) => localizeHref(href, onTurkish))
|
|
324
|
+
).toBe(html);
|
|
325
|
+
expect(localizeHtmlLinks('plain text', toEnglish)).toBe('plain text');
|
|
326
|
+
expect(localizeHtmlLinks('', toEnglish)).toBe('');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { LocaleUrlStrategy } from '@akinon/next/localization';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The storefront's locale settings, as `useLocalization()` hands them out.
|
|
5
|
+
*
|
|
6
|
+
* `localeMatcher` finds a locale segment an href already starts with — the
|
|
7
|
+
* core's `urlLocaleMatcherRegex` in the app, passed in so the rules below stay
|
|
8
|
+
* a pure function of their arguments.
|
|
9
|
+
*
|
|
10
|
+
* `siteUrl` is the storefront's own address (`NEXT_PUBLIC_URL`), which is what
|
|
11
|
+
* makes a merchant's full URL recognizable as one of these pages rather than
|
|
12
|
+
* somewhere else on the web. Without it a full URL is always left alone.
|
|
13
|
+
*/
|
|
14
|
+
export interface HrefLocale {
|
|
15
|
+
locale?: string;
|
|
16
|
+
defaultLocaleValue?: string;
|
|
17
|
+
localeUrlStrategy?: string;
|
|
18
|
+
localeMatcher?: RegExp;
|
|
19
|
+
siteUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Only a path on this storefront (`/yatak-odasi/`) has a locale to carry.
|
|
24
|
+
* `//cdn…` starts with a slash but is another host; a bare `#anchor` or
|
|
25
|
+
* `?page=2` is relative to the page the shopper is already on, which already
|
|
26
|
+
* has its locale, and prefixing it would drop that page's path instead.
|
|
27
|
+
*/
|
|
28
|
+
export const isRootRelativeHref = (href: unknown): href is string =>
|
|
29
|
+
typeof href === 'string' &&
|
|
30
|
+
href.trim().startsWith('/') &&
|
|
31
|
+
!href.trim().startsWith('//');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `/{provider}/login/` and its `/callback/`: the core `withOauthLogin`
|
|
35
|
+
* middleware runs before the locale one and matches only the unprefixed path
|
|
36
|
+
* (`^/(\w+)/login(/callback)?/?$`), so `/en/google/login/` would miss it and
|
|
37
|
+
* fall through to a page that does not exist. Every `/{word}/login/` is OAuth
|
|
38
|
+
* to the core, so leaving these alone costs no storefront page.
|
|
39
|
+
*/
|
|
40
|
+
const OAUTH_ROUTE = /^\/\w+\/login(?:\/callback)?\/?(?:[?#]|$)/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A path split into the part a locale goes in front of and the `?query#hash`
|
|
44
|
+
* that has to ride along untouched. The core's `urlLocaleMatcherRegex` ends in
|
|
45
|
+
* `(?=/|$)`, so on `/en?page=2` it finds nothing to strip and the locale would
|
|
46
|
+
* be stacked into `/en/en?page=2` — which is what a second pass over an
|
|
47
|
+
* already-localized href does, and blocks localize whatever they are handed.
|
|
48
|
+
*/
|
|
49
|
+
const splitQuery = (path: string): [string, string] => {
|
|
50
|
+
const at = path.search(/[?#]/);
|
|
51
|
+
return at === -1 ? [path, ''] : [path.slice(0, at), path.slice(at)];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Addresses the storefront serves only one copy of: the API, Next's own
|
|
56
|
+
* assets, and anything ending in a file (`/files/kvkk.pdf`, `/manifest.json`).
|
|
57
|
+
* This is the brand middleware's matcher — `/((?!api|_next|…|[\w-/*]+\.\w+).*)`
|
|
58
|
+
* — read the other way round: the locale middleware never sees these, so `/en`
|
|
59
|
+
* in front of one is a 404 rather than a translation.
|
|
60
|
+
*/
|
|
61
|
+
const hasNoLocaleVariant = (path: string): boolean => {
|
|
62
|
+
const [pathname] = splitQuery(path);
|
|
63
|
+
|
|
64
|
+
if (/^\/(?:api|_next)(?:\/|$)/.test(pathname)) return true;
|
|
65
|
+
|
|
66
|
+
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
|
|
67
|
+
return /\.[^.]+$/.test(lastSegment);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `www.` is not a different site. A merchant pastes whichever host their
|
|
72
|
+
* browser showed them, and the storefront answers on both.
|
|
73
|
+
*/
|
|
74
|
+
const siteHost = (host: string): string =>
|
|
75
|
+
host.toLowerCase().replace(/^www\./, '');
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A full URL of this storefront's own (`https://www.zsazsazsu.com.tr/yeni-urunler/`)
|
|
79
|
+
* as the path it points at, or nothing when it points somewhere else.
|
|
80
|
+
*
|
|
81
|
+
* Merchants paste the address bar, so most stored links are full URLs — 42 of
|
|
82
|
+
* them on the live home page alone. Next's app router treats an absolute URL
|
|
83
|
+
* on the same origin as an internal route: it prefetches it, the middleware
|
|
84
|
+
* answers that unprefixed path with `pz-locale=tr`, and the English page the
|
|
85
|
+
* shopper is reading starts serving Turkish. The port is part of the host
|
|
86
|
+
* comparison, so a dev storefront on :3000 is not confused with production.
|
|
87
|
+
*/
|
|
88
|
+
export const asSitePath = (
|
|
89
|
+
href: string,
|
|
90
|
+
siteUrl?: string
|
|
91
|
+
): string | undefined => {
|
|
92
|
+
if (!siteUrl || !/^https?:\/\//i.test(href)) return undefined;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(href);
|
|
96
|
+
const site = new URL(siteUrl);
|
|
97
|
+
|
|
98
|
+
if (siteHost(url.host) !== siteHost(site.host)) return undefined;
|
|
99
|
+
|
|
100
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
101
|
+
} catch {
|
|
102
|
+
// A malformed URL is not this function's to fix: the href goes out as the
|
|
103
|
+
// merchant wrote it, the same as any other address we do not recognize.
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `href` as it has to be written on a page in `locale`.
|
|
110
|
+
*
|
|
111
|
+
* Content stores storefront paths without a locale (`/yatak-odasi/`), and on
|
|
112
|
+
* an English page that path IS the Turkish site: a click leaves the English
|
|
113
|
+
* site, and every prefetch of it makes the middleware answer with
|
|
114
|
+
* `pz-locale=tr`, which every client request after it then sends as its
|
|
115
|
+
* language. So the same rules as `@akinon/next`'s own `Link` are applied here:
|
|
116
|
+
* strip a locale the href already carries, then prefix the active one when the
|
|
117
|
+
* URL strategy shows it — always for ShowAllLocales, and for HideDefaultLocale
|
|
118
|
+
* only off the default locale. The other strategies never carry a locale.
|
|
119
|
+
*
|
|
120
|
+
* A full URL of this storefront's own counts as one of those paths (see
|
|
121
|
+
* `asSitePath`), and on a page that shows its locale it comes back as the
|
|
122
|
+
* root-relative localized path — which is also what lets the router take it
|
|
123
|
+
* client-side. On a page that hides its locale it is returned exactly as
|
|
124
|
+
* given, so the Turkish site keeps rendering the links it always had.
|
|
125
|
+
*
|
|
126
|
+
* Unlike the core `Link`, anything else that is not a root-relative path comes
|
|
127
|
+
* back untouched rather than being turned into `#` or `/<locale>#anchor`:
|
|
128
|
+
* blocks route hash, query, empty and external hrefs through here as well. The
|
|
129
|
+
* OAuth routes (see `OAUTH_ROUTE`) and the addresses with no localized copy
|
|
130
|
+
* (see `hasNoLocaleVariant`) are left alone too.
|
|
131
|
+
*/
|
|
132
|
+
export const localizeHref = (
|
|
133
|
+
href: string,
|
|
134
|
+
{
|
|
135
|
+
locale,
|
|
136
|
+
defaultLocaleValue,
|
|
137
|
+
localeUrlStrategy,
|
|
138
|
+
localeMatcher,
|
|
139
|
+
siteUrl
|
|
140
|
+
}: HrefLocale
|
|
141
|
+
): string => {
|
|
142
|
+
if (!locale || typeof href !== 'string') return href;
|
|
143
|
+
|
|
144
|
+
const path = asSitePath(href.trim(), siteUrl) ?? href.trim();
|
|
145
|
+
|
|
146
|
+
if (!isRootRelativeHref(path)) return href;
|
|
147
|
+
if (OAUTH_ROUTE.test(path)) return href;
|
|
148
|
+
if (hasNoLocaleVariant(path)) return href;
|
|
149
|
+
|
|
150
|
+
const showsLocale =
|
|
151
|
+
localeUrlStrategy === LocaleUrlStrategy.ShowAllLocales ||
|
|
152
|
+
(localeUrlStrategy === LocaleUrlStrategy.HideDefaultLocale &&
|
|
153
|
+
locale !== defaultLocaleValue);
|
|
154
|
+
|
|
155
|
+
if (!showsLocale) return href;
|
|
156
|
+
|
|
157
|
+
const [pathname, query] = splitQuery(path);
|
|
158
|
+
const pathnameWithoutLocale = localeMatcher
|
|
159
|
+
? pathname.replace(localeMatcher, '')
|
|
160
|
+
: pathname;
|
|
161
|
+
|
|
162
|
+
return `/${locale}${pathnameWithoutLocale}${query}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const ANCHOR_OPEN_TAG = /<a\b[^>]*>/gi;
|
|
166
|
+
const HREF_ATTRIBUTE = /(\shref\s*=\s*)(?:"([^"]*)"|'([^']*)')/i;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Authored rich text (`text-block`) with every `<a href>` passed through
|
|
170
|
+
* `localize`. Its links reach the page as markup, not through `ThemeLink`, so
|
|
171
|
+
* `/kampanya/` in a paragraph on an English page would still open the Turkish
|
|
172
|
+
* site. A string rewrite rather than a DOM pass, because the markup is
|
|
173
|
+
* rendered on the server too, where there is no DOM. Only quoted `href`s on
|
|
174
|
+
* `<a>` tags are touched; everything else is returned byte for byte.
|
|
175
|
+
*/
|
|
176
|
+
export const localizeHtmlLinks = (
|
|
177
|
+
html: string,
|
|
178
|
+
localize: (href: string) => string
|
|
179
|
+
): string => {
|
|
180
|
+
if (typeof html !== 'string' || !/<a\b/i.test(html)) return html;
|
|
181
|
+
|
|
182
|
+
return html.replace(ANCHOR_OPEN_TAG, (tag) =>
|
|
183
|
+
tag.replace(HREF_ATTRIBUTE, (attribute, prefix, doubled, single) => {
|
|
184
|
+
const href = doubled ?? single;
|
|
185
|
+
const localized = localize(href);
|
|
186
|
+
if (localized === href) return attribute;
|
|
187
|
+
|
|
188
|
+
const quote = doubled !== undefined ? '"' : "'";
|
|
189
|
+
return `${prefix}${quote}${localized}${quote}`;
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
};
|