Skip to main content

Embed Content

to run in your site's root directory
View template source
gem "importmap-rails" unless File.read("Gemfile").include?("importmap")
gem "perron" unless File.read("Gemfile").include?("perron")

after_bundle do
  unless File.exist?("config/importmap.rb")
    rails_command "importmap:install"
  end

  unless File.exist?("config/initializers/perron.rb")
    rails_command "perron:install"
  end

  create_file "app/javascript/components/index.js", <<~JS, skip: true
  import "components/embed_content"
  JS

  application_js_path = "app/javascript/application.js"
  if File.exist?(application_js_path)
    insert_into_file application_js_path, "\nimport \"components\"\n", after: /\A(?:import .+\n)*/
  else
    create_file application_js_path, <<~JS
  import "components"
  JS
  end

  unless File.exist?("config/importmap.rb")
    say "Warning: importmap.rb not found!", :yellow
    say "Please set up importmap-rails first by running:"
    say "  rails importmap:install"
    say "Or use your preferred JavaScript set up."

    return
  end

  insert_into_file "config/importmap.rb", after: /\A.*pin .+\n+/m do
    "\npin_all_from \"app/javascript/components\", under: \"components\"\npin_all_from \"app/javascript/helpers\", under: \"helpers\"\n"
  end

  create_file "app/javascript/components//embed_content.js", <<~'TEXT', force: true
export class EmbedContent extends HTMLElement {
  static get observedAttributes() {
    return ["src", "last-read-at", "limit"]
  }

  connectedCallback() {
    this.#connectToggle()

    this.#fetch()
  }

  async refresh() {
    await this.#fetch()
  }

  show() {
    const items = this.querySelector("[items]")

    if (items) items.removeAttribute("hidden")
  }

  hide() {
    const items = this.querySelector("[items]")

    if (items) items.setAttribute("hidden", "")
  }

  toggle() {
    const items = this.querySelector("[items]")
    if (!items) return

    items.hasAttribute("hidden") ? items.removeAttribute("hidden") : items.setAttribute("hidden", "")
  }

  // private

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return

    if (name === "last-read-at") this.#markUnread()
  }

  #connectToggle() {
    const button = this.querySelector("button[toggle]")
    if (!button) return

    button.addEventListener("click", () => this.toggle())
  }

  async #fetch() {
    const src = this.getAttribute("src")
    if (!src) return

    this.setAttribute("loading", "")

    try {
      const response = await fetch(src)
      const items = await response.json()

      this.#render(items)
      this.#markUnread()
      this.removeAttribute("loading")
    } catch (error) {
      this.setAttribute("error", "")
    }
  }

  #render(items) {
    const container = this.querySelector("[items]")
    if (!container) return

    const limit = parseInt(this.getAttribute("limit"), 10)
    const limited = limit > 0 ? items.slice(0, limit) : items

    container.innerHTML = limited.map((item) => this.template(item)).join("")
  }

  #markUnread() {
    const lastReadAt = this.getAttribute("last-read-at")
    const items = this.querySelectorAll("[published-at]")

    if (!lastReadAt) {
      items.forEach((item) => {
        item.removeAttribute("unread")
      })

      this.removeAttribute("total-unread")

      return
    }

    const lastRead = new Date(lastReadAt).getTime()
    let totalUnread = 0

    items.forEach((item) => {
      const published = new Date(item.getAttribute("published-at")).getTime()

      if (published > lastRead) {
        item.setAttribute("unread", "")

        totalUnread++
      } else {
        item.removeAttribute("unread")
      }
    })

    if (totalUnread > 0) {
      this.setAttribute("total-unread", totalUnread)
    } else {
      this.removeAttribute("total-unread")
    }

    this.#syncBadge()
  }

  #syncBadge() {
    const badge = this.querySelector("[badge]")
    if (!badge) return

    badge.textContent = this.getAttribute("total-unread") || ""
  }

  get template() {
    return (item) => `
      <li published-at="${item.published_at}">
        <h3><a href="${item.url}">${item.title}</a></h3>

        <p>${item.body}</p>
      </li>
    `
  }
}

customElements.define("embed-content", EmbedContent)

TEXT

create_file "app/javascript/components//index.js", <<~'TEXT', force: true
import "components/embed-content"

TEXT

create_file "app/javascript/components/embed_content.js", <<~'TEXT', force: true
export class EmbedContent extends HTMLElement {
  static get observedAttributes() {
    return ["src", "last-read-at", "limit"]
  }

  connectedCallback() {
    this.#connectToggle()

    this.#fetch()
  }

  async refresh() {
    await this.#fetch()
  }

  show() {
    const items = this.querySelector("[items]")

    if (items) items.removeAttribute("hidden")
  }

  hide() {
    const items = this.querySelector("[items]")

    if (items) items.setAttribute("hidden", "")
  }

  toggle() {
    const items = this.querySelector("[items]")
    if (!items) return

    items.hasAttribute("hidden") ? items.removeAttribute("hidden") : items.setAttribute("hidden", "")
  }

  // private

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return

    if (name === "last-read-at") this.#markUnread()
  }

  #connectToggle() {
    const button = this.querySelector("button[toggle]")
    if (!button) return

    button.addEventListener("click", () => this.toggle())
  }

  async #fetch() {
    const src = this.getAttribute("src")
    if (!src) return

    this.setAttribute("loading", "")

    try {
      const response = await fetch(src)
      const items = await response.json()

      this.#render(items)
      this.#markUnread()
      this.removeAttribute("loading")
    } catch (error) {
      this.setAttribute("error", "")
    }
  }

  #render(items) {
    const container = this.querySelector("[items]")
    if (!container) return

    const limit = parseInt(this.getAttribute("limit"), 10)
    const limited = limit > 0 ? items.slice(0, limit) : items

    container.innerHTML = limited.map((item) => this.template(item)).join("")
  }

  #markUnread() {
    const lastReadAt = this.getAttribute("last-read-at")
    const items = this.querySelectorAll("[published-at]")

    if (!lastReadAt) {
      items.forEach((item) => {
        item.removeAttribute("unread")
      })

      this.removeAttribute("total-unread")

      return
    }

    const lastRead = new Date(lastReadAt).getTime()
    let totalUnread = 0

    items.forEach((item) => {
      const published = new Date(item.getAttribute("published-at")).getTime()

      if (published > lastRead) {
        item.setAttribute("unread", "")

        totalUnread++
      } else {
        item.removeAttribute("unread")
      }
    })

    if (totalUnread > 0) {
      this.setAttribute("total-unread", totalUnread)
    } else {
      this.removeAttribute("total-unread")
    }

    this.#syncBadge()
  }

  #syncBadge() {
    const badge = this.querySelector("[badge]")
    if (!badge) return

    badge.textContent = this.getAttribute("total-unread") || ""
  }

  get template() {
    return (item) => `
      <li published-at="${item.published_at}">
        <h3><a href="${item.url}">${item.title}</a></h3>

        <p>${item.body}</p>
      </li>
    `
  }
}

customElements.define("embed-content", EmbedContent)

TEXT
end

Headless component for embedding content from JSON endpoints.

Usage

<embed-content src="http://localhost:3001/posts.json">
  <button toggle>Toggle</button>

  <ul items hidden></ul>
</embed-content>

Attributes

Attribute Description Default
src URL to JSON endpoint -
cache-max-age Cache duration in seconds 86400 (24 hours)
limit Maximum number of items to display - (no limit)
last-read-at ISO timestamp to track read state -

Slots

Selector Description
[toggle] Clickable element to show/hide
[items] Container for rendered items
[badge] Element synced with total-unread count

State Attributes

Attribute When Set
[loading] Fetching content
[error] Fetch failed
[total-unread] Count of unread items

Caching

Content is cached in localStorage to reduce network requests. Set cache-max-age to control freshness:

<embed-content src="/posts.json">
<embed-content src="/posts.json" cache-max-age="3600">
<embed-content src="/posts.json" cache-max-age="0">

Limiting Items

<embed-content src="/posts.json" limit="5">
  <ul items></ul>
</embed-content>

Read State Tracking

<changelog-embed src="/posts.json" last-read-at="2026-03-28T09:00:00Z">
  <button toggle>Changelog <span badge></span></button>

  <ul items hidden></ul>
</changelog-embed>

The total-unread attribute is automatically synced to any [badge] element inside the component.

Extending

import { EmbedContent } from "components/embed-content"

class BlogEmbed extends EmbedContent {
  get template() {
    return (item) => `<article>${item.title}</article>`
  }
}
customElements.define("blog-embed", BlogEmbed)

JSON Response

The src endpoint should return an array of items:

[
  {
    "id": 1,
    "title": "Hello World",
    "body": "Post content...",
    "url": "/posts/hello-world",
    "published_at": "2026-03-28T09:00:00Z"
  }
]