@astryxdesign/core 0.6.3-canary.f22695a → 0.6.3
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/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +28 -13
- package/dist/Chat/ChatLayout.d.ts +0 -19
- package/dist/Chat/ChatLayout.d.ts.map +1 -1
- package/dist/Chat/ChatLayout.js +0 -19
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +34 -44
- package/dist/Citation/Citation.d.ts +1 -1
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +2 -3
- package/dist/CodeBlock/highlightRanges.d.ts.map +1 -1
- package/dist/CodeBlock/highlightRanges.js +3 -14
- package/dist/Icon/Icon.d.ts +31 -6
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +71 -8
- package/dist/Link/useLinkComponent.d.ts +15 -5
- package/dist/Link/useLinkComponent.d.ts.map +1 -1
- package/dist/Link/useLinkComponent.js +51 -34
- package/dist/Markdown/Markdown.js +2 -2
- package/dist/Markdown/url.d.ts +0 -2
- package/dist/Markdown/url.d.ts.map +1 -1
- package/dist/Markdown/url.js +2 -8
- package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchValueEditor.js +0 -5
- package/dist/TopNav/TopNavMenu.d.ts +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +2 -3
- package/dist/astryx.css +4 -2
- package/dist/hooks/useClickableContainer.d.ts +1 -10
- package/dist/hooks/useClickableContainer.d.ts.map +1 -1
- package/dist/hooks/useClickableContainer.js +4 -15
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/theme/generateThemeRules.d.ts +7 -13
- package/dist/theme/generateThemeRules.d.ts.map +1 -1
- package/dist/theme/generateThemeRules.js +35 -131
- package/package.json +4 -9
- package/src/Button/Button.doc.mjs +5 -5
- package/src/Button/Button.tsx +13 -15
- package/src/Chat/ChatLayout.doc.mjs +4 -12
- package/src/Chat/ChatLayout.test.tsx +0 -28
- package/src/Chat/ChatLayout.tsx +0 -19
- package/src/Chat/ChatLayoutScrollButton.test.tsx +0 -31
- package/src/Chat/ChatLayoutScrollButton.tsx +14 -31
- package/src/Citation/Citation.doc.mjs +2 -2
- package/src/Citation/Citation.test.tsx +0 -17
- package/src/Citation/Citation.tsx +2 -4
- package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
- package/src/CodeBlock/highlightRanges.test.ts +1 -143
- package/src/CodeBlock/highlightRanges.ts +5 -15
- package/src/Icon/Icon.doc.mjs +5 -7
- package/src/Icon/Icon.spec.md +40 -81
- package/src/Icon/Icon.test.tsx +3 -6
- package/src/Icon/Icon.tsx +68 -20
- package/src/IconButton/IconButton.doc.mjs +3 -3
- package/src/IconButton/IconButton.test.tsx +0 -40
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Link/Link.doc.mjs +4 -6
- package/src/Link/LinkProvider.doc.mjs +2 -2
- package/src/Link/useLinkComponent.test.tsx +0 -333
- package/src/Link/useLinkComponent.ts +57 -29
- package/src/List/ListItem.doc.mjs +2 -2
- package/src/Markdown/Markdown.doc.mjs +2 -2
- package/src/Markdown/Markdown.spec.md +15 -19
- package/src/Markdown/Markdown.test.tsx +0 -52
- package/src/Markdown/Markdown.tsx +2 -2
- package/src/Markdown/parser.test.ts +0 -20
- package/src/Markdown/url.ts +3 -9
- package/src/PowerSearch/PowerSearchValueEditor.test.tsx +0 -51
- package/src/PowerSearch/PowerSearchValueEditor.tsx +0 -4
- package/src/Token/Token.doc.mjs +2 -2
- package/src/TopNav/TopNavMenu.doc.mjs +2 -2
- package/src/TopNav/TopNavMenu.test.tsx +0 -30
- package/src/TopNav/TopNavMenu.tsx +2 -7
- package/src/hooks/useClickableContainer.ts +4 -20
- package/src/index.ts +0 -3
- package/src/tailwind-theme.css +6 -0
- package/src/theme/Theme.test.tsx +0 -32
- package/src/theme/generateThemeRules.test.ts +2 -470
- package/src/theme/generateThemeRules.ts +51 -219
- package/src/theme/tokenValueCompat.test.ts +1 -4
- package/dist/Icon/IconDefaultSizeContext.d.ts +0 -4
- package/dist/Icon/IconDefaultSizeContext.d.ts.map +0 -1
- package/dist/Icon/IconDefaultSizeContext.js +0 -18
- package/dist/Icon/IconSize.stylex.d.ts +0 -65
- package/dist/Icon/IconSize.stylex.d.ts.map +0 -1
- package/dist/Icon/IconSize.stylex.js +0 -75
- package/dist/Timer/Timer.d.ts +0 -49
- package/dist/Timer/Timer.d.ts.map +0 -1
- package/dist/Timer/Timer.js +0 -158
- package/dist/Timer/index.d.ts +0 -9
- package/dist/Timer/index.d.ts.map +0 -1
- package/dist/Timer/index.js +0 -10
- package/dist/theme/declarationBoundary.d.ts +0 -17
- package/dist/theme/declarationBoundary.d.ts.map +0 -1
- package/dist/theme/declarationBoundary.js +0 -343
- package/dist/utils/safeUrl.d.ts +0 -11
- package/dist/utils/safeUrl.d.ts.map +0 -1
- package/dist/utils/safeUrl.js +0 -78
- package/src/Chat/ChatLayout.spec.md +0 -271
- package/src/Chat/ChatLayoutScrollButton.spec.md +0 -338
- package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +0 -514
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +0 -740
- package/src/Icon/IconDefaultSizeContext.ts +0 -23
- package/src/Icon/IconSize.stylex.ts +0 -70
- package/src/Link/__tests__/Link.navigation.a11y.chromium.spec.ts +0 -261
- package/src/Markdown/Markdown.renderBoundary.test.tsx +0 -72
- package/src/Timer/Timer.doc.mjs +0 -183
- package/src/Timer/Timer.spec.md +0 -215
- package/src/Timer/Timer.test.tsx +0 -302
- package/src/Timer/Timer.tsx +0 -258
- package/src/Timer/index.ts +0 -11
- package/src/hooks/useClickableContainer.test.tsx +0 -236
- package/src/theme/declarationBoundary.test.ts +0 -126
- package/src/theme/declarationBoundary.ts +0 -347
- package/src/utils/safeUrl.test.ts +0 -88
- package/src/utils/safeUrl.ts +0 -85
|
@@ -4,61 +4,89 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useLinkComponent.ts
|
|
7
|
-
* @input
|
|
8
|
-
* @output
|
|
9
|
-
* @position
|
|
10
|
-
* as destination-less anchors without invoking a router.
|
|
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
|
|
11
10
|
*
|
|
12
|
-
*
|
|
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
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import {use, useMemo, createElement} from 'react';
|
|
16
24
|
import {LinkContext} from './LinkContext';
|
|
17
|
-
import {isSafeDestination} from '../utils/safeUrl';
|
|
18
25
|
import type {LinkComponentType} from './types';
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Creates a wrapper component that passes both `href` and `to` props
|
|
29
|
+
* to the underlying link component. This enables routers that use `to`
|
|
30
|
+
* (React Router, TanStack Router) to work without an adapter.
|
|
31
|
+
*
|
|
32
|
+
* The wrapper is transparent: it forwards refs and all other props unchanged.
|
|
33
|
+
* Native `<a>` elements ignore the unknown `to` prop harmlessly.
|
|
34
|
+
*/
|
|
35
|
+
function createLinkWithTo(
|
|
36
|
+
Component: LinkComponentType,
|
|
37
|
+
): LinkComponentType {
|
|
38
|
+
function LinkWithTo({
|
|
22
39
|
href,
|
|
23
|
-
to,
|
|
24
40
|
ref,
|
|
25
41
|
...rest
|
|
26
42
|
}: {
|
|
27
|
-
href?:
|
|
28
|
-
to?:
|
|
43
|
+
href?: string;
|
|
44
|
+
to?: string;
|
|
29
45
|
ref?: React.Ref<unknown>;
|
|
30
46
|
}) {
|
|
31
|
-
|
|
32
|
-
// route. Reject the whole handoff, not just one prop or a fallback value.
|
|
33
|
-
if (!isSafeDestination(href) || !isSafeDestination(to)) {
|
|
34
|
-
return createElement('a', {ref, ...rest});
|
|
35
|
-
}
|
|
36
|
-
if (Component === 'a') {
|
|
37
|
-
return createElement('a', {ref, ...rest, href});
|
|
38
|
-
}
|
|
39
|
-
return createElement(Component, {ref, ...rest, href, to: to ?? href});
|
|
47
|
+
return createElement(Component, {ref, href, to: href, ...rest});
|
|
40
48
|
}
|
|
41
|
-
|
|
49
|
+
LinkWithTo.displayName = `LinkWithTo(${
|
|
42
50
|
typeof Component === 'string'
|
|
43
51
|
? Component
|
|
44
52
|
: Component.displayName || Component.name || 'Component'
|
|
45
53
|
})`;
|
|
46
|
-
return
|
|
54
|
+
return LinkWithTo as LinkComponentType;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
58
|
+
* Resolves the link component to use.
|
|
59
|
+
*
|
|
60
|
+
* Priority: `as` prop > `LinkProvider` context > native `<a>`.
|
|
61
|
+
*
|
|
62
|
+
* When the resolved component is a custom component (not the native `<a>`),
|
|
63
|
+
* it is wrapped to receive both `href` and `to` props set to the same value.
|
|
64
|
+
* This allows `to`-based routers (React Router, TanStack Router) to work
|
|
65
|
+
* out of the box without a manual adapter.
|
|
66
|
+
*
|
|
67
|
+
* @param as - Per-component override. If provided, takes highest priority.
|
|
68
|
+
* @returns The resolved link component (with `to` injection for custom components).
|
|
53
69
|
*
|
|
54
70
|
* @example
|
|
55
71
|
* ```
|
|
56
|
-
*
|
|
57
|
-
*
|
|
72
|
+
* function MyComponent({ as }: { as?: LinkComponentType }) {
|
|
73
|
+
* const LinkComponent = useLinkComponent(as);
|
|
74
|
+
* return <LinkComponent href="/foo">Click me</LinkComponent>;
|
|
75
|
+
* }
|
|
58
76
|
* ```
|
|
59
77
|
*/
|
|
60
|
-
export function useLinkComponent(
|
|
78
|
+
export function useLinkComponent(
|
|
79
|
+
as?: LinkComponentType,
|
|
80
|
+
): LinkComponentType {
|
|
61
81
|
const ctx = use(LinkContext);
|
|
62
82
|
const resolved = as ?? ctx?.component ?? 'a';
|
|
63
|
-
|
|
83
|
+
|
|
84
|
+
// Memoize the wrapper to maintain referential stability.
|
|
85
|
+
// The wrapper is only created for custom components (not native <a>).
|
|
86
|
+
return useMemo(() => {
|
|
87
|
+
if (resolved === 'a') {
|
|
88
|
+
return 'a';
|
|
89
|
+
}
|
|
90
|
+
return createLinkWithTo(resolved);
|
|
91
|
+
}, [resolved]);
|
|
64
92
|
}
|
|
@@ -67,7 +67,7 @@ export const docs = {
|
|
|
67
67
|
{
|
|
68
68
|
name: 'href',
|
|
69
69
|
type: 'string',
|
|
70
|
-
description: 'Link URL; enables the invisible anchor pattern.
|
|
70
|
+
description: 'Link URL; enables the invisible anchor pattern.',
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
name: 'target',
|
|
@@ -174,7 +174,7 @@ export const docsDense = {
|
|
|
174
174
|
onClick: 'Click handler; enables invisible button pattern.',
|
|
175
175
|
interactiveRef:
|
|
176
176
|
'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
|
|
177
|
-
href: 'Link URL; enables invisible anchor pattern.
|
|
177
|
+
href: 'Link URL; enables invisible anchor pattern.',
|
|
178
178
|
target: 'Link target attribute, only when href provided. target="_blank" auto-adds noopener noreferrer.',
|
|
179
179
|
rel: 'Link relationship tokens. noopener noreferrer are merged for target="_blank".',
|
|
180
180
|
isDisabled: 'Disabled state; sets aria-disabled.',
|
|
@@ -112,7 +112,7 @@ export const docs = {
|
|
|
112
112
|
name: 'onLinkClick',
|
|
113
113
|
type: '(href: string, event: MouseEvent) => void | false',
|
|
114
114
|
description:
|
|
115
|
-
'Handler for link clicks. Return false to prevent the default navigation behavior.
|
|
115
|
+
'Handler for link clicks. Return false to prevent the default navigation behavior.',
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
name: 'sources',
|
|
@@ -859,7 +859,7 @@ export const docsDense = {
|
|
|
859
859
|
isStreaming:
|
|
860
860
|
'Incremental parse + fade-in for streamed chunks. Default: false.',
|
|
861
861
|
onLinkClick:
|
|
862
|
-
'(href, event) => void|false. Return false prevents navigation.
|
|
862
|
+
'(href, event) => void|false. Return false prevents navigation.',
|
|
863
863
|
sources:
|
|
864
864
|
'Record<string, MarkdownSource>. Citation sources by ID. [id]/【id】 markers render as chips.',
|
|
865
865
|
citationStyle:
|
|
@@ -13,7 +13,6 @@ review_triggers: [api, theming]
|
|
|
13
13
|
verified_by:
|
|
14
14
|
[
|
|
15
15
|
packages/core/src/Markdown/Markdown.test.tsx,
|
|
16
|
-
packages/core/src/Markdown/Markdown.renderBoundary.test.tsx,
|
|
17
16
|
packages/core/src/Markdown/Markdown.public.test.ts,
|
|
18
17
|
packages/core/src/Markdown/parser.test.ts,
|
|
19
18
|
packages/core/src/Markdown/incremental.test.ts,
|
|
@@ -317,9 +316,7 @@ and this change preserves the existing spelling exactly.
|
|
|
317
316
|
current with the implementation.
|
|
318
317
|
- `family:navigation-destinations` owns the shared accept/block result for parsed
|
|
319
318
|
links and every Astryx-owned navigation sink. `spec:AST-005/DEC-1` requires
|
|
320
|
-
Markdown
|
|
321
|
-
decision as other Core links. Rejected links remain inert and do not reach a
|
|
322
|
-
custom link renderer; accepted links retain their ordinary behavior.
|
|
319
|
+
Markdown navigation to remain conformant with Core link plumbing.
|
|
323
320
|
- `spec:AST-005/DEC-2` keeps embedded-resource policy separate. Markdown may
|
|
324
321
|
reject a broader set of image/resource URLs without narrowing the shared
|
|
325
322
|
navigation contract.
|
|
@@ -335,21 +332,20 @@ and this change preserves the existing spelling exactly.
|
|
|
335
332
|
|
|
336
333
|
## Verification map
|
|
337
334
|
|
|
338
|
-
| Contract | Verification
|
|
339
|
-
| ---------------------- |
|
|
340
|
-
| FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs`
|
|
341
|
-
| FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx`
|
|
342
|
-
| FR11 | `incremental.test.ts` and `Markdown.test.tsx`
|
|
343
|
-
| FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests
|
|
344
|
-
| FR17–FR18 | container parse/validation, ownership, and dependency tests
|
|
345
|
-
| FR19 | diagnostic-channel tests in development and production
|
|
346
|
-
| FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests
|
|
347
|
-
| FR23 | parser, renderer, nesting, and public option tests
|
|
348
|
-
| FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering
|
|
349
|
-
| FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks
|
|
350
|
-
| Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs`
|
|
351
|
-
|
|
|
352
|
-
| Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
|
|
335
|
+
| Contract | Verification | Representative states | Failure signal |
|
|
336
|
+
| ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
337
|
+
| FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
|
|
338
|
+
| FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
|
|
339
|
+
| FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
|
|
340
|
+
| FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
|
|
341
|
+
| FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
|
|
342
|
+
| FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
|
|
343
|
+
| FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
|
|
344
|
+
| FR23 | parser, renderer, nesting, and public option tests | task-only, plain-only, and mixed ordered/unordered lists at top level and nested; autolink omitted/enabled | A mixed list splits or loses order/state, a plain item becomes a checkbox, or `autolink: 'gfm'` changes non-autolink syntax. |
|
|
345
|
+
| FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering | complete, malformed, non-leading, LF/CRLF, unfinished streaming, full plugin stack | Metadata syntax renders after completion, unfinished syntax leaks while streaming, typing is lost, or later plugins stop composing. |
|
|
346
|
+
| FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks | server/RSC parsing with plugins, synchronous SSR, suspending renderer streaming boundary | A server import gains `use client`, plugin execution needs serialization, SSR loses fallback, or direct RSC rendering is misrepresented as supported. |
|
|
347
|
+
| Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
|
|
348
|
+
| Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
|
|
353
349
|
|
|
354
350
|
Focused tests continue to pin all nine current target names and default block
|
|
355
351
|
placement. Math intentionally adds no target and no default anatomy.
|
|
@@ -636,58 +636,6 @@ describe('Markdown', () => {
|
|
|
636
636
|
expect(links[1].getAttribute('href')).toBe('/page');
|
|
637
637
|
});
|
|
638
638
|
|
|
639
|
-
describe('link destinations follow the shared navigation rule', () => {
|
|
640
|
-
// The same matrix Core's link plumbing and imperative navigation apply
|
|
641
|
-
// (utils/safeUrl.ts): only executable document schemes are blocked.
|
|
642
|
-
const blocked = [
|
|
643
|
-
'javascript:alert(1)',
|
|
644
|
-
'JaVaScRiPt:alert(1)',
|
|
645
|
-
'vbscript:MsgBox(1)',
|
|
646
|
-
'data:text/html,<script>alert(1)</script>',
|
|
647
|
-
'java\nscript:alert(1)',
|
|
648
|
-
];
|
|
649
|
-
const accepted = [
|
|
650
|
-
'https://example.com',
|
|
651
|
-
'/page',
|
|
652
|
-
'#section',
|
|
653
|
-
'//example.com/x',
|
|
654
|
-
'mailto:a@example.com',
|
|
655
|
-
'tel:+15555550100',
|
|
656
|
-
'data:image/png;base64,iVBORw0KGgo=',
|
|
657
|
-
];
|
|
658
|
-
|
|
659
|
-
it.each(blocked)('renders %s as text, not a link', destination => {
|
|
660
|
-
const {container} = render(
|
|
661
|
-
<Markdown>{`[click](${destination})`}</Markdown>,
|
|
662
|
-
);
|
|
663
|
-
expect(container.querySelector('a')).toBeNull();
|
|
664
|
-
expect(container.textContent).toContain('click');
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it.each(accepted)('renders %s as a link', destination => {
|
|
668
|
-
const {container} = render(
|
|
669
|
-
<Markdown>{`[click](${destination})`}</Markdown>,
|
|
670
|
-
);
|
|
671
|
-
const link = container.querySelector('a');
|
|
672
|
-
expect(link).not.toBeNull();
|
|
673
|
-
expect(link?.getAttribute('href')).toBe(destination);
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
it('a data:image link is navigation and is accepted, while a data:image image stays rejected by the resource policy', () => {
|
|
677
|
-
const {container} = render(
|
|
678
|
-
<Markdown>
|
|
679
|
-
{
|
|
680
|
-
'[view](data:image/png;base64,iVBORw0KGgo=)\n\n'
|
|
681
|
-
}
|
|
682
|
-
</Markdown>,
|
|
683
|
-
);
|
|
684
|
-
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
|
685
|
-
'data:image/png;base64,iVBORw0KGgo=',
|
|
686
|
-
);
|
|
687
|
-
expect(container.querySelector('img')).toBeNull();
|
|
688
|
-
});
|
|
689
|
-
});
|
|
690
|
-
|
|
691
639
|
it('preserves dollar-delimited text when no math renderer is supplied', () => {
|
|
692
640
|
const {container} = render(
|
|
693
641
|
<Markdown>{'Total $5 and formula $x_1 + *y*$.'}</Markdown>,
|
|
@@ -76,7 +76,7 @@ import type {
|
|
|
76
76
|
MarkdownPluginEntry,
|
|
77
77
|
PreparedMarkdownPlugins,
|
|
78
78
|
} from './plugins/protocol';
|
|
79
|
-
import {
|
|
79
|
+
import {sanitizeMarkdownUrl} from './url';
|
|
80
80
|
import {themeProps} from '../utils/themeProps';
|
|
81
81
|
import {useTranslator, type TranslatorFn} from '../i18n';
|
|
82
82
|
|
|
@@ -961,7 +961,7 @@ function renderInline(
|
|
|
961
961
|
return <MathComp key={index} value={node.value} display="inline" />;
|
|
962
962
|
}
|
|
963
963
|
case 'link': {
|
|
964
|
-
const safeHref =
|
|
964
|
+
const safeHref = sanitizeMarkdownUrl(node.url);
|
|
965
965
|
if (safeHref == null) {
|
|
966
966
|
// Unsafe URL — render as plain text
|
|
967
967
|
return (
|
|
@@ -165,26 +165,6 @@ describe('parseInline', () => {
|
|
|
165
165
|
}
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
it('decides link destinations with the shared navigation rule', () => {
|
|
169
|
-
const destinations: [string, boolean][] = [
|
|
170
|
-
['https://example.com', true],
|
|
171
|
-
['/page', true],
|
|
172
|
-
['#section', true],
|
|
173
|
-
['mailto:a@example.com', true],
|
|
174
|
-
['tel:+1234567890', true],
|
|
175
|
-
['custom:document', true],
|
|
176
|
-
['data:image/png;base64,iVBORw0KGgo=', true],
|
|
177
|
-
['javascript:alert(1)', false],
|
|
178
|
-
['vbscript:MsgBox(1)', false],
|
|
179
|
-
['data:text/html,<b>x</b>', false],
|
|
180
|
-
['java\u0000script:alert(1)', false],
|
|
181
|
-
];
|
|
182
|
-
for (const [destination, accepted] of destinations) {
|
|
183
|
-
const [node] = parseInline(`[t](${destination})`);
|
|
184
|
-
expect(node.type === 'link').toBe(accepted);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
|
|
188
168
|
it('parses strikethrough', () => {
|
|
189
169
|
const result = parseInline('~~deleted~~');
|
|
190
170
|
expect(result[0].type).toBe('strikethrough');
|
package/src/Markdown/url.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* @position Shared URL policy for parser, transforms, and renderer
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const PARSER_DANGEROUS_URL_PATTERN =
|
|
11
|
+
/^(?:javascript:|data:text\/html|vbscript:)/i;
|
|
12
12
|
const RENDER_DANGEROUS_URL_PATTERN = /^(?:javascript:|data:|vbscript:)/i;
|
|
13
13
|
|
|
14
14
|
function normalizeMarkdownUrl(url: string): string {
|
|
@@ -19,13 +19,7 @@ function normalizeMarkdownUrl(url: string): string {
|
|
|
19
19
|
|
|
20
20
|
/** Preserve the released parser policy for no-plugin output compatibility. */
|
|
21
21
|
export function isSafeMarkdownParserUrl(url: string): boolean {
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Navigation and image rendering deliberately have different policies. */
|
|
26
|
-
export function sanitizeMarkdownLinkUrl(url: string): string | null {
|
|
27
|
-
const normalized = sanitizeUrl(url);
|
|
28
|
-
return normalized === '' ? null : normalized;
|
|
22
|
+
return !PARSER_DANGEROUS_URL_PATTERN.test(normalizeMarkdownUrl(url));
|
|
29
23
|
}
|
|
30
24
|
|
|
31
25
|
export function sanitizeMarkdownUrl(url: string): string | null {
|
|
@@ -630,54 +630,3 @@ describe('maxMenuItems', () => {
|
|
|
630
630
|
await expectCapped({type: 'entity_list', searchSource: source});
|
|
631
631
|
});
|
|
632
632
|
});
|
|
633
|
-
|
|
634
|
-
// =============================================================================
|
|
635
|
-
// EnumListEditor shows the whole closed vocabulary
|
|
636
|
-
// =============================================================================
|
|
637
|
-
|
|
638
|
-
describe('EnumListEditor', () => {
|
|
639
|
-
const values = Array.from({length: 12}, (_, index) => ({
|
|
640
|
-
value: `option-${index}`,
|
|
641
|
-
label: `Option ${index}`,
|
|
642
|
-
}));
|
|
643
|
-
|
|
644
|
-
it('shows every value on focus instead of the typeahead default of 10', async () => {
|
|
645
|
-
render(
|
|
646
|
-
<PowerSearchValueEditor
|
|
647
|
-
operatorValue={{type: 'enum_list', values}}
|
|
648
|
-
filterValue={undefined}
|
|
649
|
-
onChange={vi.fn()}
|
|
650
|
-
config={stubConfig}
|
|
651
|
-
/>,
|
|
652
|
-
);
|
|
653
|
-
|
|
654
|
-
fireEvent.focus(screen.getByRole('combobox'));
|
|
655
|
-
await act(async () => {
|
|
656
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
expect(screen.getAllByRole('option', {hidden: true})).toHaveLength(12);
|
|
660
|
-
expect(screen.getByText('Option 11')).toBeInTheDocument();
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('shows every matching value when searching', async () => {
|
|
664
|
-
render(
|
|
665
|
-
<PowerSearchValueEditor
|
|
666
|
-
operatorValue={{type: 'enum_list', values}}
|
|
667
|
-
filterValue={undefined}
|
|
668
|
-
onChange={vi.fn()}
|
|
669
|
-
config={stubConfig}
|
|
670
|
-
/>,
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
await act(async () => {
|
|
674
|
-
fireEvent.change(screen.getByRole('combobox'), {
|
|
675
|
-
target: {value: 'Option'},
|
|
676
|
-
});
|
|
677
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
expect(screen.getAllByRole('option', {hidden: true})).toHaveLength(12);
|
|
681
|
-
expect(screen.getByText('Option 11')).toBeInTheDocument();
|
|
682
|
-
});
|
|
683
|
-
});
|
|
@@ -619,10 +619,6 @@ function EnumListEditor({
|
|
|
619
619
|
placeholder={t('@astryx.powersearch.valueEditor.selectValuesPlaceholder')}
|
|
620
620
|
hasEntriesOnFocus
|
|
621
621
|
debounceMs={0}
|
|
622
|
-
// A closed vocabulary: every value is a valid choice, so the menu shows
|
|
623
|
-
// all of them instead of BaseTypeahead's 10-suggestion default, which
|
|
624
|
-
// would silently drop valid choices with no way to reach them.
|
|
625
|
-
maxMenuItems={items.length}
|
|
626
622
|
/>
|
|
627
623
|
);
|
|
628
624
|
}
|
package/src/Token/Token.doc.mjs
CHANGED
|
@@ -55,7 +55,7 @@ export const docs = {
|
|
|
55
55
|
name: 'href',
|
|
56
56
|
type: 'string',
|
|
57
57
|
description:
|
|
58
|
-
'Link URL. When provided, the token renders as an <a> element.
|
|
58
|
+
'Link URL. When provided, the token renders as an <a> element.',
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
name: 'description',
|
|
@@ -241,7 +241,7 @@ export const docsDense = {
|
|
|
241
241
|
isDisabled: 'Reduces opacity, blocks interactions.',
|
|
242
242
|
onRemove: 'Fired on remove button click. Renders X button when provided.',
|
|
243
243
|
onClick: 'Click handler. Renders <span> w/ invisible <button> inside for a11y.',
|
|
244
|
-
href: 'Link URL. Renders as <a> element.
|
|
244
|
+
href: 'Link URL. Renders as <a> element.',
|
|
245
245
|
description: 'A11y description via aria-description on root.',
|
|
246
246
|
endContent: 'Content after label, before remove button.',
|
|
247
247
|
isLabelHidden: 'Visually hides label w/ screen-reader-only clip; stays accessible.',
|
|
@@ -27,7 +27,7 @@ export const docs = {
|
|
|
27
27
|
{
|
|
28
28
|
name: 'items',
|
|
29
29
|
type: 'TopNavMenuItemData[]',
|
|
30
|
-
description: 'Menu items to display in the hover popover.
|
|
30
|
+
description: 'Menu items to display in the hover popover.',
|
|
31
31
|
required: true,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -83,7 +83,7 @@ export const docsDense = {
|
|
|
83
83
|
description: 'Nav item w/ hover-triggered popover menu containing rich items w/ icon, title, optional description.',
|
|
84
84
|
propDescriptions: {
|
|
85
85
|
label: 'Trigger button visible label.',
|
|
86
|
-
items: 'Menu items in hover popover.
|
|
86
|
+
items: 'Menu items in hover popover.',
|
|
87
87
|
delay: 'Show delay ms on hover.',
|
|
88
88
|
hideDelay: 'Hide delay ms after mouse leaves.',
|
|
89
89
|
},
|
|
@@ -31,36 +31,6 @@ const mockItems = [
|
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
describe('TopNavMenu', () => {
|
|
34
|
-
it.each([
|
|
35
|
-
'javascript:alert(1)',
|
|
36
|
-
'vbscript:MsgBox(1)',
|
|
37
|
-
'data:text/html,<b>x</b>',
|
|
38
|
-
])('does not expose rejected menu destination %s', href => {
|
|
39
|
-
const onClick = vi.fn();
|
|
40
|
-
render(
|
|
41
|
-
<TopNavMenu
|
|
42
|
-
label="Menu"
|
|
43
|
-
items={[{title: 'Destination', href, onClick}]}
|
|
44
|
-
/>,
|
|
45
|
-
);
|
|
46
|
-
const item = screen.getByRole('menuitem', {hidden: true});
|
|
47
|
-
expect(item).not.toHaveAttribute('href');
|
|
48
|
-
fireEvent.click(item);
|
|
49
|
-
expect(onClick).toHaveBeenCalledOnce();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('preserves an accepted native menu destination', () => {
|
|
53
|
-
render(
|
|
54
|
-
<TopNavMenu
|
|
55
|
-
label="Menu"
|
|
56
|
-
items={[{title: 'Destination', href: '/docs'}]}
|
|
57
|
-
/>,
|
|
58
|
-
);
|
|
59
|
-
expect(screen.getByRole('menuitem', {hidden: true})).toHaveAttribute(
|
|
60
|
-
'href',
|
|
61
|
-
'/docs',
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
34
|
it('renders the trigger button with label', () => {
|
|
65
35
|
render(<TopNavMenu label="Products" items={mockItems} />);
|
|
66
36
|
expect(screen.getByRole('button', {name: 'Products'})).toBeInTheDocument();
|
|
@@ -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
|
|
9
9
|
* @output Exports TopNavMenu component and related types
|
|
10
10
|
* @position Navigation item with hover-triggered overflow menu for TopNav
|
|
11
11
|
*
|
|
@@ -37,7 +37,6 @@ import {useTopNavSlot} from './TopNavContext';
|
|
|
37
37
|
import {useTopNavRenderMode} from './TopNavRenderContext';
|
|
38
38
|
import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
|
|
39
39
|
import {useLinkComponent} from '../Link/useLinkComponent';
|
|
40
|
-
import {isSafeUrl} from '../utils/safeUrl';
|
|
41
40
|
import {themeProps} from '../utils/themeProps';
|
|
42
41
|
import {focusOutlineProps} from '../utils/focusOutline.stylex';
|
|
43
42
|
import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
|
|
@@ -576,11 +575,7 @@ export function TopNavMenu({
|
|
|
576
575
|
// Single tab stop: useListFocus owns the roving tabindex and
|
|
577
576
|
// promotes exactly one item to 0.
|
|
578
577
|
tabIndex={-1}
|
|
579
|
-
href={
|
|
580
|
-
item.href != null && isSafeUrl(item.href)
|
|
581
|
-
? item.href
|
|
582
|
-
: undefined
|
|
583
|
-
}
|
|
578
|
+
href={item.href}
|
|
584
579
|
onClick={item.onClick}
|
|
585
580
|
{...focusOutlineProps.focusVisible(styles.menuItem)}>
|
|
586
581
|
<div {...stylex.props(styles.menuItemIcon)}>{item.icon}</div>
|
|
@@ -11,17 +11,11 @@
|
|
|
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
|
-
*
|
|
19
14
|
* SYNC: When modified, update:
|
|
20
15
|
* - /packages/core/src/hooks/index.ts (export)
|
|
21
16
|
*/
|
|
22
17
|
|
|
23
18
|
import {useCallback, useEffect, type RefObject, type MouseEvent} from 'react';
|
|
24
|
-
import {isSafeUrl} from '../utils/safeUrl';
|
|
25
19
|
|
|
26
20
|
/**
|
|
27
21
|
* Canonical list of interactive element selectors — native controls plus
|
|
@@ -95,11 +89,7 @@ export interface UseClickableContainerOptions {
|
|
|
95
89
|
interactiveRef?: RefObject<HTMLElement | null>;
|
|
96
90
|
/** Click handler */
|
|
97
91
|
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
|
98
|
-
/**
|
|
99
|
-
* Navigation URL — when provided, clicking the container navigates. Checked
|
|
100
|
-
* with the shared navigation rule (utils/safeUrl.ts) before any activation
|
|
101
|
-
* method (plain, new-tab, Cmd/Ctrl-click, middle-click, delegated click).
|
|
102
|
-
*/
|
|
92
|
+
/** Navigation URL — when provided, clicking the container navigates */
|
|
103
93
|
href?: string;
|
|
104
94
|
/** Link target */
|
|
105
95
|
target?: string;
|
|
@@ -190,12 +180,8 @@ export function useClickableContainer({
|
|
|
190
180
|
return;
|
|
191
181
|
}
|
|
192
182
|
|
|
193
|
-
// Navigate if href is provided
|
|
194
|
-
|
|
195
|
-
// navigation rule decides once, before target, modifier key, or the
|
|
196
|
-
// delegated anchor can select a sink. A blocked href simply does not
|
|
197
|
-
// navigate — the click handler above still ran.
|
|
198
|
-
if (href != null && isSafeUrl(href)) {
|
|
183
|
+
// Navigate if href is provided
|
|
184
|
+
if (href != null) {
|
|
199
185
|
const shouldOpenNewTab =
|
|
200
186
|
target === '_blank' || event.ctrlKey || event.metaKey;
|
|
201
187
|
if (shouldOpenNewTab) {
|
|
@@ -244,13 +230,11 @@ export function useClickableContainer({
|
|
|
244
230
|
return;
|
|
245
231
|
}
|
|
246
232
|
|
|
247
|
-
// Middle-click on href opens in new tab
|
|
248
|
-
// this navigation never passes through React either).
|
|
233
|
+
// Middle-click on href opens in new tab
|
|
249
234
|
const isMiddleClick = event.button === 1;
|
|
250
235
|
if (
|
|
251
236
|
isMiddleClick &&
|
|
252
237
|
href != null &&
|
|
253
|
-
isSafeUrl(href) &&
|
|
254
238
|
(eventTarget === event.currentTarget ||
|
|
255
239
|
!hasInteractiveAncestor(eventTarget, containerEl))
|
|
256
240
|
) {
|
package/src/index.ts
CHANGED
package/src/tailwind-theme.css
CHANGED
|
@@ -182,6 +182,12 @@
|
|
|
182
182
|
--text-4xl: var(--font-size-4xl);
|
|
183
183
|
--text-5xl: var(--font-size-5xl);
|
|
184
184
|
|
|
185
|
+
/* --- Font weights --- */
|
|
186
|
+
--font-weight-normal: var(--font-weight-normal);
|
|
187
|
+
--font-weight-medium: var(--font-weight-medium);
|
|
188
|
+
--font-weight-semibold: var(--font-weight-semibold);
|
|
189
|
+
--font-weight-bold: var(--font-weight-bold);
|
|
190
|
+
|
|
185
191
|
/* =========================================================================
|
|
186
192
|
* Border radius
|
|
187
193
|
*
|