@astryxdesign/core 0.6.3-canary.98a2e4e → 0.6.3-canary.9e4c545

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 (51) hide show
  1. package/dist/Citation/Citation.d.ts +1 -1
  2. package/dist/Citation/Citation.d.ts.map +1 -1
  3. package/dist/Citation/Citation.js +3 -2
  4. package/dist/CodeBlock/highlightRanges.d.ts.map +1 -1
  5. package/dist/CodeBlock/highlightRanges.js +14 -3
  6. package/dist/Link/useLinkComponent.d.ts +5 -15
  7. package/dist/Link/useLinkComponent.d.ts.map +1 -1
  8. package/dist/Link/useLinkComponent.js +34 -51
  9. package/dist/Markdown/Markdown.js +2 -2
  10. package/dist/Markdown/url.d.ts +2 -0
  11. package/dist/Markdown/url.d.ts.map +1 -1
  12. package/dist/Markdown/url.js +8 -2
  13. package/dist/TopNav/TopNavMenu.d.ts +1 -1
  14. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  15. package/dist/TopNav/TopNavMenu.js +3 -2
  16. package/dist/hooks/useClickableContainer.d.ts +10 -1
  17. package/dist/hooks/useClickableContainer.d.ts.map +1 -1
  18. package/dist/hooks/useClickableContainer.js +15 -4
  19. package/dist/utils/safeUrl.d.ts +11 -0
  20. package/dist/utils/safeUrl.d.ts.map +1 -0
  21. package/dist/utils/safeUrl.js +78 -0
  22. package/package.json +3 -3
  23. package/src/Button/Button.doc.mjs +1 -1
  24. package/src/Citation/Citation.doc.mjs +2 -2
  25. package/src/Citation/Citation.test.tsx +17 -0
  26. package/src/Citation/Citation.tsx +4 -2
  27. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  28. package/src/CodeBlock/highlightRanges.test.ts +143 -1
  29. package/src/CodeBlock/highlightRanges.ts +15 -5
  30. package/src/Item/Item.doc.mjs +2 -2
  31. package/src/Link/Link.doc.mjs +6 -4
  32. package/src/Link/LinkProvider.doc.mjs +2 -2
  33. package/src/Link/__tests__/Link.navigation.a11y.chromium.spec.ts +261 -0
  34. package/src/Link/useLinkComponent.test.tsx +333 -0
  35. package/src/Link/useLinkComponent.ts +29 -57
  36. package/src/List/ListItem.doc.mjs +2 -2
  37. package/src/Markdown/Markdown.doc.mjs +2 -2
  38. package/src/Markdown/Markdown.renderBoundary.test.tsx +72 -0
  39. package/src/Markdown/Markdown.spec.md +19 -15
  40. package/src/Markdown/Markdown.test.tsx +52 -0
  41. package/src/Markdown/Markdown.tsx +2 -2
  42. package/src/Markdown/parser.test.ts +20 -0
  43. package/src/Markdown/url.ts +9 -3
  44. package/src/Token/Token.doc.mjs +2 -2
  45. package/src/TopNav/TopNavMenu.doc.mjs +2 -2
  46. package/src/TopNav/TopNavMenu.test.tsx +30 -0
  47. package/src/TopNav/TopNavMenu.tsx +7 -2
  48. package/src/hooks/useClickableContainer.test.tsx +236 -0
  49. package/src/hooks/useClickableContainer.ts +20 -4
  50. package/src/utils/safeUrl.test.ts +88 -0
  51. package/src/utils/safeUrl.ts +85 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file Citation.tsx
3
- * @input Uses React, StyleX, theme tokens
3
+ * @input Uses React, StyleX, theme tokens, and the shared navigation policy
4
4
  * @output Exports Citation component for inline citation references
5
5
  * @position Core implementation; consumed by index.ts
6
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"Citation.d.ts","sourceRoot":"","sources":["../../src/Citation/Citation.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAarC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAK5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAED,MAAM,WAAW,aAAc,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC3D,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC9B;AAiHD,wBAAgB,QAAQ,CAAC,EACvB,GAAG,EACH,MAAM,EACN,MAAM,EACN,OAAiB,EACjB,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,IAAI,EACR,EAAE,aAAa,GAAG,KAAK,CAAC,YAAY,CA2FpC;yBArGe,QAAQ"}
1
+ {"version":3,"file":"Citation.d.ts","sourceRoot":"","sources":["../../src/Citation/Citation.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAcrC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAK5C,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAED,MAAM,WAAW,aAAc,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC3D,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC9B;AAiHD,wBAAgB,QAAQ,CAAC,EACvB,GAAG,EACH,MAAM,EACN,MAAM,EACN,OAAiB,EACjB,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,IAAI,EACR,EAAE,aAAa,GAAG,KAAK,CAAC,YAAY,CA4FpC;yBAtGe,QAAQ"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Citation.tsx
7
- * @input Uses React, StyleX, theme tokens
7
+ * @input Uses React, StyleX, theme tokens, and the shared navigation policy
8
8
  * @output Exports Citation component for inline citation references
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
@@ -19,6 +19,7 @@ import * as stylex from '@stylexjs/stylex';
19
19
  import "../theme/tokens.stylex.js";
20
20
  import { colorVars, spacingVars, radiusVars, typeScaleVars, fontWeightVars, borderVars, durationVars, easeVars } from "../theme/tokens.stylex.js";
21
21
  import { mergeProps } from "../utils/index.js";
22
+ import { isSafeUrl } from "../utils/safeUrl.js";
22
23
  import { themeProps } from "../utils/themeProps.js";
23
24
  import { useTranslator } from "../i18n/index.js";
24
25
  import { renderIconSlot } from "../Icon/index.js";
@@ -103,7 +104,7 @@ export function Citation({
103
104
  }) {
104
105
  const t = useTranslator();
105
106
  const title = source.title ?? String(number);
106
- const href = source.url;
107
+ const href = source.url != null && isSafeUrl(source.url) ? source.url : undefined;
107
108
 
108
109
  // Resolve the source icon. A non-string `icon` node renders as-is (an Astryx
109
110
  // <Icon>, SVG, avatar, etc.). Otherwise fall back to an image URL: `src`, or
@@ -1 +1 @@
1
- {"version":3,"file":"highlightRanges.d.ts","sourceRoot":"","sources":["../../src/CodeBlock/highlightRanges.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAc,SAAS,EAAC,MAAM,aAAa,CAAC;AA8FxD,UAAU,UAAU;IAClB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;CACtB;AAgDD,iBAAS,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,CAIjD;AA4BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,SAAS,EAAE,GACtB,MAAM,IAAI,CAsHZ;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,SAAS,EAAE,EACvB,SAAS,EAAE,MAAM,GAChB,UAAU,EAAE,CAqBd;AAMD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,WAAW,EACf,UAAU,EAAE,SAAS,EAAE,GACtB,MAAM,IAAI,CA2DZ;AAeD;;GAEG;AACH,OAAO,EAAC,aAAa,EAAC,CAAC"}
1
+ {"version":3,"file":"highlightRanges.d.ts","sourceRoot":"","sources":["../../src/CodeBlock/highlightRanges.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAc,SAAS,EAAC,MAAM,aAAa,CAAC;AAwGxD,UAAU,UAAU;IAClB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;CACtB;AAgDD,iBAAS,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,CAIjD;AA4BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,SAAS,EAAE,GACtB,MAAM,IAAI,CAsHZ;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,SAAS,EAAE,EACvB,SAAS,EAAE,MAAM,GAChB,UAAU,EAAE,CAqBd;AAMD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,WAAW,EACf,UAAU,EAAE,SAAS,EAAE,GACtB,MAAM,IAAI,CA2DZ;AAeD;;GAEG;AACH,OAAO,EAAC,aAAa,EAAC,CAAC"}
@@ -52,9 +52,20 @@ function ensureDynamicHighlightType(tokenType) {
52
52
  return;
53
53
  }
54
54
  }
55
- const name = `astryx-${tokenType}`;
56
- const colorVar = `var(--color-syntax-${tokenType}, currentColor)`;
57
- dynamicStyleSheet.insertRule(`.astryx-code-block code::highlight(${name}), .astryx-codeeditor code::highlight(${name}) { color: ${colorVar}; }`);
55
+
56
+ // CSS.escape both generated names before they land in rule text: the
57
+ // registry key stays the raw string (a ::highlight() selector matches by
58
+ // ident VALUE, escapes and all), so dotted, non-ASCII, `_private`, and
59
+ // digit-led token types keep their colours — while nothing a token stream
60
+ // carries can step outside its ident and shape the rule.
61
+ const name = CSS.escape(`astryx-${tokenType}`);
62
+ const colorVar = `var(${CSS.escape(`--color-syntax-${tokenType}`)}, currentColor)`;
63
+ try {
64
+ dynamicStyleSheet.insertRule(`.astryx-code-block code::highlight(${name}), .astryx-codeeditor code::highlight(${name}) { color: ${colorVar}; }`);
65
+ } catch {
66
+ // An engine that still refuses the rule costs that type its colour
67
+ // (ranges paint with currentColor) — never the whole code block.
68
+ }
58
69
  }
59
70
 
60
71
  // ---------------------------------------------------------------------------
@@ -1,23 +1,13 @@
1
1
  import type { LinkComponentType } from './types';
2
2
  /**
3
- * Resolves the link component to use.
4
- *
5
- * Priority: `as` prop > `LinkProvider` context > native `<a>`.
6
- *
7
- * When the resolved component is a custom component (not the native `<a>`),
8
- * it is wrapped to receive both `href` and `to` props set to the same value.
9
- * This allows `to`-based routers (React Router, TanStack Router) to work
10
- * out of the box without a manual adapter.
11
- *
12
- * @param as - Per-component override. If provided, takes highest priority.
13
- * @returns The resolved link component (with `to` injection for custom components).
3
+ * Resolve `as`, then LinkProvider, then a native anchor. Both destination props
4
+ * are checked before rendering; accepted structured values retain identity.
5
+ * Custom components receive `to={href}` unless an explicit `to` is supplied.
14
6
  *
15
7
  * @example
16
8
  * ```
17
- * function MyComponent({ as }: { as?: LinkComponentType }) {
18
- * const LinkComponent = useLinkComponent(as);
19
- * return <LinkComponent href="/foo">Click me</LinkComponent>;
20
- * }
9
+ * const LinkComponent = useLinkComponent(as);
10
+ * return <LinkComponent href="/docs">Documentation</LinkComponent>;
21
11
  * ```
22
12
  */
23
13
  export declare function useLinkComponent(as?: LinkComponentType): LinkComponentType;
@@ -1 +1 @@
1
- {"version":3,"file":"useLinkComponent.d.ts","sourceRoot":"","sources":["../../src/Link/useLinkComponent.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,SAAS,CAAC;AAgC/C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,CAAC,EAAE,iBAAiB,GACrB,iBAAiB,CAYnB"}
1
+ {"version":3,"file":"useLinkComponent.d.ts","sourceRoot":"","sources":["../../src/Link/useLinkComponent.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,SAAS,CAAC;AA+B/C;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,CAAC,EAAE,iBAAiB,GAAG,iBAAiB,CAI1E"}
@@ -4,79 +4,62 @@
4
4
 
5
5
  /**
6
6
  * @file useLinkComponent.ts
7
- * @input React use, useMemo, createElement, forwardRef, LinkContext, LinkComponentType
8
- * @output Exports useLinkComponent hook
9
- * @position Hook for resolving the link component in Astryx components
7
+ * @input Per-component override, LinkProvider context, and navigation props
8
+ * @output Stable link component with destination checks and custom-router `to` injection
9
+ * @position Shared native and custom link boundary; rejected destinations render
10
+ * as destination-less anchors without invoking a router.
10
11
  *
11
- * Resolution order: per-component `as` prop > LinkProvider context > native `<a>`.
12
- *
13
- * When the resolved component is a custom component (not native `<a>`),
14
- * wraps it to pass `to={href}` alongside `href`. This enables compatibility
15
- * with routers that use `to` (React Router, TanStack Router)
16
- * without requiring an adapter component.
17
- *
18
- * SYNC: When modified, update these files to stay in sync:
19
- * - /packages/core/src/Link/index.ts
20
- * - /packages/core/src/Link/Link.doc.mjs
12
+ * SYNC: Link.doc.mjs and LinkProvider.doc.mjs describe this boundary.
21
13
  */
22
14
  import { use, useMemo, createElement } from 'react';
23
15
  import { LinkContext } from "./LinkContext.js";
24
- /**
25
- * Creates a wrapper component that passes both `href` and `to` props
26
- * to the underlying link component. This enables routers that use `to`
27
- * (React Router, TanStack Router) to work without an adapter.
28
- *
29
- * The wrapper is transparent: it forwards refs and all other props unchanged.
30
- * Native `<a>` elements ignore the unknown `to` prop harmlessly.
31
- */
32
- function createLinkWithTo(Component) {
33
- function LinkWithTo({
16
+ import { isSafeDestination } from "../utils/safeUrl.js";
17
+ function createSafeLink(Component) {
18
+ function SafeLink({
34
19
  href,
20
+ to,
35
21
  ref,
36
22
  ...rest
37
23
  }) {
24
+ // An undefined destination can crash a router or resolve to the current
25
+ // route. Reject the whole handoff, not just one prop or a fallback value.
26
+ if (!isSafeDestination(href) || !isSafeDestination(to)) {
27
+ return /*#__PURE__*/createElement('a', {
28
+ ref,
29
+ ...rest
30
+ });
31
+ }
32
+ if (Component === 'a') {
33
+ return /*#__PURE__*/createElement('a', {
34
+ ref,
35
+ ...rest,
36
+ href
37
+ });
38
+ }
38
39
  return /*#__PURE__*/createElement(Component, {
39
40
  ref,
41
+ ...rest,
40
42
  href,
41
- to: href,
42
- ...rest
43
+ to: to ?? href
43
44
  });
44
45
  }
45
- LinkWithTo.displayName = `LinkWithTo(${typeof Component === 'string' ? Component : Component.displayName || Component.name || 'Component'})`;
46
- return LinkWithTo;
46
+ SafeLink.displayName = `SafeLink(${typeof Component === 'string' ? Component : Component.displayName || Component.name || 'Component'})`;
47
+ return SafeLink;
47
48
  }
48
49
 
49
50
  /**
50
- * Resolves the link component to use.
51
- *
52
- * Priority: `as` prop > `LinkProvider` context > native `<a>`.
53
- *
54
- * When the resolved component is a custom component (not the native `<a>`),
55
- * it is wrapped to receive both `href` and `to` props set to the same value.
56
- * This allows `to`-based routers (React Router, TanStack Router) to work
57
- * out of the box without a manual adapter.
58
- *
59
- * @param as - Per-component override. If provided, takes highest priority.
60
- * @returns The resolved link component (with `to` injection for custom components).
51
+ * Resolve `as`, then LinkProvider, then a native anchor. Both destination props
52
+ * are checked before rendering; accepted structured values retain identity.
53
+ * Custom components receive `to={href}` unless an explicit `to` is supplied.
61
54
  *
62
55
  * @example
63
56
  * ```
64
- * function MyComponent({ as }: { as?: LinkComponentType }) {
65
- * const LinkComponent = useLinkComponent(as);
66
- * return <LinkComponent href="/foo">Click me</LinkComponent>;
67
- * }
57
+ * const LinkComponent = useLinkComponent(as);
58
+ * return <LinkComponent href="/docs">Documentation</LinkComponent>;
68
59
  * ```
69
60
  */
70
61
  export function useLinkComponent(as) {
71
62
  const ctx = use(LinkContext);
72
63
  const resolved = as ?? ctx?.component ?? 'a';
73
-
74
- // Memoize the wrapper to maintain referential stability.
75
- // The wrapper is only created for custom components (not native <a>).
76
- return useMemo(() => {
77
- if (resolved === 'a') {
78
- return 'a';
79
- }
80
- return createLinkWithTo(resolved);
81
- }, [resolved]);
64
+ return useMemo(() => createSafeLink(resolved), [resolved]);
82
65
  }
@@ -35,7 +35,7 @@ import { parseInlineAst, parseMarkdownAst, parseMarkdownAstIncremental, createIn
35
35
  import { getMarkdownAstLegacyCodeLanguage, markdownAstText } from "./ast.js";
36
36
  import { applyMarkdownTransforms, getMarkdownExtensionRenderer, markdownExtensionText, prepareMarkdownPlugins, reportMarkdownPluginFailure } from "./plugins/protocol.js";
37
37
  import { getMarkdownFenceProposal } from "./plugins/semanticFence.js";
38
- import { sanitizeMarkdownUrl } from "./url.js";
38
+ import { sanitizeMarkdownLinkUrl, sanitizeMarkdownUrl } from "./url.js";
39
39
  import { themeProps } from "../utils/themeProps.js";
40
40
  import { useTranslator } from "../i18n/index.js";
41
41
 
@@ -668,7 +668,7 @@ function renderInline(node, index, onLinkClick, cursor, citationCtx, linkCompone
668
668
  }
669
669
  case 'link':
670
670
  {
671
- const safeHref = sanitizeMarkdownUrl(node.url);
671
+ const safeHref = sanitizeMarkdownLinkUrl(node.url);
672
672
  if (safeHref == null) {
673
673
  // Unsafe URL — render as plain text
674
674
  return /*#__PURE__*/_jsx("span", {
@@ -1,4 +1,6 @@
1
1
  /** Preserve the released parser policy for no-plugin output compatibility. */
2
2
  export declare function isSafeMarkdownParserUrl(url: string): boolean;
3
+ /** Navigation and image rendering deliberately have different policies. */
4
+ export declare function sanitizeMarkdownLinkUrl(url: string): string | null;
3
5
  export declare function sanitizeMarkdownUrl(url: string): string | null;
4
6
  //# sourceMappingURL=url.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"url.d.ts","sourceRoot":"","sources":["../../src/Markdown/url.ts"],"names":[],"mappings":"AAmBA,8EAA8E;AAC9E,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE5D;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK9D"}
1
+ {"version":3,"file":"url.d.ts","sourceRoot":"","sources":["../../src/Markdown/url.ts"],"names":[],"mappings":"AAmBA,8EAA8E;AAC9E,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE5D;AAED,2EAA2E;AAC3E,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGlE;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK9D"}
@@ -7,7 +7,7 @@
7
7
  * @position Shared URL policy for parser, transforms, and renderer
8
8
  */
9
9
 
10
- const PARSER_DANGEROUS_URL_PATTERN = /^(?:javascript:|data:text\/html|vbscript:)/i;
10
+ import { isSafeUrl, sanitizeUrl } from "../utils/safeUrl.js";
11
11
  const RENDER_DANGEROUS_URL_PATTERN = /^(?:javascript:|data:|vbscript:)/i;
12
12
  function normalizeMarkdownUrl(url) {
13
13
  // Browsers ignore embedded controls in schemes, so normalize before checking.
@@ -17,7 +17,13 @@ function normalizeMarkdownUrl(url) {
17
17
 
18
18
  /** Preserve the released parser policy for no-plugin output compatibility. */
19
19
  export function isSafeMarkdownParserUrl(url) {
20
- return !PARSER_DANGEROUS_URL_PATTERN.test(normalizeMarkdownUrl(url));
20
+ return isSafeUrl(url);
21
+ }
22
+
23
+ /** Navigation and image rendering deliberately have different policies. */
24
+ export function sanitizeMarkdownLinkUrl(url) {
25
+ const normalized = sanitizeUrl(url);
26
+ return normalized === '' ? null : normalized;
21
27
  }
22
28
  export function sanitizeMarkdownUrl(url) {
23
29
  const normalized = normalizeMarkdownUrl(url);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file TopNavMenu.tsx
3
3
  * @input Uses React, StyleX, usePopover, useMenuHover, useListFocus,
4
- * useTypeahead, TopNavItem tokens
4
+ * useTypeahead, TopNavItem tokens, and the shared navigation policy
5
5
  * @output Exports TopNavMenu component and related types
6
6
  * @position Navigation item with hover-triggered overflow menu for TopNav
7
7
  *
@@ -1 +1 @@
1
- {"version":3,"file":"TopNavMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AASf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqM5C;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAMD,MAAM,WAAW,eAAgB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACnE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,KAAK,EAAE,kBAAkB,EAAE,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,wBAAgB,UAAU,CAAC,EACzB,GAAG,EACH,KAAK,EACL,KAAK,EACL,KAAW,EACX,SAAe,EACf,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,YAAY,EAAE,gBAAgB,EAC9B,GAAG,IAAI,EACR,EAAE,eAAe,4BAqQjB;yBAlRe,UAAU"}
1
+ {"version":3,"file":"TopNavMenu.d.ts","sourceRoot":"","sources":["../../src/TopNav/TopNavMenu.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AASf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAsM5C;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAMD,MAAM,WAAW,eAAgB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACnE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,KAAK,EAAE,kBAAkB,EAAE,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,wBAAgB,UAAU,CAAC,EACzB,GAAG,EACH,KAAK,EACL,KAAK,EACL,KAAW,EACX,SAAe,EACf,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,YAAY,EAAE,gBAAgB,EAC9B,GAAG,IAAI,EACR,EAAE,eAAe,4BAyQjB;yBAtRe,UAAU"}
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @file TopNavMenu.tsx
7
7
  * @input Uses React, StyleX, usePopover, useMenuHover, useListFocus,
8
- * useTypeahead, TopNavItem tokens
8
+ * useTypeahead, TopNavItem tokens, and the shared navigation policy
9
9
  * @output Exports TopNavMenu component and related types
10
10
  * @position Navigation item with hover-triggered overflow menu for TopNav
11
11
  *
@@ -29,6 +29,7 @@ import { useTopNavSlot } from "./TopNavContext.js";
29
29
  import { useTopNavRenderMode } from "./TopNavRenderContext.js";
30
30
  import { useAppShellMobile } from "../AppShell/AppShellMobileContext.js";
31
31
  import { useLinkComponent } from "../Link/useLinkComponent.js";
32
+ import { isSafeUrl } from "../utils/safeUrl.js";
32
33
  import { themeProps } from "../utils/themeProps.js";
33
34
  import { focusOutlineProps } from "../utils/focusOutline.stylex.js";
34
35
  import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js";
@@ -378,7 +379,7 @@ export function TopNavMenu({
378
379
  // promotes exactly one item to 0.
379
380
  ,
380
381
  tabIndex: -1,
381
- href: item.href,
382
+ href: item.href != null && isSafeUrl(item.href) ? item.href : undefined,
382
383
  onClick: item.onClick,
383
384
  ...focusOutlineProps.focusVisible(styles.menuItem),
384
385
  children: [/*#__PURE__*/_jsx("div", {
@@ -7,6 +7,11 @@
7
7
  * Solves the "nested interactive elements" problem: when a card is clickable
8
8
  * but contains buttons/links, clicking those should NOT trigger the card's action.
9
9
  *
10
+ * Every navigation exit here (window.open, location.href, and the delegated
11
+ * click on the interactive ref) runs after the shared navigation-destination
12
+ * rule in utils/safeUrl.ts; a blocked href does not navigate by any
13
+ * activation method.
14
+ *
10
15
  * SYNC: When modified, update:
11
16
  * - /packages/core/src/hooks/index.ts (export)
12
17
  */
@@ -30,7 +35,11 @@ export interface UseClickableContainerOptions {
30
35
  interactiveRef?: RefObject<HTMLElement | null>;
31
36
  /** Click handler */
32
37
  onClick?: (event: MouseEvent<HTMLElement>) => void;
33
- /** Navigation URL — when provided, clicking the container navigates */
38
+ /**
39
+ * Navigation URL — when provided, clicking the container navigates. Checked
40
+ * with the shared navigation rule (utils/safeUrl.ts) before any activation
41
+ * method (plain, new-tab, Cmd/Ctrl-click, middle-click, delegated click).
42
+ */
34
43
  href?: string;
35
44
  /** Link target */
36
45
  target?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"useClickableContainer.d.ts","sourceRoot":"","sources":["../../src/hooks/useClickableContainer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAC,MAAM,OAAO,CAAC;AAE9E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAmBvB,CAAC;AAmCZ,MAAM,WAAW,4BAA4B;IAC3C,yCAAyC;IACzC,YAAY,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC5C,mEAAmE;IACnE,cAAc,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC/C,oBAAoB;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IACnD,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kBAAkB;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IAClD,SAAS,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,YAAY,EACZ,cAAc,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EACJ,MAAM,EACN,QAAgB,GACjB,EAAE,4BAA4B,GAAG,wBAAwB,CA6GzD"}
1
+ {"version":3,"file":"useClickableContainer.d.ts","sourceRoot":"","sources":["../../src/hooks/useClickableContainer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAC,MAAM,OAAO,CAAC;AAG9E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAmBvB,CAAC;AAmCZ,MAAM,WAAW,4BAA4B;IAC3C,yCAAyC;IACzC,YAAY,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC5C,mEAAmE;IACnE,cAAc,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC/C,oBAAoB;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IACnD;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kBAAkB;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IAClD,SAAS,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,YAAY,EACZ,cAAc,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EACJ,MAAM,EACN,QAAgB,GACjB,EAAE,4BAA4B,GAAG,wBAAwB,CAmHzD"}
@@ -11,10 +11,16 @@
11
11
  * Solves the "nested interactive elements" problem: when a card is clickable
12
12
  * but contains buttons/links, clicking those should NOT trigger the card's action.
13
13
  *
14
+ * Every navigation exit here (window.open, location.href, and the delegated
15
+ * click on the interactive ref) runs after the shared navigation-destination
16
+ * rule in utils/safeUrl.ts; a blocked href does not navigate by any
17
+ * activation method.
18
+ *
14
19
  * SYNC: When modified, update:
15
20
  * - /packages/core/src/hooks/index.ts (export)
16
21
  */
17
22
  import { useCallback, useEffect } from 'react';
23
+ import { isSafeUrl } from "../utils/safeUrl.js";
18
24
 
19
25
  /**
20
26
  * Canonical list of interactive element selectors — native controls plus
@@ -128,8 +134,12 @@ export function useClickableContainer({
128
134
  return;
129
135
  }
130
136
 
131
- // Navigate if href is provided
132
- if (href != null) {
137
+ // Navigate if href is provided. React DOM vets the hrefs it writes as
138
+ // attributes; nothing below passes through React, so the shared
139
+ // navigation rule decides once, before target, modifier key, or the
140
+ // delegated anchor can select a sink. A blocked href simply does not
141
+ // navigate — the click handler above still ran.
142
+ if (href != null && isSafeUrl(href)) {
133
143
  const shouldOpenNewTab = target === '_blank' || event.ctrlKey || event.metaKey;
134
144
  if (shouldOpenNewTab) {
135
145
  window.open(href, '_blank', 'noopener');
@@ -171,9 +181,10 @@ export function useClickableContainer({
171
181
  return;
172
182
  }
173
183
 
174
- // Middle-click on href opens in new tab
184
+ // Middle-click on href opens in new tab (same scheme rule as onClick —
185
+ // this navigation never passes through React either).
175
186
  const isMiddleClick = event.button === 1;
176
- if (isMiddleClick && href != null && (eventTarget === event.currentTarget || !hasInteractiveAncestor(eventTarget, containerEl))) {
187
+ if (isMiddleClick && href != null && isSafeUrl(href) && (eventTarget === event.currentTarget || !hasInteractiveAncestor(eventTarget, containerEl))) {
177
188
  window.open(href, '_blank', 'noopener');
178
189
  }
179
190
  }, [containerRef, href, disabled]);
@@ -0,0 +1,11 @@
1
+ /** Return a normalized navigation URL, or null for a blocked scheme. */
2
+ export declare function sanitizeUrl(url: string): string | null;
3
+ /** Inspect a navigation string without changing the value used by its sink. */
4
+ export declare function isSafeUrl(url: string): boolean;
5
+ /**
6
+ * Inspect React Router paths, Next.js URL objects, and URL instances without
7
+ * serializing or replacing the object handed to the router. Search/query/hash
8
+ * are route-relative data, not schemes. Absent destinations remain absent.
9
+ */
10
+ export declare function isSafeDestination(destination: unknown): boolean;
11
+ //# sourceMappingURL=safeUrl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safeUrl.d.ts","sourceRoot":"","sources":["../../src/utils/safeUrl.ts"],"names":[],"mappings":"AAcA,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWtD;AAED,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,OAAO,GAAG,OAAO,CA8C/D"}
@@ -0,0 +1,78 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file safeUrl.ts
5
+ * @input Navigation strings or supported structured router destinations
6
+ * @output The shared blocked-scheme decision, without rewriting accepted inputs
7
+ * @position Core navigation policy; embedded-resource policy is separate
8
+ */
9
+
10
+ function normalizeUrl(url) {
11
+ // eslint-disable-next-line no-control-regex -- inspect schemes without browser-ignored controls
12
+ return url.replace(/[\x00-\x1f\x7f]/g, '').trim();
13
+ }
14
+
15
+ /** Return a normalized navigation URL, or null for a blocked scheme. */
16
+ export function sanitizeUrl(url) {
17
+ const normalized = normalizeUrl(url);
18
+ const lower = normalized.toLowerCase();
19
+ if (lower.startsWith('javascript:') || lower.startsWith('vbscript:') || lower.startsWith('data:text/html')) {
20
+ return null;
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ /** Inspect a navigation string without changing the value used by its sink. */
26
+ export function isSafeUrl(url) {
27
+ return sanitizeUrl(url) !== null;
28
+ }
29
+
30
+ /**
31
+ * Inspect React Router paths, Next.js URL objects, and URL instances without
32
+ * serializing or replacing the object handed to the router. Search/query/hash
33
+ * are route-relative data, not schemes. Absent destinations remain absent.
34
+ */
35
+ export function isSafeDestination(destination) {
36
+ if (destination == null) {
37
+ return true;
38
+ }
39
+ if (typeof destination === 'string') {
40
+ return isSafeUrl(destination);
41
+ }
42
+ if (typeof destination !== 'object') {
43
+ return false;
44
+ }
45
+ const {
46
+ pathname,
47
+ href,
48
+ protocol,
49
+ host,
50
+ hostname
51
+ } = destination;
52
+ for (const field of [pathname, href, protocol, host, hostname]) {
53
+ if (field != null && typeof field !== 'string') {
54
+ return false;
55
+ }
56
+ }
57
+ if (typeof pathname === 'string' && !isSafeUrl(pathname)) {
58
+ return false;
59
+ }
60
+ if (typeof href === 'string' && !isSafeUrl(href)) {
61
+ return false;
62
+ }
63
+ if (typeof protocol === 'string' && protocol !== '') {
64
+ // URL-object formatters accept a protocol with or without its colon and
65
+ // can assemble a data media type from the host and pathname fields.
66
+ const normalizedProtocol = normalizeUrl(protocol);
67
+ const prefix = normalizedProtocol.endsWith(':') ? normalizedProtocol : `${normalizedProtocol}:`;
68
+ if (!isSafeUrl(prefix)) {
69
+ return false;
70
+ }
71
+ const body = host || hostname || '';
72
+ const path = typeof pathname === 'string' ? pathname : '';
73
+ if (!isSafeUrl(`${prefix}${body}${path}`)) {
74
+ return false;
75
+ }
76
+ }
77
+ return true;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.6.3-canary.98a2e4e",
3
+ "version": "0.6.3-canary.9e4c545",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -692,8 +692,8 @@
692
692
  "react-dom": ">=19.0.0"
693
693
  },
694
694
  "devDependencies": {
695
- "@astryxdesign/a11y-spec": "0.6.3-canary.98a2e4e",
696
- "@astryxdesign/cli": "0.6.3-canary.98a2e4e",
695
+ "@astryxdesign/a11y-spec": "0.6.3-canary.9e4c545",
696
+ "@astryxdesign/cli": "0.6.3-canary.9e4c545",
697
697
  "@babel/cli": "^7.29.7",
698
698
  "@babel/core": "^7.29.7",
699
699
  "@babel/preset-react": "^7.29.7",
@@ -210,7 +210,7 @@ export const docs = {
210
210
  name: 'href',
211
211
  type: 'string',
212
212
  description:
213
- 'When provided, renders the button as a link element (<a> or custom link component).',
213
+ 'When provided, renders the button as a link element (<a> or custom link component). The destination follows the shared navigation rule described on the Link `href` prop.',
214
214
  },
215
215
  {
216
216
  name: 'as',
@@ -71,7 +71,7 @@ export const docs = {
71
71
  name: 'source',
72
72
  type: 'CitationSource',
73
73
  description:
74
- 'The citation source object containing title, url, an optional image src, and an optional icon node.',
74
+ 'The citation source object containing title, url, an optional image src, and an optional icon node. The url follows the shared navigation rule described on the Link `href` prop; rejected destinations leave the citation visible without navigation. Image src uses separate resource handling.',
75
75
  required: true,
76
76
  },
77
77
  {
@@ -122,7 +122,7 @@ export const docsDense = {
122
122
  },
123
123
  propDescriptions: {
124
124
  source:
125
- 'citation source object with title, url, optional image src, and optional icon node.',
125
+ 'citation source with title, url, optional image src, and optional icon. url follows the Link href navigation rule; rejected destinations stay visible without navigation. Image src handling is separate.',
126
126
  number: 'display index for this citation.',
127
127
  variant: 'display style: label chip with source title or compact numbered badge.',
128
128
  },
@@ -36,6 +36,23 @@ function atomicClasses(style: (typeof probe)[keyof typeof probe]): string[] {
36
36
  describe('Citation', () => {
37
37
  const source = {title: 'Example Source', url: 'https://example.com'};
38
38
 
39
+ it.each([
40
+ 'javascript:alert(1)',
41
+ 'vbscript:MsgBox(1)',
42
+ 'data:text/html,<b>x</b>',
43
+ 'java\nscript:alert(1)',
44
+ ])('renders rejected citation URL %s without navigation', url => {
45
+ const {container} = render(
46
+ <>
47
+ <Citation source={{title: 'Source', url}} number={1} />
48
+ <Citation source={{title: 'Source', url}} number={1} variant="number" />
49
+ </>,
50
+ );
51
+ expect(container.querySelector('a')).toBeNull();
52
+ expect(container.querySelector('[href]')).toBeNull();
53
+ expect(container.textContent).toBe('Source1');
54
+ });
55
+
39
56
  it('renders the source title as a link in the label variant', () => {
40
57
  render(<Citation source={source} number={1} data-testid="citation" />);
41
58
  const el = screen.getByTestId('citation');
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Citation.tsx
7
- * @input Uses React, StyleX, theme tokens
7
+ * @input Uses React, StyleX, theme tokens, and the shared navigation policy
8
8
  * @output Exports Citation component for inline citation references
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
@@ -30,6 +30,7 @@ import {
30
30
  easeVars,
31
31
  } from '../theme/tokens.stylex';
32
32
  import {mergeProps} from '../utils';
33
+ import {isSafeUrl} from '../utils/safeUrl';
33
34
  import type {BaseProps} from '../BaseProps';
34
35
  import {themeProps} from '../utils/themeProps';
35
36
  import {useTranslator} from '../i18n';
@@ -189,7 +190,8 @@ export function Citation({
189
190
  }: CitationProps): React.ReactElement {
190
191
  const t = useTranslator();
191
192
  const title = source.title ?? String(number);
192
- const href = source.url;
193
+ const href =
194
+ source.url != null && isSafeUrl(source.url) ? source.url : undefined;
193
195
 
194
196
  // Resolve the source icon. A non-string `icon` node renders as-is (an Astryx
195
197
  // <Icon>, SVG, avatar, etc.). Otherwise fall back to an image URL: `src`, or
@@ -22,7 +22,7 @@ export const docs = {
22
22
  props: [
23
23
  {name: 'label', type: 'string', description: 'Accessibility label.', required: true},
24
24
  {name: 'onClick', type: '(event: MouseEvent) => void', description: 'Click handler: fires on card surface only.'},
25
- {name: 'href', type: 'string', description: 'Navigation URL.'},
25
+ {name: 'href', type: 'string', description: 'Navigation URL. Plain, new-tab, Cmd/Ctrl-click, and middle-click activation all follow the shared navigation rule described on the Link `href` prop.'},
26
26
  {name: 'target', type: 'string', description: 'Link target.', default: "'_self'"},
27
27
  {name: 'isDisabled', type: 'boolean', description: 'Disables the card.', default: 'false'},
28
28
  {name: 'children', type: 'ReactNode', description: 'Card content.'},
@@ -69,7 +69,7 @@ export const docsDense = {
69
69
  propDescriptions: {
70
70
  label: 'accessibility label',
71
71
  onClick: 'click handler: fires on card surface only',
72
- href: 'navigation URL',
72
+ href: 'navigation URL; every activation follows the shared navigation rule (see Link href)',
73
73
  target: 'link target',
74
74
  isDisabled: 'disables card',
75
75
  padding: 'inner padding',