SEO.LLMs behaviour (SEO v0.2.1)

Copy Markdown View Source

Serve an /llms.txt file per the llmstxt.org spec.

The llms.txt file provides LLM-friendly content about your site: a title, summary, and categorized links to key pages — structured progressively so LLMs can stop reading early and still have useful context.

Quick start

  1. Add "md" to your router pipeline and forward /llms.txt:

     pipeline :browser do
       plug :accepts, ["html", "md"]
     end
    
     forward "/llms.txt", SEO.LLMs,
       config: MyAppWeb.SEO,
       provider: MyAppWeb.LLMsProvider
  2. Create markdown view modules (FooMD) that implement this behaviour:

     defmodule MyAppWeb.ArticleMD do
       @behaviour SEO.LLMs
       use MyAppWeb, :verified_routes
    
       def show(%{article: article}) do
         """
         # #{article.title}
    
         #{article.body}
         """
       end
    
       @impl SEO.LLMs
       def entry(article) do
         SEO.LLMs.Entry.build(
           section: "Articles",
           title: article.title,
           url: ~p"/articles/#{article.slug}",
           description: article.summary
         )
       end
     end
  3. Register the markdown view in your controllers:

     defmodule MyAppWeb.ArticleController do
       use MyAppWeb, :controller
    
       plug :put_view, html: MyAppWeb.ArticleHTML, md: MyAppWeb.ArticleMD
    
       def show(conn, %{"slug" => slug}) do
         article = Blog.get_article_by_slug!(slug)
         render(conn, :show, article: article)
         # html → ArticleHTML.show/1
         # md   → ArticleMD.show/1
       end
     end
  4. Create a provider that assembles the llms.txt index:

     defmodule MyAppWeb.LLMsProvider do
       @behaviour SEO.LLMs.Provider
       use MyAppWeb, :verified_routes
    
       alias SEO.LLMs.Entry
    
       @impl true
       def sections do
         articles = MyApp.Blog.list_published()
    
         entries = Enum.map(articles, &MyAppWeb.ArticleMD.entry/1)
         dynamic = Entry.group_by_section(entries)
    
         static = [
           {"Docs", [
             {"About", ~p"/about", "What this site covers"}
           ]}
         ]
    
         static ++ dynamic
       end
     end

How it works

Phoenix resolves view modules by format: ArticleHTML for HTML, ArticleJSON for JSON, ArticleMD for markdown. A single render(conn, :show, article: article) call dispatches to the right view based on content negotiation.

Your FooMD modules serve double duty:

  • Phoenix view functions (show/1, index/1, etc.) render full markdown content when the "md" format is requested
  • The entry/1 callback provides metadata for the llms.txt index — section, title, URL, and description

The provider collects entries from your MD modules and groups them into sections. The Plug renders the final llms.txt file, pulling the site title and description from your existing SEO config.

Using MDEx with Phoenix

MDEx is a fast markdown library for Elixir that pairs naturally with FooMD view modules. It provides a ~MD sigil for markdown templates — the markdown equivalent of HEEx's ~H sigil for HTML.

FormatView moduleTemplate engineSigil
HTMLFooHTMLHEEx~H
JSONFooJSONPlain maps
MarkdownFooMDMDEx~MD

Setup

Add MDEx to your dependencies:

{:mdex, "~> 0.12"}

The ~MD sigil

The ~MD sigil with the MD modifier outputs CommonMark markdown (not HTML). It supports assigns ({@var}) and expressions (<%= ... %>), and is processed at compile time for performance:

defmodule MyAppWeb.ArticleMD do
  @behaviour SEO.LLMs
  use MyAppWeb, :verified_routes
  import MDEx.Sigil

  def show(assigns) do
    ~MD"""
    # {@article.title}

    > Published {@article.date}

    {@article.body}

    ## Related

    <%= for tag <- @article.tags do %>
    - #{tag}
    <% end %>
    """MD
  end

  @impl SEO.LLMs
  def entry(article) do
    SEO.LLMs.Entry.build(
      section: "Articles",
      title: article.title,
      url: ~p"/articles/#{article.slug}",
      description: article.summary
    )
  end
end

See MDEx.Sigil for the full list of modifiers and options.

When to use the sigil vs string interpolation

The ~MD sigil processes templates at compile time, which makes it ideal for views with structured, known layouts — like documentation pages or about pages. For DB-backed content where the body is already markdown (like a blog post stored as markdown), plain string interpolation is simpler:

# Compile-time template — good for structured pages
def show(assigns) do
  ~MD"""
  # {@page.title}

  {@page.body}
  """MD
end

# Runtime interpolation — good for DB-backed markdown content
def show(%{article: article}) do
  """
  # #{article.title}

  #{article.body}
  """
end

Converting HTML content to markdown

If your content is stored as HTML and you need to serve it as markdown, MDEx can parse and re-render it:

def show(%{article: article}) do
  article.html_body
  |> MDEx.parse_document!()
  |> MDEx.to_markdown!()
end

Plug options

  • :title — H1 heading. Falls back to open_graph.site_name from config.
  • :description — Blockquote summary. Falls back to site.description from config.
  • :body — Optional prose between the summary and sections.
  • :sections — Static list of {section_name, entries} tuples.
  • :provider — Module implementing SEO.LLMs.Provider for dynamic sections.
  • :config — Your use SEO module or config map, used to derive title/description.

Static sections (without a provider)

For simple sites you can skip the provider and declare sections inline:

forward "/llms.txt", SEO.LLMs,
  config: MyAppWeb.SEO,
  sections: [
    {"Docs", [
      {"API Reference", "/docs/api", "Full REST API docs"},
      {"Guides", "/docs/guides"}
    ]},
    {"Optional", [
      {"Changelog", "/changelog"}
    ]}
  ]

Summary

Callbacks

Callback for markdown view modules (FooMD) to provide llms.txt entries.

Functions

Render the llms.txt markdown string from a map of options.

Callbacks

entry(term)

@callback entry(term()) :: SEO.LLMs.Entry.t() | [SEO.LLMs.Entry.t()] | nil

Callback for markdown view modules (FooMD) to provide llms.txt entries.

Receives a resource and returns an SEO.LLMs.Entry, a list of entries, or nil. Used by SEO.LLMs.Provider implementations to build the llms.txt index from your view modules.

Functions

render(opts)

@spec render(map()) :: String.t()

Render the llms.txt markdown string from a map of options.

Expects a map with:

  • :title (required) — the H1 heading
  • :description (optional) — the blockquote summary
  • :body (optional) — prose content between summary and sections
  • :sections — list of {name, entries} tuples