Skip to content

feat: add markdownLinkRenderer hook#145

Open
ajram23 wants to merge 1 commit into
LiYanan2004:mainfrom
ajram23:feat/link-renderer-hook
Open

feat: add markdownLinkRenderer hook#145
ajram23 wants to merge 1 commit into
LiYanan2004:mainfrom
ajram23:feat/link-renderer-hook

Conversation

@ajram23
Copy link
Copy Markdown

@ajram23 ajram23 commented May 28, 2026

Summary

Adds markdownLinkRenderer, mirroring the existing markdownImageRenderer pattern so consumers can inject a custom SwiftUI view for inline markdown links — e.g. favicon-prefixed links, hover tooltips, scheme-specific link UI.

Motivation

MarkdownImageRenderer already lets consumers customize image rendering per URL scheme. Links have no equivalent — every consumer that wants custom link UI (favicons, tooltips, internal scheme handling) has to wrap or fork. This PR fills that symmetric gap with the same API shape.

API

struct MyLinkRenderer: MarkdownLinkRenderer {
    func makeBody(configuration: Configuration) -> some View {
        HStack { faviconFor(configuration.url); configuration.label }
    }
}

MarkdownView(content)
    .markdownLinkRenderer(MyLinkRenderer(), forURLScheme: "https")
    // or, with the "*" wildcard, for ALL schemes:
    .markdownLinkRenderer(MyLinkRenderer())

Configuration exposes url: URL and label: AnyView. Dispatch is by URL scheme with "*" as a wildcard — extends the image-renderer's exact+lowercased-scheme match to also support a catch-all.

Design notes

Env-scoped storage, not a singleton. Renderers live on MarkdownRendererConfiguration.linkRenderers: [String: AnyMarkdownLinkRenderer] and participate in Equatable so CmarkFirstMarkdownViewRenderer's view cache invalidates correctly when a renderer instance changes. The modifier writes via transformEnvironment. No global state. Two callsites registering different renderers for the same scheme stay isolated to their respective subtrees.

(Note: the same global-singleton anti-pattern exists in MarkdownImageRenders upstream. Intentionally only fixing the link side here to minimize divergence — happy to follow up with a matching image-side fix in a separate PR if you'd like.)

Sendable requirement on the protocol. Mirrors SwiftUI's AnyShape pattern (AnyShape requires S: Sendable on init). Required for Swift 6 region-based isolation because AnyMarkdownLinkRenderer's init captures the generic renderer: D into three stored closures.

⚠️ Breaking change for consumers: every type conforming to MarkdownLinkRenderer must now also conform to Sendable. Free for types with no stored properties — synthesis is implicit. New API, so the breakage only affects anyone tracking this branch pre-merge.

Configuration itself is intentionally NOT Sendable because AnyView's Sendable conformance is @available(*, unavailable) per Apple. The protocol is @preconcurrency @MainActor, so the config never crosses an actor boundary in practice.

Files

File Change
Modifiers/MarkdownLinkRendererModifier.swift new — public View.markdownLinkRenderer(_:forURLScheme:)
Renderers/Node Representations/Links/Protocol/MarkdownLinkRenderer.swift new — protocol + Configuration + AnyMarkdownLinkRenderer
Configurations/MarkdownRendererConfiguration.swift adds linkRenderers field, contributes to Equatable
Renderers/Cmark/CmarkNodeVisitor.swift visitLink dispatches to custom renderer if registered for the URL scheme (or "*"); default branch adds .help(url) for the native macOS hover tooltip

170 insertions, 1 deletion. No changes to existing public API surface.

Verification

  • swift build — Build complete!
  • Used in production by an external macOS app (@MainActor SwiftUI consumer) — favicon + URL hover tooltip on inline markdown links in an AI chat surface. Renders thousands of links per session, no observed cache thrash or render glitches.

MarkdownView 3 / #132

I see #132 ("Introducing MarkdownView 3") is in draft. If v3 plans to ship a link-renderer hook with a different architecture, happy to defer this — just let me know. Otherwise this should be useful for everyone on the current 2.x line.

Mirrors the existing markdownImageRenderer pattern so consumers can
inject a custom SwiftUI view for inline markdown links — e.g. to add a
favicon, a hover tooltip, or scheme-specific link UI.

## API

```swift
struct MyLinkRenderer: MarkdownLinkRenderer {
    func makeBody(configuration: Configuration) -> some View {
        HStack { faviconFor(configuration.url); configuration.label }
    }
}

MarkdownView(content)
    .markdownLinkRenderer(MyLinkRenderer(), forURLScheme: "https")
    // or for all schemes:
    .markdownLinkRenderer(MyLinkRenderer())
```

`Configuration` exposes `url: URL` and `label: AnyView`. Dispatch is by
URL scheme with `"*"` as a wildcard fallback (extends the
image-renderer's exact+lowercased-scheme match to also support a
catch-all renderer).

## Design

**Env-scoped, not singleton.** Renderers live on
`MarkdownRendererConfiguration.linkRenderers: [String: AnyMarkdownLinkRenderer]`
and participate in `Equatable` so `CmarkFirstMarkdownViewRenderer`'s
view cache invalidates when a renderer instance changes. The modifier
writes via `transformEnvironment`. No global state. Two callsites
registering different renderers for the same scheme stay isolated to
their respective subtrees — deterministic and testable.

The same global-singleton anti-pattern exists upstream in
`MarkdownImageRenders`; only fixing the link side here to keep
divergence minimal.

**Sendable required on the protocol.** Mirrors SwiftUI's `AnyShape`
pattern (`AnyShape` requires `S: Sendable` on init).
`AnyMarkdownLinkRenderer`'s init captures the generic
`renderer: D` into three stored closures (wrapped + makeBody +
isEqualTo). Without `D: Sendable`, Swift 6 region-based isolation
rejects those captures because the wrapper struct is `Sendable`
(via `@unchecked` to allow `@MainActor` function-typed properties).
Hoisting the requirement onto the protocol makes `D` automatically
`Sendable` and removes the need for a `sending` parameter modifier
that wouldn't have worked across all three capture sites anyway.

**Breaking change:** every type conforming to `MarkdownLinkRenderer`
must also conform to `Sendable`. Free for types with no stored
properties — the synthesis is implicit.

`Configuration` itself is intentionally NOT `Sendable` because
`AnyView`'s `Sendable` conformance is `@available(*, unavailable)`
per Apple. The protocol is `@preconcurrency @MainActor`, so the
config never crosses an actor boundary in practice.

## Files

- `Modifiers/MarkdownLinkRendererModifier.swift` (new) — public
  `View.markdownLinkRenderer(_:forURLScheme:)` extension
- `Renderers/Node Representations/Links/Protocol/MarkdownLinkRenderer.swift`
  (new) — public protocol + `Configuration` + `AnyMarkdownLinkRenderer`
  type erasure
- `Configurations/MarkdownRendererConfiguration.swift` — adds
  `linkRenderers: [String: AnyMarkdownLinkRenderer]` field + Equatable
  contribution
- `Renderers/Cmark/CmarkNodeVisitor.swift` — `visitLink` dispatches to
  custom renderer if registered for the URL scheme (or `"*"`);
  otherwise unchanged. Default branch adds `.help(url)` for the
  native macOS hover tooltip.

## Verification

- `swift build` — Build complete!
- Used in production by an external macOS app (`@MainActor` SwiftUI
  consumer) — favicon + URL hover tooltip on inline markdown links
  in an AI chat surface. Renders thousands of links per session, no
  observed cache thrash or render glitches.

170 insertions, 1 deletion across 4 files.
@ajram23
Copy link
Copy Markdown
Author

ajram23 commented May 28, 2026

@LiYanan2004 Codex app does this and make for better looking links.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant