Lynx: Dynamic Linking

Automatically embed links in content uploaded by your users. Learn how to generate link previews for articles and other rich media.

Installation

Add Lynx to your dependencies in mix.exs:

defp deps do
  [
    # Other deps
    {:lynx, "~> 0.1", organization: "equip"}
  ]
end

Note: In fresh Phoenix projects, :floki is added as a :test dependency. To install Lynx, you may have to change {:floki, ">= 0.0.0", only: :test} to {:floki, ">= 0.0.0"}.

Basic usage

At it’s simplest, Lynx is used to, well, render links. We can change URLs in a body of text to links using Lynx.Text:

iex(1)> Lynx.HTML.linkify_text("Take me to example.com!")
"Take me to <a href=\"http://example.com\">example.com</a>!"

The linkify_text/2 function also accepts arguments that give the user more control over how links are rendered, for example:

iex(1)> Lynx.HTML.linkify_text "Take me to example.com!",
...(1)>   process_href: &Routes.exit_path(@conn, :exit, path: &1)
"Take me to <a href=\"http://myapp.com/exit?path=example.com\">example.com</a>!"

(The above examples will actually render iodata, not binaries. Binaries are shown for readability.)

For all options, see the docs.

Configuration

What if we want to add other, special kinds of links to our app? With Lynx, you can customize what is parsed and how values are formatted.

config :lynx,
  parser: MyApp.Lynx.CustomParser,
  formatter: MyApp.Lynx.CustomFormatter

Example parsing module:

defmodule MyApp.Lynx.CustomParser do
  use Lynx.Parser,
    strategies: [
      Lynx.Parser.strategy(:link),
      Lynx.Parser.strategy(:mention),
      cashtag: ~r{\$\w+}u
    ]
end

Parsing strategies are simply a key paired with a RegExp for isolating values. Strategies take preference in the order they’re defined.

defmodule MyApp.Lynx.CustomParser do
  use Lynx.Parser,
    strategies: [
      Lynx.Parser.strategy(:link),
      Lynx.Parser.strategy(:mention),
      cashtag: ~r{\$\w+}u
    ]
end

A custom formatter must implement functions to handle each strategy defined in the parser.

defmodule MyApp.Lynx.CustomFormatter do
  def format({:cashtag, value}), do: String.upcase(value)
end

Link Previews

Lynx can help to generate “link previews” for any links that are submitted to your application. To get started, run the generator:

mix lynx.create MyContext

This will produce the following results:

* creating lib/my_app/my_context/link_preview.ex
* creating priv/repo/migrations/20210115215417_create_link_previews.exs
* creating lib/my_app/my_context/link_preview_pub_sub.ex
* creating lib/my_app/my_context.ex
* injecting lib/my_app/my_context.ex

Take a look at link_preview.ex, this is the schema we will be using to persist new link previews to the database.

In my_context.ex (or, however you named your context), you can see that helper functions have been created for managing link previews. These functions will be used by Lynx.

The link_preview_pub_sub.ex file contains a module for assisting with how your app is notified about inserted and updated link previews.

Next, let’s add the LinkPreview.Server to our supervision tree:

children = [
  # Other children
  {Lynx.LinkPreview.Server, context_module: MyApp.MyContext}
]

The context module must be configured either here or in the :lynx config.

Now we can submit text to the server. It will parse out any links and send them to the default LinkPreview.Client. Here, we’ll submit a Post to the link preview server. By default, submit_resource/1 looks for a :text field.

def create_post(user = %User{}, attrs) do
  %Post{}
  |> Post.changeset(attrs)
  |> Ecto.Changeset.put_assoc(:user, user)
  |> Repo.insert()
  |> Lynx.LinkPreview.Server.submit_resource()
end

Our context module functions are already set up to broadcast updates when link previews are inserted or updated, so to add real-time link updating, we have to listen to these updates in our LiveView.

defmodule MyAppWeb.PostLive do
  use MyAppWeb, :live_view

  alias MyApp.MyContext
  alias MyApp.MyContext.LinkPreview

  @impl true
  def mount(%{"id" => post_id}, _session, socket) do
    post = MyContext.get_post!(post_id)

    # If connected to the live view, subscribe to link previews for this post
    if socket.connected? do
      LinkPreview.PubSub.subscribe_link_preview(post)
    end

    {:ok, assign(socket, :post, post)}
  end

  @impl true
  def handle_info({:link_preview, _message}, socket) do
    # Reload the post (with link previews)
    post = MyContext.get_post!(socket.assigns.post.id)

    {:noreply, assign(socket, :post, post)}
  end
end

It’s possible to configure the client that handles fetching data for links. By default, pages are fetched and then parsed following the Open Graph protocol.

config :lynx,
  client: [
    strategy: Lynx.LinkPreview.OpenGraphClient
  ]