Andromeda

MDX components

View as Markdown

Use your own Rails partials inside Markdown.

An .mdx file is Markdown that can also call components. In Andromeda a component is a plain Rails partial — there is no component framework to adopt.

Calling a component

import Callout from 'content_components/callout';

<Callout type="warning" title="Careful">
  The body of a component is **Markdown** too.
</Callout>

Writing one

<%# app/views/content_components/_callout.html.erb %>
<aside class="callout callout--<%= local_assigns.fetch(:type, "note") %>">
  <% if local_assigns[:title] %>
    <p class="callout__title"><%= title %></p>
  <% end %>
  <%= content %>
</aside>
  • Props become locals. camelCase props arrive as snake_case.
  • The children of the tag arrive as content, already rendered.
  • <Fragment slot="header">…</Fragment> arrives as a header local.
  • Optional props are read through local_assigns.

content is passed even to a self-closing tag (<Callout />), so a partial that uses strict locals must declare it:

<%# locals: (type: "note", title: nil, content: nil) %>

bin/rails generate andromeda:component Callout type title writes the stub, with content: nil already in its locals.

Querying other entries

A component can look up other entries, such as a card that links to another post:

<%# app/views/content_components/_post_card.html.erb %>
<%# locals: (slug:, content: nil) %>
<% post = Content::Blog.find(slug) %>
<a href="<%= blog_path(post.id) %>">
  <img src="<%= andromeda_image_url(post.cover_image) %>" alt="">
  <%= post.title %>
</a>

During andromeda:build these queries read the source files, so an entry that has not been converted yet is still found, and andromeda_content(post) converts it on the spot. Two entries whose components render each other fail with a “circular conversion” error.

The component’s HTML is stored at build time, before the asset pipeline has stamped any digests, so andromeda_image_url leaves a placeholder there. andromeda_content turns it into the digest-stamped URL when the page is served.

Passing images

Import an image and pass it as a prop, as in Astro:

import photo from './photo.png';

<Figure src={photo} caption="The view from the top" />

The image is published like an :image attribute, and the partial receives an Andromeda::Image:

<%# app/views/content_components/_figure.html.erb %>
<%# locals: (src:, caption: nil, content: nil) %>
<figure>
  <img src="<%= andromeda_image_url(src) %>" class="w-full" alt="">
  <figcaption><%= caption %></figcaption>
</figure>

How a tag is resolved

  1. An explicit registration, if you made one.
  2. The path in the import statement, when it points at a partial.
  3. The naming convention: Callout → app/views/content_components/_callout.html.erb.

A component with no partial is an error that names the file, the line and the path to create — never a silently dropped tag.

Expressions

Literals, {frontmatter.title} and imported images ({photo}) are evaluated; {/* comments */} are dropped. Anything that would need a JavaScript runtime raises an error instead of being ignored, so a page never renders half of what its author wrote.

When components render

Components are expanded during the build, not per request. That makes a page cheap to serve, and means a component cannot depend on the current request — no current_user inside the body. Put per-user markup in the layout, or load it after the fact with a lazy Turbo Frame.

Last updated September 29, 2026