@akinon/pz-theme 2.0.121-rc.0 → 2.0.122-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 CHANGED
@@ -1,40 +1,21 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.121-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.121-rc.0
37
- - @akinon/theme-contract@2.0.121-rc.0
3
+ ## 2.0.122-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.122-beta.0
9
+ - @akinon/theme-contract@2.0.122-beta.0
10
+
11
+ ## 2.0.121
12
+
13
+ ### Patch Changes
14
+
15
+ - 60fc43e: ZERO-5108: A theme link fetches nothing ahead of time unless its caller asks for it. Next otherwise fetches a link's page as soon as the link is on screen, and because the language lives in a cookie that any such request rewrites, one link that escaped the locale rules switched the whole session's language with nobody clicking anything. next/link now has a single importer in the package, held there by a test, so a block cannot bring its own link back in without the locale or this default.
16
+ - e64e783: ZERO-5107: Put the page locale on a link in the browser as well. A public env var is inlined when the app is built and ACC hands the container its environment afterwards, so the client bundle carried no site address: the server rendered the localized path, hydration put the merchant's full URL back, Next fetched that unprefixed path ahead of the shopper and the middleware answered it by switching the language cookie. The browser now falls back to the address it is served from, and every caller asks the same question through one helper.
17
+ - @akinon/next@2.0.121
18
+ - @akinon/theme-contract@2.0.121
38
19
 
39
20
  ## 2.0.120
40
21
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.121-rc.0",
4
+ "version": "2.0.122-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.121-rc.0",
11
+ "@akinon/next": "2.0.122-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.121-rc.0",
16
+ "@akinon/theme-contract": "2.0.122-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.121-rc.0",
24
+ "@akinon/next": "2.0.122-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -1,7 +1,11 @@
1
1
  import React from 'react';
2
2
  import { ThemeLink as NextLink } from '../components/theme-link';
3
3
  import { getResponsiveValue } from '../utils';
4
- import { asSitePath, isRootRelativeHref } from '../utils/localize-href';
4
+ import {
5
+ asSitePath,
6
+ isRootRelativeHref,
7
+ storefrontOrigin
8
+ } from '../utils/localize-href';
5
9
  import {
6
10
  buildCommerceItem,
7
11
  emitCommerceEvent,
@@ -76,7 +80,7 @@ const GroupBlock = ({
76
80
  const routerHref = isRootRelativeHref(href)
77
81
  ? href.trim()
78
82
  : typeof href === 'string'
79
- ? asSitePath(href.trim(), process.env.NEXT_PUBLIC_URL)
83
+ ? asSitePath(href.trim(), storefrontOrigin())
80
84
  : undefined;
81
85
  const useRouterLink = tag === 'a' && !isDesigner && routerHref !== undefined;
82
86
  if (tag === 'a' && href && !useRouterLink) {
@@ -119,7 +123,6 @@ const GroupBlock = ({
119
123
  return (
120
124
  <NextLink
121
125
  href={routerHref as string}
122
- prefetch={false}
123
126
  className="contents"
124
127
  onClick={handleAnchorAnalytics}
125
128
  />
@@ -168,7 +171,6 @@ const GroupBlock = ({
168
171
  return (
169
172
  <NextLink
170
173
  href={routerHref as string}
171
- prefetch={false}
172
174
  className="contents"
173
175
  onClick={handleAnchorAnalytics}
174
176
  >
@@ -5,7 +5,7 @@ import React from 'react';
5
5
  // `/{provider}/login/`, so there is no locale to add. `localizeHref` leaves that
6
6
  // route alone too (`OAUTH_ROUTE`), which keeps a row template that renders
7
7
  // `item.url` through a button or group working on an English page.
8
- import NextLink from 'next/link';
8
+ import { ThemeLink as NextLink } from '../components/theme-link';
9
9
  import { getResponsiveValue, partAttrs } from '../utils';
10
10
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
11
11
  import { hasItemTemplate, ItemTemplateList } from './item-template';
@@ -149,8 +149,9 @@ const OauthButtonsBlock = ({
149
149
  data-oauth-button={provider}
150
150
  {...partAttrs(`button-${provider}`)}
151
151
  href={isDesigner ? '#' : providerHref(provider)}
152
- // The handshake leaves the Next.js app entirely, so the client router
153
- // must not try to prefetch or soft-navigate it.
152
+ // The handshake leaves the Next.js app entirely, so the client
153
+ // router must not soft-navigate it. `ThemeLink` leaves an OAuth
154
+ // route's href exactly as it is and prefetches nothing by default.
154
155
  prefetch={false}
155
156
  onClick={(event) => {
156
157
  if (isDesigner) event.preventDefault();
@@ -0,0 +1,121 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import React from 'react';
4
+ import { render } from '@testing-library/react';
5
+
6
+ /**
7
+ * ZERO-5108: a theme link prefetches nothing unless its caller asks.
8
+ *
9
+ * Next fetches a link's page as soon as the link is on screen, and the locale
10
+ * lives in a cookie that any request to an unprefixed path rewrites. One link
11
+ * that escapes the locale rules therefore stops being a wrong destination and
12
+ * becomes a silent language switch for the whole session, with nobody clicking
13
+ * anything — which is exactly how zsa-zsa-zsu's English storefront started
14
+ * serving Turkish menus. The rules still apply; this removes the mechanism
15
+ * that made a single miss cost the session.
16
+ *
17
+ * Asserted on the props `next/link` receives, because a boolean that React
18
+ * never puts on the anchor cannot be read back off the DOM.
19
+ */
20
+ const received: Array<Record<string, unknown>> = [];
21
+
22
+ jest.mock('next/link', () => ({
23
+ __esModule: true,
24
+ default: (props: Record<string, unknown>) => {
25
+ received.push(props);
26
+ return React.createElement('a', {
27
+ href: typeof props.href === 'string' ? props.href : '#'
28
+ });
29
+ }
30
+ }));
31
+
32
+ jest.mock('@akinon/next/hooks', () => ({
33
+ __esModule: true,
34
+ useLocalization: () => ({
35
+ locale: 'tr',
36
+ defaultLocaleValue: 'tr',
37
+ localeUrlStrategy: 'hide-default-locale'
38
+ })
39
+ }));
40
+
41
+ import { ThemeLink } from '../theme-link';
42
+
43
+ beforeEach(() => {
44
+ received.length = 0;
45
+ });
46
+
47
+ describe('ThemeLink prefetching', () => {
48
+ it('asks for no prefetch by default', () => {
49
+ render(<ThemeLink href="/yatak-odasi/">Yatak odası</ThemeLink>);
50
+
51
+ expect(received).toHaveLength(1);
52
+ expect(received[0].prefetch).toBe(false);
53
+ });
54
+
55
+ it('lets a caller turn it back on for its own case', () => {
56
+ render(
57
+ <ThemeLink href="/yatak-odasi/" prefetch>
58
+ Yatak odası
59
+ </ThemeLink>
60
+ );
61
+
62
+ expect(received[0].prefetch).toBe(true);
63
+ });
64
+
65
+ /**
66
+ * `oauth-buttons-block` renders its handshake through this component now.
67
+ * The handshake leaves the app, and the core middleware matches only the
68
+ * unprefixed path, so `/en/google/login/` would reach a page that does not
69
+ * exist.
70
+ */
71
+ it('leaves an OAuth handshake unprefixed and unprefetched', () => {
72
+ render(<ThemeLink href="/google/login/">Continue with Google</ThemeLink>);
73
+
74
+ expect(received[0]).toMatchObject({
75
+ href: '/google/login/',
76
+ prefetch: false
77
+ });
78
+ });
79
+
80
+ it('keeps passing everything else through', () => {
81
+ render(
82
+ <ThemeLink href="/yatak-odasi/" className="row" data-part="link">
83
+ Yatak odası
84
+ </ThemeLink>
85
+ );
86
+
87
+ expect(received[0]).toMatchObject({
88
+ href: '/yatak-odasi/',
89
+ className: 'row',
90
+ 'data-part': 'link'
91
+ });
92
+ });
93
+ });
94
+
95
+ /**
96
+ * The guard that keeps the rule true for code written later: `next/link` is
97
+ * imported in exactly one file, so no block can bring its own link back in —
98
+ * with neither the locale nor this default on it.
99
+ */
100
+ describe('next/link has one importer', () => {
101
+ const SRC = path.resolve(__dirname, '../..');
102
+
103
+ const sourceFiles = (dir: string): string[] =>
104
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
105
+ const full = path.join(dir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ return entry.name === '__tests__' || entry.name === 'node_modules'
108
+ ? []
109
+ : sourceFiles(full);
110
+ }
111
+ return /\.tsx?$/.test(entry.name) ? [full] : [];
112
+ });
113
+
114
+ it('is components/theme-link.tsx and nothing else', () => {
115
+ const importers = sourceFiles(SRC)
116
+ .filter((file) => /from\s+'next\/link'/.test(fs.readFileSync(file, 'utf8')))
117
+ .map((file) => path.relative(SRC, file).split(path.sep).join('/'));
118
+
119
+ expect(importers).toEqual(['components/theme-link.tsx']);
120
+ });
121
+ });
@@ -85,10 +85,9 @@ describe('ThemeLink', () => {
85
85
  });
86
86
 
87
87
  /**
88
- * The storefront's own address reaches the rules as `NEXT_PUBLIC_URL`, which
89
- * Next inlines into the server render and the client bundle alike — so a
90
- * merchant's pasted full URL reads as a path on both sides, and the anchor
91
- * the shopper sees is the same one the server sent.
88
+ * The storefront's own address reaches the rules as `NEXT_PUBLIC_URL`, so a
89
+ * merchant's pasted full URL reads as a path and the anchor the shopper sees
90
+ * is the same one the server sent.
92
91
  */
93
92
  describe('with the site address configured', () => {
94
93
  const configured = process.env.NEXT_PUBLIC_URL;
@@ -134,6 +133,56 @@ describe('ThemeLink', () => {
134
133
  });
135
134
  });
136
135
 
136
+ /**
137
+ * ZERO-5107: a public env var is inlined at BUILD time and ACC hands the
138
+ * container its environment afterwards, so no client bundle built there
139
+ * carries `NEXT_PUBLIC_URL` at all. Without a fallback the server rendered
140
+ * `/en/yeni-urunler/`, hydration put the full URL back, Next prefetched that
141
+ * unprefixed path and the middleware answered it with `pz-locale=tr`.
142
+ */
143
+ describe('with the site address missing from the bundle', () => {
144
+ const configured = process.env.NEXT_PUBLIC_URL;
145
+
146
+ beforeEach(() => {
147
+ delete process.env.NEXT_PUBLIC_URL;
148
+ });
149
+
150
+ afterAll(() => {
151
+ if (configured === undefined) delete process.env.NEXT_PUBLIC_URL;
152
+ else process.env.NEXT_PUBLIC_URL = configured;
153
+ });
154
+
155
+ it('falls back to the address the page is served from', () => {
156
+ mockLocalization = { ...TURKISH, locale: 'en' };
157
+
158
+ expect(
159
+ hrefOf(
160
+ <ThemeLink href={`${window.location.origin}/yeni-urunler/`}>
161
+ New in
162
+ </ThemeLink>
163
+ )
164
+ ).toBe('/en/yeni-urunler/');
165
+ });
166
+
167
+ it('keeps that address as it was written on the Turkish page', () => {
168
+ const href = `${window.location.origin}/yeni-urunler/`;
169
+
170
+ expect(hrefOf(<ThemeLink href={href}>Yeni ürünler</ThemeLink>)).toBe(
171
+ href
172
+ );
173
+ });
174
+
175
+ it('still leaves another site alone', () => {
176
+ mockLocalization = { ...TURKISH, locale: 'en' };
177
+
178
+ expect(
179
+ hrefOf(
180
+ <ThemeLink href="https://lookbook.example.com/x">Lookbook</ThemeLink>
181
+ )
182
+ ).toBe('https://lookbook.example.com/x');
183
+ });
184
+ });
185
+
137
186
  it('keeps every other prop and forwards a ref to the anchor', () => {
138
187
  mockLocalization = { ...TURKISH, locale: 'en' };
139
188
  const ref = React.createRef<HTMLAnchorElement>();
@@ -18,15 +18,26 @@ type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
18
18
  * Rendered through `next/link` rather than `@akinon/next`'s `Link` so a ref
19
19
  * still reaches the anchor, and so links and imperative navigations share one
20
20
  * rule set.
21
+ *
22
+ * NOTHING IS PREFETCHED UNLESS THE CALLER ASKS (ZERO-5108). Next fetches a
23
+ * link's page as soon as the link scrolls into view, and the locale lives in a
24
+ * cookie that any request to an unprefixed path rewrites — so one link that
25
+ * escapes the rules above stops being a wrong destination and becomes a silent
26
+ * language switch for the whole session, with nobody clicking anything. A
27
+ * composition is a wall of links the merchant arranged, and the cost of not
28
+ * prefetching them is paid once per click by the shopper who actually
29
+ * navigates. `prefetch` is still a prop: a caller that measured its own case
30
+ * can pass `true`.
21
31
  */
22
32
  export const ThemeLink = forwardRef<HTMLAnchorElement, ThemeLinkProps>(
23
- function ThemeLink({ href, ...rest }, ref) {
33
+ function ThemeLink({ href, prefetch = false, ...rest }, ref) {
24
34
  const localize = useLocalizeHref();
25
35
 
26
36
  return (
27
37
  <NextLink
28
38
  ref={ref}
29
39
  href={typeof href === 'string' ? localize(href) : href}
40
+ prefetch={prefetch}
30
41
  {...rest}
31
42
  />
32
43
  );
@@ -175,6 +175,12 @@ write the locale yourself; there are three places it goes on for you:
175
175
  | Any other link | `ThemeLink` in place of `next/link`, with the same props |
176
176
  | A bare string: a plain `<a>`, `router.push`, a url read off commerce data | `const localize = useLocalizeHref();` then `localize(url)` |
177
177
 
178
+ `ThemeLink` prefetches nothing unless you pass `prefetch`. Next otherwise
179
+ fetches a link's page the moment it scrolls into view, and because the locale
180
+ lives in a cookie that any request to an unprefixed path rewrites, one link
181
+ that slips past these rules would switch the whole session's language without
182
+ anyone clicking it.
183
+
178
184
  All three come from `@akinon/pz-theme/src/custom`. An `<a>` nested deeper
179
185
  inside a `ThemeEditable` is not touched — that one is a link of yours, so it
180
186
  takes the row it belongs to above.
@@ -3,7 +3,7 @@
3
3
  import { useCallback } from 'react';
4
4
  import { useLocalization } from '@akinon/next/hooks';
5
5
  import { urlLocaleMatcherRegex } from '@akinon/next/utils';
6
- import { localizeHref } from '../utils/localize-href';
6
+ import { localizeHref, storefrontOrigin } from '../utils/localize-href';
7
7
 
8
8
  /**
9
9
  * `localizeHref` bound to the active locale, for anything that navigates
@@ -11,14 +11,11 @@ import { localizeHref } from '../utils/localize-href';
11
11
  *
12
12
  * Everything that localizes goes through this hook rather than calling
13
13
  * `localizeHref` itself, because this is the only place that knows the
14
- * storefront's own address — and without it a merchant's full URL to this same
15
- * site would not be recognized as a path. `NEXT_PUBLIC_URL` is a public env
16
- * var, so Next inlines the same literal into the server render and the client
17
- * bundle, and the two cannot disagree about which links are internal.
14
+ * storefront's own address.
18
15
  */
19
16
  export const useLocalizeHref = (): ((href: string) => string) => {
20
17
  const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
21
- const siteUrl = process.env.NEXT_PUBLIC_URL;
18
+ const site = storefrontOrigin();
22
19
 
23
20
  return useCallback(
24
21
  (href: string) =>
@@ -27,8 +24,8 @@ export const useLocalizeHref = (): ((href: string) => string) => {
27
24
  defaultLocaleValue,
28
25
  localeUrlStrategy,
29
26
  localeMatcher: urlLocaleMatcherRegex,
30
- siteUrl
27
+ siteUrl: site
31
28
  }),
32
- [locale, defaultLocaleValue, localeUrlStrategy, siteUrl]
29
+ [locale, defaultLocaleValue, localeUrlStrategy, site]
33
30
  );
34
31
  };
@@ -67,6 +67,27 @@ const hasNoLocaleVariant = (path: string): boolean => {
67
67
  return /\.[^.]+$/.test(lastSegment);
68
68
  };
69
69
 
70
+ /**
71
+ * The storefront's own address, which is what makes a merchant's full URL to
72
+ * this same site recognizable as one of its paths.
73
+ *
74
+ * `NEXT_PUBLIC_URL` is read first and is the only value the server has. The
75
+ * browser falls back to the address it is actually being served from, because
76
+ * a public env var is inlined at BUILD time and ACC hands the container its
77
+ * environment afterwards: no client bundle built there carries the value at
78
+ * all. Without the fallback the server rendered `/en/yeni-urunler/` and
79
+ * hydration put the full URL back, Next prefetched that unprefixed path, and
80
+ * the middleware answered it with `pz-locale=tr` — every client request on an
81
+ * English page came back Turkish (ZERO-5107, live on 2026-09-23).
82
+ *
83
+ * Exported because a block that decides whether an href is internal has to ask
84
+ * the same question the same way; `group-block` read the env var directly and
85
+ * answered "not this site" in every browser.
86
+ */
87
+ export const storefrontOrigin = (): string | undefined =>
88
+ process.env.NEXT_PUBLIC_URL ||
89
+ (typeof window === 'undefined' ? undefined : window.location.origin);
90
+
70
91
  /**
71
92
  * `www.` is not a different site. A merchant pastes whichever host their
72
93
  * browser showed them, and the storefront answers on both.