@akinon/pz-theme 2.0.139-beta.0 → 2.0.139

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,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.139-beta.0
3
+ ## 2.0.139
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.139-beta.0
9
- - @akinon/theme-contract@2.0.139-beta.0
7
+ - 1bd9583: ZERO-5188: Refuse to render an anchor inside another anchor
8
+ - @akinon/next@2.0.139
9
+ - @akinon/theme-contract@2.0.139
10
10
 
11
11
  ## 2.0.138
12
12
 
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.139-beta.0",
4
+ "version": "2.0.139",
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.139-beta.0",
11
+ "@akinon/next": "2.0.139",
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.139-beta.0",
16
+ "@akinon/theme-contract": "2.0.139",
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.139-beta.0",
24
+ "@akinon/next": "2.0.139",
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",
@@ -248,10 +248,10 @@ describe('interactive nesting', () => {
248
248
  expect(html).toContain('<button type="button" data-block-id="cta"');
249
249
  });
250
250
 
251
- it('still catches the nesting when neither link acts', () => {
252
- // Guards the guard. With no action the root is an anchor again, and a
253
- // nested link is the original defect — which only the descendant-side
254
- // guard can fix, not the root element choice.
251
+ it('refuses to nest an anchor when neither link acts', () => {
252
+ // The original defect: with no action the root is a real anchor again,
253
+ // and the merchant nested a second link inside it. The descendant guard
254
+ // in ThemeLink is what covers this one — the root element choice cannot.
255
255
  const html = renderToStaticMarkup(
256
256
  <LinkBlock
257
257
  {...props({
@@ -261,7 +261,13 @@ describe('interactive nesting', () => {
261
261
  />
262
262
  );
263
263
 
264
- expect(invalidNestings(html)).toContain('a-in-a');
264
+ expect(invalidNestings(html)).toEqual([]);
265
+ // The outer link keeps the destination: it is what a shopper aims at and
266
+ // what a crawler should follow.
267
+ expect(html).toContain('href="/koleksiyonlar/"');
268
+ // The inner one degraded rather than disappearing.
269
+ expect(html).toContain('data-nested-link');
270
+ expect((html.match(/<a /g) ?? []).length).toBe(1);
265
271
  });
266
272
 
267
273
  it('detects button-in-button, so the native-button root cannot come back', () => {
@@ -207,6 +207,12 @@ const LinkBlock = ({
207
207
  rel={isDesigner ? undefined : rel}
208
208
  {...rootProps}
209
209
  >
210
+ {/*
211
+ * No anchor guard needed here: `ThemeLink` declares the open anchor
212
+ * for its own subtree, so every link in the package is covered at
213
+ * one place. The action branch above renders a span, which can
214
+ * legally hold a link, and correctly declares nothing.
215
+ */}
210
216
  {children}
211
217
  </NextLink>
212
218
  );
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext } from 'react';
4
+
5
+ /**
6
+ * Whether an anchor is already open above this point in the composed tree.
7
+ *
8
+ * `<a>` is an HTML formatting element: an anchor nested inside another is not
9
+ * merely invalid, the parser REPARENTS it, so the DOM the browser builds stops
10
+ * matching the markup react-dom streamed. With no Suspense boundary over
11
+ * composed content React 18 answers that mismatch by client-rendering the
12
+ * whole document — one nested link a merchant dragged into a card takes the
13
+ * entire page down.
14
+ *
15
+ * A composition is authored, not written, so the renderer cannot assume the
16
+ * shape is legal. This context lets the anchor that is already open say so,
17
+ * and `components/theme-link.tsx` — the one place every link in the package
18
+ * goes through — renders something that is not an anchor instead.
19
+ *
20
+ * Only ANCHORS provide it. A `<span role="button">` action root may hold a
21
+ * real link, and `<a>` inside `<button>` is not reparented either; the pair
22
+ * that breaks is anchor-in-anchor (and button-in-button, which the package
23
+ * cannot author because no button-rooted block renders child blocks).
24
+ */
25
+ const AnchorAncestorContext = createContext(false);
26
+
27
+ /** True when the nearest composed ancestor already rendered an `<a>`. */
28
+ export const useInsideAnchor = (): boolean => useContext(AnchorAncestorContext);
29
+
30
+ /**
31
+ * Wrap the CHILDREN of an element that is a real anchor. Rendered from the
32
+ * same branch that chose the anchor, so the signal always describes the
33
+ * element that actually shipped rather than the block's type — a link block
34
+ * performing an action renders a span and must not claim an anchor is open.
35
+ */
36
+ export const AnchorAncestor = ({
37
+ children
38
+ }: {
39
+ children: React.ReactNode;
40
+ }): React.ReactElement => (
41
+ <AnchorAncestorContext.Provider value={true}>
42
+ {children}
43
+ </AnchorAncestorContext.Provider>
44
+ );
45
+
46
+ export default AnchorAncestor;
@@ -3,6 +3,7 @@
3
3
  import React, { forwardRef } from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { useLocalizeHref } from '../hooks/use-localize-href';
6
+ import { AnchorAncestor, useInsideAnchor } from './interactive-ancestor';
6
7
 
7
8
  type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
8
9
 
@@ -28,18 +29,85 @@ type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
28
29
  * prefetching them is paid once per click by the shopper who actually
29
30
  * navigates. `prefetch` is still a prop: a caller that measured its own case
30
31
  * can pass `true`.
32
+ *
33
+ * INSIDE ANOTHER ANCHOR IT IS NOT AN ANCHOR (ZERO-5188). Every link in the
34
+ * package comes through here, so this is the one place that can refuse to
35
+ * nest one. See `components/interactive-ancestor.tsx` for why a nested anchor
36
+ * costs the whole document.
31
37
  */
32
38
  export const ThemeLink = forwardRef<HTMLAnchorElement, ThemeLinkProps>(
33
39
  function ThemeLink({ href, prefetch = false, ...rest }, ref) {
34
40
  const localize = useLocalizeHref();
41
+ const insideAnchor = useInsideAnchor();
42
+
43
+ const resolved = typeof href === 'string' ? localize(href) : href;
44
+
45
+ if (insideAnchor) {
46
+ const { target, rel, onClick, children, ...spanProps } = rest;
47
+
48
+ /**
49
+ * The destination survives as a real navigation, so the shopper still
50
+ * gets where they were going. `window.location` rather than the router:
51
+ * a hook here would make every block test that renders a link need an
52
+ * app-router context, and this path is the rare one — the OUTER link is
53
+ * the card a shopper aims at, this is the control nested inside it.
54
+ *
55
+ * Middle click, the context menu and crawling are the real loss. Both
56
+ * authored examples put something redundant here (a decorative icon
57
+ * with `href="#"`, a read-more pointing at the post the card already
58
+ * links to), so the outer anchor keeps the destination that matters.
59
+ */
60
+ const go = () => {
61
+ if (typeof window === 'undefined') return;
62
+
63
+ const to = typeof resolved === 'string' ? resolved : undefined;
64
+ if (!to || to === '#') return;
65
+
66
+ if (target === '_blank') {
67
+ window.open(to, '_blank', rel || 'noopener noreferrer');
68
+ return;
69
+ }
70
+
71
+ window.location.assign(to);
72
+ };
73
+
74
+ return (
75
+ <span
76
+ // Not `role="link"`: that would announce a link the keyboard cannot
77
+ // follow the way a link is followed, and it is already inside one.
78
+ // The element stays styleable and clickable, and the ancestor
79
+ // anchor still describes the destination.
80
+ ref={ref as unknown as React.Ref<HTMLSpanElement>}
81
+ data-nested-link=""
82
+ onClick={(event: React.MouseEvent<HTMLSpanElement>) => {
83
+ onClick?.(
84
+ event as unknown as React.MouseEvent<HTMLAnchorElement, MouseEvent>
85
+ );
86
+ if (event.defaultPrevented) return;
87
+ // The ancestor anchor would otherwise navigate to its own href.
88
+ event.preventDefault();
89
+ event.stopPropagation();
90
+ go();
91
+ }}
92
+ {...(spanProps as React.HTMLAttributes<HTMLSpanElement>)}
93
+ >
94
+ {children}
95
+ </span>
96
+ );
97
+ }
98
+
99
+ const { children, ...anchorProps } = rest;
35
100
 
36
101
  return (
37
- <NextLink
38
- ref={ref}
39
- href={typeof href === 'string' ? localize(href) : href}
40
- prefetch={prefetch}
41
- {...rest}
42
- />
102
+ <NextLink ref={ref} href={resolved} prefetch={prefetch} {...anchorProps}>
103
+ {/*
104
+ * Both sides of the rule live here, which is why there is no
105
+ * per-block contract to keep: this is the only element in the
106
+ * package that is a real anchor, so it is also the only one that has
107
+ * to tell its subtree one is open.
108
+ */}
109
+ <AnchorAncestor>{children}</AnchorAncestor>
110
+ </NextLink>
43
111
  );
44
112
  }
45
113
  );