mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(web): linkify custom URI schemes with a confirm prompt (#633)
* feat(web): add UriConfirmDialog component
Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.
- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}
* feat(web): autolink non-https URI schemes in markdown
Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).
- No scheme allowlist: every `scheme://` pattern is converted; the
sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
code-block bypass, trailing-punct trimming.
* feat(web): linkify custom URI schemes via markdown <a> handler
Wire up 4-layer URI security policy in the markdown renderer:
1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
using classifyScheme as single source of truth (handles percent-encoding,
case-insensitive, whitespace-prefix bypass patterns).
2. onClick intercept: custom <A> component classifies each href —
- IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
- Deny (javascript/data/vbscript/file): preventDefault silently.
- Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.
3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
the subtree — "Always allow" click updates every link in one React commit.
4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
receive allowed-scheme updates synchronously without waiting for the window
storage event (which only fires in other tabs). Cross-tab sync continues via
the existing window storage event listener.
5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
schemes once allowed navigate directly on subsequent clicks, no dialog gate.
href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
Deny-scheme href="" prevents any navigation even if localStorage tampered.
Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.
Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.
* fix(web): block control-char-spliced deny schemes in classifyScheme
Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().
Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
(javascript%253A → javascript%3A → javascript:) are fully unwrapped
before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
the browser's own normalization
classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.
Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).
* fix(web): preserve relative markdown links from being blocked
Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.
Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.
Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.
Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.
* test(web): cover percent-encoded scheme control char + protocol-relative href
Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:
- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
same decode→strip code path as the literal `java\nscript:` case but was only
tested literally. Add an explicit test so a future refactor that drops the
decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
false and `<A>` treats them as scheme-less — browsers then navigate them as
the current origin's protocol. Existing relative-href tests covered absolute
paths, hashes, queries, and colon-in-path, but not the protocol-relative
variant. Add one assertion.
Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.
* fix(web): preserve balanced parens/brackets in autolinked URIs
The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.
Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.
Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
||||
MARKDOWN_COMPONENTS_BY_LANGUAGE,
|
||||
MARKDOWN_CLASSNAME,
|
||||
defaultComponents,
|
||||
denyOnlyTransform,
|
||||
UriConfirmProvider,
|
||||
} from '@/components/assistant-ui/markdown-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -22,15 +24,18 @@ function MarkdownContent(props: MarkdownRendererProps) {
|
||||
: defaultComponents
|
||||
|
||||
return (
|
||||
<UriConfirmProvider>
|
||||
<TextMessagePartProvider text={props.content}>
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={MARKDOWN_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
components={mergedComponents}
|
||||
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
||||
urlTransform={denyOnlyTransform}
|
||||
className={cn(MARKDOWN_CLASSNAME, props.className)}
|
||||
/>
|
||||
</TextMessagePartProvider>
|
||||
</UriConfirmProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { UriConfirmDialog } from '@/components/UriConfirmDialog'
|
||||
|
||||
function renderDialog(props: Partial<React.ComponentProps<typeof UriConfirmDialog>> = {}) {
|
||||
const defaults = {
|
||||
open: true,
|
||||
url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest',
|
||||
scheme: 'obsidian',
|
||||
onCancel: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onAlwaysAllow: vi.fn(),
|
||||
}
|
||||
const merged = { ...defaults, ...props }
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<UriConfirmDialog {...merged} />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('UriConfirmDialog', () => {
|
||||
it('renders dialog title when open', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('Open this link?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the scheme prefix in the URI display', () => {
|
||||
renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' })
|
||||
// The URI is split: scheme prefix + remainder in sibling spans.
|
||||
expect(screen.getByText('obsidian:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the URL remainder after the scheme prefix', () => {
|
||||
renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' })
|
||||
expect(screen.getByText('//open?vault=MyVault&file=Notes%2Ftest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emphasizes the scheme in the URI display', () => {
|
||||
renderDialog({ scheme: 'obsidian' })
|
||||
// The scheme label should appear visually prominent (e.g. as a separate span)
|
||||
expect(screen.getByText('obsidian:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Cancel, Open, and Always allow buttons', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^open$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /always allow/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
renderDialog({ onCancel })
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onOpen when Open button is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderDialog({ onOpen })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^open$/i }))
|
||||
expect(onOpen).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onAlwaysAllow with the scheme when Always allow is clicked', () => {
|
||||
const onAlwaysAllow = vi.fn()
|
||||
renderDialog({ scheme: 'obsidian', onAlwaysAllow })
|
||||
fireEvent.click(screen.getByRole('button', { name: /always allow/i }))
|
||||
expect(onAlwaysAllow).toHaveBeenCalledWith('obsidian')
|
||||
})
|
||||
|
||||
it('does not render dialog content when open is false', () => {
|
||||
renderDialog({ open: false })
|
||||
expect(screen.queryByText('Open this link?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without error in open and closed states (onOpenChange wiring)', () => {
|
||||
// Verifies the Dialog onOpenChange prop is wired so that open→closed calls onCancel.
|
||||
// Direct Esc simulation is unreliable in jsdom; we confirm both states render cleanly.
|
||||
const onCancel = vi.fn()
|
||||
const { rerender } = renderDialog({ onCancel, open: true })
|
||||
expect(screen.getByText('Open this link?')).toBeInTheDocument()
|
||||
rerender(
|
||||
<I18nProvider>
|
||||
<UriConfirmDialog
|
||||
open={false}
|
||||
url="obsidian://open"
|
||||
scheme="obsidian"
|
||||
onCancel={onCancel}
|
||||
onOpen={vi.fn()}
|
||||
onAlwaysAllow={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
expect(screen.queryByText('Open this link?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes the scheme name in the Always allow button label', () => {
|
||||
renderDialog({ scheme: 'vscode' })
|
||||
expect(screen.getByRole('button', { name: /always allow vscode/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
export type UriConfirmDialogProps = {
|
||||
/** Whether the dialog is visible. */
|
||||
open: boolean
|
||||
/** The full URL being navigated to. */
|
||||
url: string
|
||||
/** The scheme portion of the URL (without the colon), e.g. "obsidian". */
|
||||
scheme: string
|
||||
/** Called when the user dismisses the dialog without navigating. */
|
||||
onCancel: () => void
|
||||
/** Called when the user chooses to open the link once. */
|
||||
onOpen: () => void
|
||||
/** Called when the user chooses to always allow this scheme. */
|
||||
onAlwaysAllow: (scheme: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown before navigating to a non-IANA URI scheme.
|
||||
* Follows the RenameSessionDialog pattern (Radix Dialog + Button + i18n).
|
||||
*/
|
||||
export function UriConfirmDialog(props: UriConfirmDialogProps) {
|
||||
const { open, url, scheme, onCancel, onOpen, onAlwaysAllow } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Split URL into scheme prefix and the rest for visual emphasis.
|
||||
const schemePrefix = `${scheme}:`
|
||||
const urlRemainder = url.startsWith(schemePrefix) ? url.slice(schemePrefix.length) : url
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.uri.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="mt-2">
|
||||
{t('dialog.uri.description')}
|
||||
</DialogDescription>
|
||||
|
||||
{/* URI display with scheme emphasis */}
|
||||
<div className="mt-3 rounded-lg border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-3 py-2 font-mono text-sm break-all">
|
||||
<span className="font-semibold text-[var(--app-link)]">{schemePrefix}</span>
|
||||
<span className="text-[var(--app-fg)]">{urlRemainder}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 justify-end flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t('dialog.uri.open')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onAlwaysAllow(scheme)}
|
||||
>
|
||||
{t('dialog.uri.alwaysAllow', { scheme })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Tests for the custom <A> anchor component and the inlined URL policy helpers
|
||||
* in markdown-text.tsx.
|
||||
*
|
||||
* Covers:
|
||||
* - classifyScheme: IANA / deny / custom, 6-axis security bypass
|
||||
* - denyOnlyTransform: deny → "", IANA/custom → pass-through, relative paths
|
||||
* - useAllowedSchemes (inlined): localStorage roundtrip, cross-tab storage event, tamper guard
|
||||
* - <A> component click behaviour: deny, IANA, custom (dialog opened via context)
|
||||
* - intra-tab cross-provider sync via module-level schemeListeners emitter
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { defaultComponents, classifyScheme, denyOnlyTransform, UriConfirmProvider } from '@/components/assistant-ui/markdown-text'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
|
||||
// defaultComponents.a is the memoized A component.
|
||||
const AnchorComponent = (defaultComponents as Record<string, unknown>).a as React.ComponentType<
|
||||
React.ComponentPropsWithoutRef<'a'>
|
||||
>
|
||||
|
||||
// Helper: wrap A in UriConfirmProvider so useContext(UriConfirmContext) is non-null.
|
||||
// Previously <A> had a localHook fallback for bare renders, but that fallback
|
||||
// added a storage listener per link (N links → N+1 listeners). The fallback is
|
||||
// removed; tests must provide the context instead.
|
||||
function renderA(props: React.ComponentPropsWithoutRef<'a'>) {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<UriConfirmProvider>
|
||||
<AnchorComponent {...props} />
|
||||
</UriConfirmProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hapi-allowed-schemes'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ── classifyScheme ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('classifyScheme — IANA schemes', () => {
|
||||
it.each(['https://example.com', 'http://example.com', 'mailto:user@x.com', 'irc://irc.libera.chat', 'ircs://irc.libera.chat', 'xmpp:user@x.com'])(
|
||||
'classifies %s as iana',
|
||||
(url) => expect(classifyScheme(url)).toBe('iana')
|
||||
)
|
||||
it('classifies HTTPS: (uppercase) as iana (case-insensitive)', () => {
|
||||
expect(classifyScheme('HTTPS://example.com')).toBe('iana')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyScheme — deny schemes', () => {
|
||||
it.each(['javascript:alert(1)', 'data:text/html,<h1>xss</h1>', 'vbscript:msgbox(1)', 'file:///tmp/test.txt'])(
|
||||
'classifies %s as deny',
|
||||
(url) => expect(classifyScheme(url)).toBe('deny')
|
||||
)
|
||||
})
|
||||
|
||||
describe('classifyScheme — custom schemes', () => {
|
||||
it.each(['obsidian://open?vault=V&file=F', 'vscode://file/path', 'slack://channel?team=T123'])(
|
||||
'classifies %s as custom',
|
||||
(url) => expect(classifyScheme(url)).toBe('custom')
|
||||
)
|
||||
})
|
||||
|
||||
describe('classifyScheme — security bypass axes', () => {
|
||||
// (a) case bypass
|
||||
it.each(['JavaScript:alert(1)', 'JAVASCRIPT:alert(1)'])('blocks %s (case) as deny', (url) =>
|
||||
expect(classifyScheme(url)).toBe('deny')
|
||||
)
|
||||
// (b) whitespace prefix on entire URL
|
||||
it.each(['\tjavascript:alert(1)', '\njavascript:alert(1)', ' javascript:alert(1)'])('blocks %s (whitespace prefix) as deny', (url) =>
|
||||
expect(classifyScheme(url)).toBe('deny')
|
||||
)
|
||||
// (c) percent-encoding
|
||||
it('%6Aavascript: (encoded j) → deny', () => expect(classifyScheme('%6Aavascript:alert(1)')).toBe('deny'))
|
||||
it('jav%61script: (encoded a) → deny', () => expect(classifyScheme('jav%61script:alert(1)')).toBe('deny'))
|
||||
// (d) double-encoding — 2-pass decode unwraps javascript%253A → javascript%3A → javascript:
|
||||
// With 2-pass decode, the second pass resolves %3A → literal colon, so the scheme
|
||||
// "javascript" is extracted and hits DENY_SCHEMES → 'deny' via scheme-match.
|
||||
it('javascript%253A (double-encoded colon) → deny', () => expect(classifyScheme('javascript%253Aalert(1)')).toBe('deny'))
|
||||
// `javascript%3Aalert(1)` — single-encoded colon. decodeURIComponent yields
|
||||
// `javascript:alert(1)` with a literal colon, so classifyScheme extracts scheme
|
||||
// "javascript" → hits DENY_SCHEMES → 'deny'. This is the real scheme-match path.
|
||||
it('javascript%3A (single-encoded colon) → deny via scheme-match', () => expect(classifyScheme('javascript%3Aalert(1)')).toBe('deny'))
|
||||
// (e) control characters spliced into scheme name
|
||||
// Browsers strip \n, \t, \r from URL schemes during navigation; our normalizer
|
||||
// must do the same before comparing against the deny list.
|
||||
it('java\\nscript: (newline in scheme) → deny', () => expect(classifyScheme('java\nscript:alert(1)')).toBe('deny'))
|
||||
it('java\\tscript: (tab in scheme) → deny', () => expect(classifyScheme('java\tscript:alert(1)')).toBe('deny'))
|
||||
it('java\\rscript: (carriage return in scheme) → deny', () => expect(classifyScheme('java\rscript:alert(1)')).toBe('deny'))
|
||||
it('java script: (space in scheme) → deny', () => expect(classifyScheme('java script:alert(1)')).toBe('deny'))
|
||||
// percent-encoded control chars inside the scheme — decoded by pass 1 then stripped
|
||||
it('java%0Ascript: (percent-encoded newline in scheme) → deny', () => expect(classifyScheme('java%0Ascript:alert(1)')).toBe('deny'))
|
||||
// leading whitespace on the URL itself (already covered by trimStart, added for completeness)
|
||||
it('\\tjavascript: (leading tab on URL) → deny', () => expect(classifyScheme('\tjavascript:alert(1)')).toBe('deny'))
|
||||
// case sanity (also covered above but keep explicit)
|
||||
it('JAVASCRIPT: → deny', () => expect(classifyScheme('JAVASCRIPT:alert(1)')).toBe('deny'))
|
||||
it('JaVaScRipT: → deny', () => expect(classifyScheme('JaVaScRipT:')).toBe('deny'))
|
||||
// edge
|
||||
it('empty string → deny', () => expect(classifyScheme('')).toBe('deny'))
|
||||
it('no-colon string → deny', () => expect(classifyScheme('not-a-url')).toBe('deny'))
|
||||
})
|
||||
|
||||
// ── denyOnlyTransform ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('denyOnlyTransform', () => {
|
||||
it.each(['javascript:alert(1)', 'data:text/html,xss', 'vbscript:x', 'file:///tmp/f', 'JavaScript:alert(1)', 'jav%61script:alert(1)', '%6Aavascript:alert(1)'])(
|
||||
'strips %s → ""',
|
||||
(url) => expect(denyOnlyTransform(url)).toBe('')
|
||||
)
|
||||
it.each(['https://example.com', 'http://example.com', 'mailto:a@b.com'])(
|
||||
'passes %s through unchanged',
|
||||
(url) => expect(denyOnlyTransform(url)).toBe(url)
|
||||
)
|
||||
it.each(['obsidian://open?vault=V', 'vscode://file/path', 'slack://channel'])(
|
||||
'passes custom scheme %s through unchanged',
|
||||
(url) => expect(denyOnlyTransform(url)).toBe(url)
|
||||
)
|
||||
it('passes relative path through', () => {
|
||||
expect(denyOnlyTransform('/relative/path')).toBe('/relative/path')
|
||||
})
|
||||
})
|
||||
|
||||
// ── useAllowedSchemes (inlined hook, tested via A component) ──────────────────
|
||||
|
||||
describe('localStorage roundtrip via A component', () => {
|
||||
it('renders href="#" for unallowed custom scheme', () => {
|
||||
renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('#')
|
||||
})
|
||||
|
||||
it('renders real href for pre-seeded allowed custom scheme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian']))
|
||||
renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open?vault=V&file=F')
|
||||
})
|
||||
|
||||
it('isAllowed returns false for deny scheme even if tampered into localStorage', () => {
|
||||
// Tamper: put javascript into allowed list
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['javascript']))
|
||||
// The A component should still not treat javascript as allowed (it classifies to 'deny')
|
||||
renderA({ href: 'javascript:alert(1)', children: 'evil' })
|
||||
// href="" comes from denyOnlyTransform; onclick should preventDefault
|
||||
const link = document.querySelector('a')!
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
link.dispatchEvent(clickEvent)
|
||||
expect(preventSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-tab sync via storage event', () => {
|
||||
it('updates after storage event fires (simulated other-tab write)', () => {
|
||||
// Start with empty storage, render an unallowed custom link
|
||||
renderA({ href: 'obsidian://open', children: 'note' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('#')
|
||||
|
||||
// Simulate another tab writing the allowed schemes
|
||||
act(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian']))
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: STORAGE_KEY,
|
||||
newValue: JSON.stringify(['obsidian']),
|
||||
storageArea: localStorage,
|
||||
}))
|
||||
})
|
||||
|
||||
// After storage event the hook re-reads; re-render the component
|
||||
cleanup()
|
||||
renderA({ href: 'obsidian://open', children: 'note' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open')
|
||||
})
|
||||
})
|
||||
|
||||
// ── <A> component — click handler ────────────────────────────────────────────
|
||||
|
||||
describe('markdown <A> component — click handler', () => {
|
||||
it('prevents default when href is empty string (deny-scheme link)', () => {
|
||||
renderA({ href: '', children: 'deny' })
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
document.querySelector('a')!.dispatchEvent(clickEvent)
|
||||
expect(preventSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents default when href is undefined', () => {
|
||||
renderA({ children: 'no href' })
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
document.querySelector('a')!.dispatchEvent(clickEvent)
|
||||
expect(preventSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders href="#" for an unallowed custom scheme (no middle-click bypass)', () => {
|
||||
renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('#')
|
||||
})
|
||||
|
||||
it('renders the real href for an IANA scheme (https)', () => {
|
||||
renderA({ href: 'https://example.com', children: 'link' })
|
||||
expect(document.querySelector('a')!.getAttribute('href')).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('does not navigate for a deny scheme (href="")', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
renderA({ href: '', children: 'evil' })
|
||||
fireEvent.click(document.querySelector('a')!)
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ── relative / no-scheme hrefs — regression guard ────────────────────────────
|
||||
//
|
||||
// Finding 2: denyOnlyTransform passes relative hrefs through unchanged (no colon
|
||||
// → not a scheme URL), but the <A> onClick handler called classifyScheme(href)
|
||||
// which returned 'deny' for inputs with no valid scheme → preventDefault was
|
||||
// called → relative/internal links were silently blocked.
|
||||
//
|
||||
// Fix: <A> must detect hrefs that have no scheme and treat them as 'iana' so the
|
||||
// browser/router can navigate normally.
|
||||
|
||||
describe('markdown <A> component — relative / no-scheme hrefs navigate normally', () => {
|
||||
// Each of these hrefs has no URL scheme. Clicks must NOT be prevented.
|
||||
// We verify by checking that preventDefault is NOT called on the click event.
|
||||
|
||||
function clickAndCheckNotPrevented(href: string) {
|
||||
renderA({ href, children: 'link' })
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
document.querySelector('a')!.dispatchEvent(clickEvent)
|
||||
expect(preventSpy).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
it('/settings → click not prevented (absolute-path relative link)', () => {
|
||||
clickAndCheckNotPrevented('/settings')
|
||||
})
|
||||
|
||||
it('./foo → click not prevented (relative-path link)', () => {
|
||||
clickAndCheckNotPrevented('./foo')
|
||||
})
|
||||
|
||||
it('#section → click not prevented (hash fragment link)', () => {
|
||||
clickAndCheckNotPrevented('#section')
|
||||
})
|
||||
|
||||
it('?q=1 → click not prevented (query-only link)', () => {
|
||||
clickAndCheckNotPrevented('?q=1')
|
||||
})
|
||||
|
||||
it('/path:colon → click not prevented (path with colon, no scheme)', () => {
|
||||
// "/" appears before ":" so this is a path, not a scheme.
|
||||
clickAndCheckNotPrevented('/path:colon')
|
||||
})
|
||||
|
||||
it('//example.com → click not prevented (protocol-relative URL, no colon)', () => {
|
||||
// Protocol-relative URLs have no colon; browsers navigate them as the
|
||||
// current origin's protocol, same as any other relative href.
|
||||
clickAndCheckNotPrevented('//example.com/path')
|
||||
})
|
||||
|
||||
it('https://example.com → click not prevented (regression: IANA still passes through)', () => {
|
||||
clickAndCheckNotPrevented('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
// ── intra-tab cross-provider sync (schemeListeners emitter) ──────────────────
|
||||
//
|
||||
// P7e.1 added a module-level `schemeListeners: Set<SchemeListener>` so that
|
||||
// when two sibling <UriConfirmProvider>s exist in the same window (e.g.
|
||||
// MarkdownText + Reasoning in AssistantMessage), clicking "Always allow" in
|
||||
// one provider's dialog immediately updates the other without waiting for a
|
||||
// cross-tab storage event (which browsers only fire in OTHER tabs).
|
||||
//
|
||||
// This test asserts that path: mount two sibling providers, trigger allow()
|
||||
// in one via the dialog flow, verify the other's link href updates.
|
||||
|
||||
describe('intra-tab cross-provider sync (schemeListeners emitter)', () => {
|
||||
it('allowing a scheme in one UriConfirmProvider propagates to a sibling provider', async () => {
|
||||
localStorage.clear()
|
||||
|
||||
// Suppress window.open — handleAlwaysAllow calls it after allow()
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<UriConfirmProvider>
|
||||
<AnchorComponent href="obsidian://open?a=1">link1</AnchorComponent>
|
||||
</UriConfirmProvider>
|
||||
<UriConfirmProvider>
|
||||
<AnchorComponent href="obsidian://open?a=2">link2</AnchorComponent>
|
||||
</UriConfirmProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
const [a1, a2] = links
|
||||
|
||||
// Both links start blocked (href="#") because obsidian is not yet allowed.
|
||||
expect(a1.getAttribute('href')).toBe('#')
|
||||
expect(a2.getAttribute('href')).toBe('#')
|
||||
|
||||
// Click the first link → its provider opens the UriConfirmDialog.
|
||||
await act(async () => {
|
||||
fireEvent.click(a1)
|
||||
})
|
||||
|
||||
// The "Always allow obsidian:" button is rendered by UriConfirmDialog
|
||||
// via Radix Dialog portal into document.body.
|
||||
const alwaysBtn = await waitFor(() =>
|
||||
screen.getByRole('button', { name: /always allow obsidian/i })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(alwaysBtn)
|
||||
})
|
||||
|
||||
// After "Always allow", the schemeListeners emitter must have notified
|
||||
// the sibling provider synchronously. Both links should now carry the
|
||||
// live href (not '#').
|
||||
await waitFor(() => {
|
||||
expect(a1.getAttribute('href')).toBe('obsidian://open?a=1')
|
||||
expect(a2.getAttribute('href')).toBe('obsidian://open?a=2')
|
||||
})
|
||||
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
|
||||
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
|
||||
import { MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text'
|
||||
|
||||
describe('MARKDOWN_PLUGINS integration', () => {
|
||||
it('includes remarkNonHttpsAutolink', () => {
|
||||
expect(MARKDOWN_PLUGINS).toContain(remarkNonHttpsAutolink)
|
||||
})
|
||||
|
||||
it('places remarkNonHttpsAutolink BEFORE remarkStripCjkAutolink so CJK strip sees new links', () => {
|
||||
const idxAutolink = MARKDOWN_PLUGINS.indexOf(remarkNonHttpsAutolink)
|
||||
const idxCjk = MARKDOWN_PLUGINS.indexOf(remarkStripCjkAutolink)
|
||||
expect(idxAutolink).toBeGreaterThan(0) // not first (remarkGfm is first)
|
||||
expect(idxAutolink).toBeLessThan(idxCjk) // autolink before CJK strip
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@assistant-ui/react-markdown/styles/dot.css'
|
||||
|
||||
import type { ComponentPropsWithoutRef, MouseEvent } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react'
|
||||
import {
|
||||
MarkdownTextPrimitive,
|
||||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||||
@@ -13,6 +14,7 @@ import rehypeKatex from 'rehype-katex'
|
||||
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
|
||||
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
|
||||
import { cn, encodeBase64 } from '@/lib/utils'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
|
||||
@@ -20,10 +22,26 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
|
||||
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
|
||||
import { UriConfirmDialog } from '@/components/UriConfirmDialog'
|
||||
|
||||
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
|
||||
|
||||
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
// ── Plugin array ────────────────────────────────────────────────────────────
|
||||
// Order: remarkGfm → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks
|
||||
// remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the
|
||||
// CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation
|
||||
// from them. Both must come before remarkMath (to avoid treating TeX as URI).
|
||||
// remarkFilePathLinks runs last to convert file paths → links after all other
|
||||
// transforms have settled.
|
||||
export const MARKDOWN_PLUGINS = [
|
||||
remarkGfm,
|
||||
remarkNonHttpsAutolink,
|
||||
remarkStripCjkAutolink,
|
||||
remarkMath,
|
||||
remarkDisableIndentedCode,
|
||||
remarkFilePathLinks, // upstream — file path → link conversion, runs last
|
||||
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
|
||||
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
|
||||
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]'
|
||||
export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
|
||||
@@ -32,6 +50,292 @@ export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
|
||||
},
|
||||
} satisfies NonNullable<MarkdownTextPrimitiveProps['componentsByLanguage']>
|
||||
|
||||
// ── URI scheme policy (inlined from url-scheme-policy.ts) ───────────────────
|
||||
//
|
||||
// IANA-registered safe schemes — mirrors react-markdown defaultUrlTransform exactly.
|
||||
// Source: node_modules/react-markdown/lib/index.js:124 /^(https?|ircs?|mailto|xmpp)$/i
|
||||
const IANA_SAFE_SCHEMES: ReadonlySet<string> = new Set([
|
||||
'http',
|
||||
'https',
|
||||
'irc',
|
||||
'ircs',
|
||||
'mailto',
|
||||
'xmpp',
|
||||
])
|
||||
|
||||
// Schemes that must always be blocked regardless of user preference.
|
||||
// Includes file: because Chromium denies navigation from http(s) origins.
|
||||
const DENY_SCHEMES: ReadonlySet<string> = new Set([
|
||||
'javascript',
|
||||
'data',
|
||||
'vbscript',
|
||||
'file',
|
||||
])
|
||||
|
||||
/**
|
||||
* Extract the normalised scheme from a URL string.
|
||||
*
|
||||
* Applies up to two rounds of decodeURIComponent so that double-encoded
|
||||
* bypass attempts (`javascript%253A` → `javascript%3A` → `javascript:`)
|
||||
* are unwrapped before the scheme is extracted.
|
||||
*
|
||||
* After decoding, ASCII control characters (U+0000–U+001F, U+007F) and
|
||||
* all whitespace are stripped from the extracted scheme string so that
|
||||
* browsers' built-in normalization — which silently discards \t, \n, \r
|
||||
* and space from scheme names during navigation — cannot be used to bypass
|
||||
* the deny list (e.g. `java\nscript:alert(1)` → scheme `"javascript"`).
|
||||
*
|
||||
* Returns null when no valid scheme separator is found.
|
||||
*/
|
||||
function normalizedScheme(url: string): string | null {
|
||||
let value = url.trimStart()
|
||||
for (let i = 0; i < 2; i++) {
|
||||
try {
|
||||
const next = decodeURIComponent(value)
|
||||
if (next === value) break
|
||||
value = next
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
const colonIndex = value.indexOf(':')
|
||||
if (colonIndex <= 0) return null
|
||||
// Strip ASCII control chars (U+0000–U+001F, DEL U+007F) and all whitespace
|
||||
// from the scheme name. Browsers strip exactly these characters during
|
||||
// navigation, so `java\nscript` navigates as `javascript`.
|
||||
return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a URL string by its scheme.
|
||||
*
|
||||
* Uses normalizedScheme() so that common bypass patterns — control characters
|
||||
* spliced into the scheme name, tab/space prefix, single- and double-encoded
|
||||
* scheme characters — are all caught before the deny/IANA comparison.
|
||||
*
|
||||
* @returns 'iana' | 'deny' | 'custom'
|
||||
*/
|
||||
export function classifyScheme(url: string): 'iana' | 'deny' | 'custom' {
|
||||
const scheme = normalizedScheme(url)
|
||||
if (scheme === null) return 'deny'
|
||||
if (DENY_SCHEMES.has(scheme)) return 'deny'
|
||||
if (IANA_SAFE_SCHEMES.has(scheme)) return 'iana'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when href contains a URL scheme (i.e. a "scheme:" prefix that
|
||||
* appears before any path/query/fragment delimiter).
|
||||
*
|
||||
* This distinguishes `mailto:foo@bar` (has scheme → true) from purely relative
|
||||
* hrefs like `/settings`, `./foo`, `#section`, `?q=1`, or paths that contain a
|
||||
* colon after a path segment like `/path:colon` (→ false, because `/` appears
|
||||
* before `:`). Protocol-relative URLs (`//host/path`) have no colon and are
|
||||
* also treated as scheme-less; browsers navigate them as the current origin's
|
||||
* protocol, same as a normal relative path would do.
|
||||
*
|
||||
* Used by <A> to short-circuit classifyScheme for no-scheme hrefs and treat them
|
||||
* as 'iana' so the browser/router can handle them normally (fixing the regression
|
||||
* where relative markdown links were silently blocked by the onClick deny guard).
|
||||
*/
|
||||
function hasScheme(href: string): boolean {
|
||||
const colonIdx = href.indexOf(':')
|
||||
if (colonIdx <= 0) return false
|
||||
const boundaryIdx = href.search(/[/?#]/)
|
||||
return boundaryIdx < 0 || colonIdx < boundaryIdx
|
||||
}
|
||||
|
||||
// ── URL sanitize transform (deny-only) ──────────────────────────────────────
|
||||
// Passed as urlTransform to MarkdownTextPrimitive. Only deny-listed schemes
|
||||
// are stripped; every other scheme (IANA + custom) passes through so the
|
||||
// <a href> is preserved for the onClick layer to handle.
|
||||
//
|
||||
// Uses classifyScheme as the single source of truth for scheme extraction so
|
||||
// that percent-encoded bypass patterns (jav%61script:, %6Aavascript:) are
|
||||
// caught by the same decoding logic used at click time.
|
||||
//
|
||||
// Relative paths (no colon, or colon only in path/query) have no scheme and
|
||||
// are always passed through — they are safe and used for img src etc.
|
||||
//
|
||||
// Known limitation (FIX 5, deferred): data:image/png;base64,... used in
|
||||
// <img src> is also stripped because DENY_SCHEMES includes 'data'. However,
|
||||
// react-markdown's own defaultUrlTransform strips all data: URLs identically,
|
||||
// so this is not a regression introduced by this PR.
|
||||
export function denyOnlyTransform(url: string): string {
|
||||
if (!url) return url
|
||||
const trimmed = url.trimStart()
|
||||
const colonIdx = trimmed.indexOf(':')
|
||||
const slashIdx = trimmed.search(/[/?#]/)
|
||||
if (colonIdx < 0 || (slashIdx >= 0 && slashIdx < colonIdx)) {
|
||||
return url
|
||||
}
|
||||
return classifyScheme(url) === 'deny' ? '' : url
|
||||
}
|
||||
|
||||
// ── Allowed-schemes state (inlined from useAllowedSchemes.ts) ───────────────
|
||||
|
||||
const STORAGE_KEY = 'hapi-allowed-schemes'
|
||||
|
||||
// Module-level subscriber set for intra-tab cross-provider sync (P7e.1).
|
||||
//
|
||||
// `window` `storage` events fire only in OTHER tabs/windows, not in the same
|
||||
// tab that called setItem. When two <UriConfirmProvider>s exist in the same
|
||||
// window (e.g. MarkdownText + Reasoning as sibling providers in AssistantMessage),
|
||||
// clicking "Always allow" in one provider's dialog must immediately update the
|
||||
// other without waiting for a page reload.
|
||||
//
|
||||
// Solution: a module-level Set of subscriber callbacks. writeAllowedToStorage
|
||||
// calls each subscriber synchronously after writing to localStorage. Each
|
||||
// useAllowedSchemes instance registers on mount and unregisters on unmount.
|
||||
// Cross-tab sync continues to use the existing window `storage` event.
|
||||
type SchemeListener = (schemes: ReadonlySet<string>) => void
|
||||
const schemeListeners = new Set<SchemeListener>()
|
||||
|
||||
function readAllowedFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return new Set()
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(
|
||||
parsed
|
||||
.filter((s): s is string => typeof s === 'string')
|
||||
.filter((s) => !DENY_SCHEMES.has(s))
|
||||
)
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function writeAllowedToStorage(schemes: ReadonlySet<string>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(schemes)))
|
||||
} catch {
|
||||
// Storage quota exceeded — silently ignore; in-memory set still works.
|
||||
}
|
||||
// Notify all same-tab subscribers synchronously so sibling providers
|
||||
// (e.g. MarkdownText + Reasoning) update in the same React event loop tick.
|
||||
for (const listener of schemeListeners) {
|
||||
listener(schemes)
|
||||
}
|
||||
}
|
||||
|
||||
function useAllowedSchemes() {
|
||||
const [allowed, setAllowed] = useState<ReadonlySet<string>>(() => readAllowedFromStorage())
|
||||
|
||||
useEffect(() => {
|
||||
// Cross-tab sync: storage event fires in other tabs/windows.
|
||||
function handleStorage(e: StorageEvent) {
|
||||
if (e.key !== STORAGE_KEY) return
|
||||
setAllowed(readAllowedFromStorage())
|
||||
}
|
||||
window.addEventListener('storage', handleStorage)
|
||||
|
||||
// Intra-tab sync: subscribe to module-level emitter so sibling
|
||||
// providers in the same window see updates without page reload.
|
||||
function handleIntraTab(schemes: ReadonlySet<string>) {
|
||||
setAllowed(new Set(schemes))
|
||||
}
|
||||
schemeListeners.add(handleIntraTab)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
schemeListeners.delete(handleIntraTab)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const allow = useCallback((scheme: string) => {
|
||||
if (DENY_SCHEMES.has(scheme)) return
|
||||
setAllowed((prev) => {
|
||||
if (prev.has(scheme)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(scheme)
|
||||
writeAllowedToStorage(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isAllowed = useCallback(
|
||||
(scheme: string): boolean => {
|
||||
if (DENY_SCHEMES.has(scheme)) return false
|
||||
return allowed.has(scheme)
|
||||
},
|
||||
[allowed]
|
||||
)
|
||||
|
||||
return { allowed, allow, isAllowed }
|
||||
}
|
||||
|
||||
// ── UriConfirmContext — one dialog per markdown root ────────────────────────
|
||||
|
||||
type DialogState = {
|
||||
url: string
|
||||
scheme: string
|
||||
} | null
|
||||
|
||||
type UriConfirmContextValue = {
|
||||
openUri: (url: string, scheme: string) => void
|
||||
/** Shared isAllowed so all <a> tags in this tree re-render on the same state update. */
|
||||
isAllowed: (scheme: string) => boolean
|
||||
}
|
||||
|
||||
const UriConfirmContext = createContext<UriConfirmContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Provider that mounts a single <UriConfirmDialog> for all <a> links in its
|
||||
* subtree. Wrap each MarkdownText / MarkdownRenderer / Reasoning surface with
|
||||
* this so that only one dialog instance exists per markdown root instead of
|
||||
* one per <a> tag.
|
||||
*
|
||||
* The allowed-schemes state lives here so all child <A> components share the
|
||||
* same React state — when "Always allow" is clicked, every link in the tree
|
||||
* re-renders in the same React commit (no cross-tab storage event required).
|
||||
*/
|
||||
export function UriConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const { allow, isAllowed } = useAllowedSchemes()
|
||||
|
||||
const openUri = useCallback((url: string, scheme: string) => {
|
||||
setDialog({ url, scheme })
|
||||
}, [])
|
||||
|
||||
const closeDialog = () => setDialog(null)
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!dialog) return
|
||||
closeDialog()
|
||||
window.open(dialog.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleAlwaysAllow = (allowedScheme: string) => {
|
||||
allow(allowedScheme)
|
||||
closeDialog()
|
||||
if (dialog) {
|
||||
window.open(dialog.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue = useMemo(() => ({ openUri, isAllowed }), [openUri, isAllowed])
|
||||
|
||||
return (
|
||||
<UriConfirmContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{dialog !== null && (
|
||||
<UriConfirmDialog
|
||||
open={true}
|
||||
url={dialog.url}
|
||||
scheme={dialog.scheme}
|
||||
onCancel={closeDialog}
|
||||
onOpen={handleOpen}
|
||||
onAlwaysAllow={handleAlwaysAllow}
|
||||
/>
|
||||
)}
|
||||
</UriConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Components ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CodeHeader(props: CodeHeaderProps) {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const language = props.language && props.language !== 'unknown' ? props.language : 'text'
|
||||
@@ -122,8 +426,35 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor component with URI scheme policy enforcement.
|
||||
*
|
||||
* - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through
|
||||
* without interception so the browser or SPA router can navigate normally.
|
||||
* - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly.
|
||||
* - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform
|
||||
* already strips the href to "", so href="" in DOM (belt-and-suspenders onClick
|
||||
* guard also calls preventDefault).
|
||||
* - Custom schemes, NOT yet allowed by user: href="#" in DOM (not the live URL)
|
||||
* so middle-click / drag-to-bar cannot bypass the dialog. Dialog opens on left-click
|
||||
* via the shared UriConfirmContext (single dialog per markdown root).
|
||||
* - Custom schemes, already allowed by user: live href in DOM; middle-click works.
|
||||
* - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor
|
||||
* which uses useNavigate for SPA routing.
|
||||
*/
|
||||
function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
const chat = useOptionalHappyChatContext()
|
||||
// useContext must be called unconditionally before any early return so that
|
||||
// the Rules of Hooks are satisfied regardless of whether `filePath` is set.
|
||||
// isAllowed comes exclusively from the shared context. Every call site
|
||||
// (MarkdownText, Reasoning, MarkdownRenderer) wraps its surface with
|
||||
// <UriConfirmProvider>, so ctx is always present in production.
|
||||
//
|
||||
// The previous localHook fallback instantiated useAllowedSchemes() even
|
||||
// when ctx was present — 20 anchors → 21 storage listeners per mount.
|
||||
// Removing it requires tests that render <A> directly to wrap with
|
||||
// <UriConfirmProvider> (or supply a mock UriConfirmContext.Provider).
|
||||
const ctx = useContext(UriConfirmContext)
|
||||
const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null
|
||||
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
|
||||
|
||||
@@ -134,10 +465,61 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
|
||||
}
|
||||
|
||||
const isAllowed = ctx?.isAllowed ?? (() => false)
|
||||
|
||||
const { onClick, href, ...rest } = props
|
||||
// Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be
|
||||
// classified via classifyScheme — it returns 'deny' for inputs with no valid
|
||||
// scheme, which previously caused the onClick handler to preventDefault and
|
||||
// silently break all relative markdown links. Treat them as 'iana' so the
|
||||
// browser or SPA router can navigate normally.
|
||||
const isRelative = href ? !hasScheme(href) : false
|
||||
const classification = href && !isRelative ? classifyScheme(href) : 'iana'
|
||||
const colonIdx = href ? href.indexOf(':') : -1
|
||||
const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : ''
|
||||
const isCustomAllowed = classification === 'custom' && isAllowed(scheme)
|
||||
|
||||
const domHref =
|
||||
classification === 'iana' || isCustomAllowed
|
||||
? href
|
||||
: classification === 'custom'
|
||||
? '#'
|
||||
: href
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const url = href ?? ''
|
||||
|
||||
if (!url) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (classification === 'deny') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (classification === 'iana') {
|
||||
onClick?.(e)
|
||||
return
|
||||
}
|
||||
|
||||
if (isCustomAllowed) {
|
||||
onClick?.(e)
|
||||
return
|
||||
}
|
||||
|
||||
// Unallowed custom scheme: show confirmation dialog via context.
|
||||
e.preventDefault()
|
||||
ctx?.openUri(url, scheme)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
{...rest}
|
||||
href={domHref}
|
||||
rel={rel}
|
||||
onClick={handleClick}
|
||||
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
|
||||
/>
|
||||
)
|
||||
@@ -280,12 +662,15 @@ export const defaultComponents = memoizeMarkdownComponents({
|
||||
|
||||
export function MarkdownText() {
|
||||
return (
|
||||
<UriConfirmProvider>
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={MARKDOWN_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
components={defaultComponents}
|
||||
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
||||
urlTransform={denyOnlyTransform}
|
||||
className={cn(MARKDOWN_CLASSNAME)}
|
||||
/>
|
||||
</UriConfirmProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
MARKDOWN_PLUGINS,
|
||||
MARKDOWN_REHYPE_PLUGINS,
|
||||
defaultComponents,
|
||||
denyOnlyTransform,
|
||||
UriConfirmProvider,
|
||||
} from '@/components/assistant-ui/markdown-text'
|
||||
|
||||
function ChevronIcon(props: { className?: string; open?: boolean }) {
|
||||
@@ -41,13 +43,16 @@ function ShimmerDot() {
|
||||
|
||||
export const Reasoning: FC = () => {
|
||||
return (
|
||||
<UriConfirmProvider>
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={MARKDOWN_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
components={defaultComponents}
|
||||
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
||||
urlTransform={denyOnlyTransform}
|
||||
className={cn(MARKDOWN_CLASSNAME, 'aui-reasoning-content text-[13.5px] text-[var(--app-hint)]')}
|
||||
/>
|
||||
</UriConfirmProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ export default {
|
||||
'session.action.copy': 'Copy',
|
||||
|
||||
// Dialogs
|
||||
'dialog.uri.title': 'Open this link?',
|
||||
'dialog.uri.description': 'This link uses a custom URI scheme. Make sure you trust this link before opening it.',
|
||||
'dialog.uri.open': 'Open',
|
||||
'dialog.uri.alwaysAllow': 'Always allow {scheme}:',
|
||||
'dialog.rename.title': 'Rename Session',
|
||||
'dialog.rename.placeholder': 'Session name',
|
||||
'dialog.rename.save': 'Save',
|
||||
|
||||
@@ -83,6 +83,10 @@ export default {
|
||||
'session.action.copy': '复制',
|
||||
|
||||
// Dialogs
|
||||
'dialog.uri.title': '打开此链接?',
|
||||
'dialog.uri.description': '此链接使用自定义 URI 协议。请确认您信任此链接后再打开。',
|
||||
'dialog.uri.open': '打开',
|
||||
'dialog.uri.alwaysAllow': '始终允许 {scheme}:',
|
||||
'dialog.rename.title': '重命名会话',
|
||||
'dialog.rename.placeholder': '会话名称',
|
||||
'dialog.rename.save': '保存',
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
|
||||
|
||||
/**
|
||||
* Unit tests for remark-non-https-autolink.
|
||||
*
|
||||
* We test the tree-transform function directly by feeding it MDAST
|
||||
* structures. The plugin converts raw "scheme://..." text in paragraph
|
||||
* text nodes into link nodes, skipping http/https (handled by GFM),
|
||||
* explicit markdown links, and code blocks/inline code.
|
||||
*/
|
||||
describe('remarkNonHttpsAutolink', () => {
|
||||
const transform = remarkNonHttpsAutolink()
|
||||
|
||||
function makeRoot(children: any[]) { return { type: 'root', children } as any }
|
||||
function makeParagraph(children: any[]) { return { type: 'paragraph', children } as any }
|
||||
function makeText(value: string) { return { type: 'text', value } as any }
|
||||
function makeLink(url: string, textValue: string) {
|
||||
return { type: 'link', url, children: [{ type: 'text', value: textValue }] } as any
|
||||
}
|
||||
function makeCode(lang: string, value: string) { return { type: 'code', lang, value } as any }
|
||||
function makeInlineCode(value: string) { return { type: 'inlineCode', value } as any }
|
||||
|
||||
// ── Basic autolink ───────────────────────────────────────────────────
|
||||
|
||||
it('converts obsidian:// raw URI in text to a link node', () => {
|
||||
const tree = makeRoot([makeParagraph([makeText('Check obsidian://open?vault=V&file=F here')])])
|
||||
transform(tree)
|
||||
const para = tree.children[0]
|
||||
expect(para.children[0]).toMatchObject({ type: 'text', value: 'Check ' })
|
||||
expect(para.children[1]).toMatchObject({ type: 'link', url: 'obsidian://open?vault=V&file=F' })
|
||||
expect(para.children[2]).toMatchObject({ type: 'text', value: ' here' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['vscode://file/path/to/file', 'Open vscode://file/path/to/file'],
|
||||
['slack://channel?team=T123', 'Join slack://channel?team=T123'],
|
||||
])('converts %s URI to a link node', (expectedUrl, inputText) => {
|
||||
const tree = makeRoot([makeParagraph([makeText(inputText)])])
|
||||
transform(tree)
|
||||
const link = tree.children[0].children.find((c: any) => c.type === 'link')
|
||||
expect(link?.url).toBe(expectedUrl)
|
||||
})
|
||||
|
||||
it('sets the link text to the full URI (autolink style)', () => {
|
||||
const tree = makeRoot([makeParagraph([makeText('obsidian://vault/file')])])
|
||||
transform(tree)
|
||||
const link = tree.children[0].children[0]
|
||||
expect(link.children[0].value).toBe('obsidian://vault/file')
|
||||
})
|
||||
|
||||
// ── http/https NOT converted (GFM handles those) ─────────────────────
|
||||
|
||||
it.each([
|
||||
'http://example.com',
|
||||
'https://example.com',
|
||||
])('does NOT convert %s (GFM handles those)', (url) => {
|
||||
const tree = makeRoot([makeParagraph([makeText(`Visit ${url}`)])])
|
||||
transform(tree)
|
||||
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
|
||||
expect(links.length).toBe(0)
|
||||
})
|
||||
|
||||
// ── Existing link nodes not touched ──────────────────────────────────
|
||||
|
||||
it('does not modify an existing explicit markdown link node', () => {
|
||||
const existingLink = makeLink('obsidian://open', 'My Note')
|
||||
const tree = makeRoot([makeParagraph([existingLink])])
|
||||
transform(tree)
|
||||
const para = tree.children[0]
|
||||
expect(para.children.length).toBe(1)
|
||||
expect(para.children[0]).toMatchObject({ type: 'link', url: 'obsidian://open' })
|
||||
expect(para.children[0].children[0].value).toBe('My Note')
|
||||
})
|
||||
|
||||
// ── Code blocks / inline code not touched ────────────────────────────
|
||||
|
||||
it('does not linkify URIs inside code blocks', () => {
|
||||
const tree = makeRoot([makeCode('sh', 'open obsidian://vault/file')])
|
||||
transform(tree)
|
||||
expect(tree.children[0].type).toBe('code')
|
||||
expect(tree.children[0].value).toBe('open obsidian://vault/file')
|
||||
})
|
||||
|
||||
it('does not linkify URIs inside inline code', () => {
|
||||
const tree = makeRoot([makeParagraph([makeInlineCode('obsidian://vault/file')])])
|
||||
transform(tree)
|
||||
expect(tree.children[0].children[0].type).toBe('inlineCode')
|
||||
})
|
||||
|
||||
// ── Trailing punctuation handling ────────────────────────────────────
|
||||
|
||||
it.each([
|
||||
['period', 'See obsidian://open?vault=V.', 'obsidian://open?vault=V', '.'],
|
||||
['comma', 'Link obsidian://open,', 'obsidian://open', ','],
|
||||
['closing paren', 'See (obsidian://open)', 'obsidian://open', ')'],
|
||||
])('strips trailing %s from the linked URL', (_label, inputText, expectedUrl, expectedTrailing) => {
|
||||
const tree = makeRoot([makeParagraph([makeText(inputText)])])
|
||||
transform(tree)
|
||||
const para = tree.children[0]
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link?.url).toBe(expectedUrl)
|
||||
const trailingText = para.children[para.children.indexOf(link) + 1]
|
||||
expect(trailingText?.value).toContain(expectedTrailing)
|
||||
})
|
||||
|
||||
// ── Balanced paren/bracket preservation (GFM autolink literal behaviour) ───
|
||||
|
||||
it.each([
|
||||
['balanced paren in URL', 'obsidian://open?file=Note(1)', 'obsidian://open?file=Note(1)'],
|
||||
['balanced bracket in URL', 'obsidian://open?file=Note[1]', 'obsidian://open?file=Note[1]'],
|
||||
['nested balanced parens', 'obsidian://open?q=(a(b)c)', 'obsidian://open?q=(a(b)c)'],
|
||||
])('keeps %s inside the linked URL', (_label, inputText, expectedUrl) => {
|
||||
const tree = makeRoot([makeParagraph([makeText(inputText)])])
|
||||
transform(tree)
|
||||
const link = tree.children[0].children.find((c: any) => c.type === 'link')
|
||||
expect(link?.url).toBe(expectedUrl)
|
||||
// No trailing text node should be emitted for a balanced URL.
|
||||
expect(tree.children[0].children.length).toBe(1)
|
||||
})
|
||||
|
||||
it('strips period after a balanced-paren URL but keeps the parens', () => {
|
||||
const tree = makeRoot([makeParagraph([makeText('See obsidian://open?file=Note(1).')])])
|
||||
transform(tree)
|
||||
const para = tree.children[0]
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link?.url).toBe('obsidian://open?file=Note(1)')
|
||||
const trailingText = para.children[para.children.indexOf(link) + 1]
|
||||
expect(trailingText?.value).toBe('.')
|
||||
})
|
||||
|
||||
it('strips an unmatched closing paren (no opener in URL body)', () => {
|
||||
const tree = makeRoot([makeParagraph([makeText('(see obsidian://x).')])])
|
||||
transform(tree)
|
||||
const para = tree.children[0]
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link?.url).toBe('obsidian://x')
|
||||
const trailingText = para.children[para.children.indexOf(link) + 1]
|
||||
expect(trailingText?.value).toBe(').')
|
||||
})
|
||||
|
||||
// ── Multiple URIs in one text node ───────────────────────────────────
|
||||
|
||||
it('converts multiple non-https URIs in the same text node', () => {
|
||||
const tree = makeRoot([
|
||||
makeParagraph([makeText('A: obsidian://vault/a and B: vscode://file/b done')])
|
||||
])
|
||||
transform(tree)
|
||||
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
|
||||
expect(links.length).toBe(2)
|
||||
expect(links[0].url).toBe('obsidian://vault/a')
|
||||
expect(links[1].url).toBe('vscode://file/b')
|
||||
})
|
||||
|
||||
// ── Scheme-only / edge cases ─────────────────────────────────────────
|
||||
|
||||
it('does not linkify a bare scheme without "://"', () => {
|
||||
// mailto: without // is valid URI but our plugin targets scheme:// only
|
||||
const tree = makeRoot([makeParagraph([makeText('mailto:user@example.com')])])
|
||||
transform(tree)
|
||||
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
|
||||
expect(links.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Remark plugin that converts raw non-https URI scheme text into link nodes.
|
||||
*
|
||||
* GFM (`remark-gfm`) already handles `http://`, `https://`, and `www.` autolinks.
|
||||
* This plugin handles the remainder: any `scheme://...` pattern where the scheme
|
||||
* is NOT `http` or `https` (to avoid duplicating GFM's work).
|
||||
*
|
||||
* Pipeline position: before `remarkStripCjkAutolink`, before `remarkMath`.
|
||||
*
|
||||
* Security note: this plugin deliberately has NO scheme allowlist — it converts
|
||||
* every `scheme://` pattern it finds. The sanitize layer (`urlTransform`) and
|
||||
* the onClick layer (`classifyScheme`) handle blocking/confirmation downstream.
|
||||
* Keeping the plugin allowlist-free means new custom schemes work automatically
|
||||
* without touching this file.
|
||||
*/
|
||||
|
||||
// Matches a non-http(s) URI of the form `scheme://...` where:
|
||||
// - scheme is one or more ASCII letters (a-z), digits, +, -, or .
|
||||
// - scheme is NOT "http" or "https" (those are GFM's domain)
|
||||
// - followed by "://" and a run of non-whitespace characters
|
||||
//
|
||||
// Trailing punctuation (.,!?;:) and closing brackets/parens are stripped
|
||||
// by a post-match trim step so "See obsidian://x." doesn't include the ".".
|
||||
const NON_HTTPS_URI_RE = /\b(?!https?:\/\/)([a-zA-Z][a-zA-Z0-9+\-.]*):\/\/[^\s]*/g
|
||||
|
||||
// Characters that may be stripped from the end of a matched URI.
|
||||
// `)` and `]` are only stripped when the URL body has no unmatched opening
|
||||
// counterpart — this mirrors GFM autolink literal behaviour and keeps URIs
|
||||
// like `obsidian://open?file=Note(1)` intact.
|
||||
const TRAILING_PUNCT_CHARS = /[.,;!?:)>\]'"]/
|
||||
|
||||
/**
|
||||
* Strip trailing punctuation from a URI, but preserve `)` / `]` that close an
|
||||
* unmatched `(` / `[` inside the URL body.
|
||||
*
|
||||
* Examples:
|
||||
* `obsidian://x.` → stripped `obsidian://x`, trailing `.`
|
||||
* `obsidian://open?file=Note(1)` → stripped unchanged, trailing ``
|
||||
* `obsidian://x).` → stripped `obsidian://x`, trailing `).` (no `(` to balance)
|
||||
*/
|
||||
function stripTrailingPunct(uri: string): { stripped: string; trailing: string } {
|
||||
let stripped = uri
|
||||
let trailing = ''
|
||||
while (stripped.length > 0) {
|
||||
const last = stripped[stripped.length - 1]
|
||||
if (!TRAILING_PUNCT_CHARS.test(last)) break
|
||||
|
||||
if (last === ')' || last === ']') {
|
||||
const open = last === ')' ? '(' : '['
|
||||
const inner = stripped.slice(0, -1)
|
||||
let opens = 0
|
||||
let closes = 0
|
||||
for (const ch of inner) {
|
||||
if (ch === open) opens++
|
||||
else if (ch === last) closes++
|
||||
}
|
||||
// If the inner URL already has more opens than closes, the trailing
|
||||
// closer balances an earlier opener and belongs to the URL.
|
||||
if (closes < opens) break
|
||||
}
|
||||
|
||||
trailing = last + trailing
|
||||
stripped = stripped.slice(0, -1)
|
||||
}
|
||||
return { stripped, trailing }
|
||||
}
|
||||
|
||||
interface MdastNode {
|
||||
type: string
|
||||
url?: string
|
||||
value?: string
|
||||
lang?: string
|
||||
children?: MdastNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk all text nodes inside paragraph-like containers and replace
|
||||
* `scheme://...` patterns with link nodes.
|
||||
*
|
||||
* Skips:
|
||||
* - `code` (fenced code blocks) and `inlineCode` nodes — never touched.
|
||||
* - `link` / `linkReference` nodes — their children are not re-processed
|
||||
* (existing links are left as-is).
|
||||
*/
|
||||
function visitAndLinkify(node: MdastNode): void {
|
||||
if (!node.children) return
|
||||
|
||||
const newChildren: MdastNode[] = []
|
||||
|
||||
for (const child of node.children) {
|
||||
// Don't descend into existing links or code nodes.
|
||||
if (
|
||||
child.type === 'link'
|
||||
|| child.type === 'linkReference'
|
||||
|| child.type === 'inlineCode'
|
||||
|| child.type === 'code'
|
||||
) {
|
||||
newChildren.push(child)
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.type === 'text' && typeof child.value === 'string') {
|
||||
const segments = linkifyText(child.value)
|
||||
newChildren.push(...segments)
|
||||
continue
|
||||
}
|
||||
|
||||
// Recurse into other container nodes (e.g. paragraph, blockquote, list items).
|
||||
visitAndLinkify(child)
|
||||
newChildren.push(child)
|
||||
}
|
||||
|
||||
node.children = newChildren
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a raw text string around any `scheme://...` matches and return a
|
||||
* mixed array of text nodes and link nodes.
|
||||
*/
|
||||
function linkifyText(text: string): MdastNode[] {
|
||||
const result: MdastNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
// Reset the regex state (global flag carries state across calls).
|
||||
NON_HTTPS_URI_RE.lastIndex = 0
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = NON_HTTPS_URI_RE.exec(text)) !== null) {
|
||||
const rawUri = match[0]
|
||||
const matchStart = match.index
|
||||
|
||||
// Strip trailing punctuation characters from the URI, preserving
|
||||
// balanced ()/[].
|
||||
const { stripped, trailing } = stripTrailingPunct(rawUri)
|
||||
|
||||
// Text before this match.
|
||||
if (matchStart > lastIndex) {
|
||||
result.push({ type: 'text', value: text.slice(lastIndex, matchStart) })
|
||||
}
|
||||
|
||||
// The link node.
|
||||
result.push({
|
||||
type: 'link',
|
||||
url: stripped,
|
||||
children: [{ type: 'text', value: stripped }],
|
||||
})
|
||||
|
||||
// Any stripped trailing punctuation becomes a plain text node.
|
||||
if (trailing) {
|
||||
result.push({ type: 'text', value: trailing })
|
||||
}
|
||||
|
||||
lastIndex = matchStart + rawUri.length
|
||||
}
|
||||
|
||||
// Remaining text after the last match.
|
||||
if (lastIndex < text.length) {
|
||||
result.push({ type: 'text', value: text.slice(lastIndex) })
|
||||
}
|
||||
|
||||
// If no matches were found, return the original text node unchanged.
|
||||
if (result.length === 0) {
|
||||
result.push({ type: 'text', value: text })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export default function remarkNonHttpsAutolink() {
|
||||
return (tree: MdastNode) => {
|
||||
visitAndLinkify(tree)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user