Packages

A visual, drag & drop content builder for Phoenix LiveView (in the spirit of Elementor): an editor with live canvas, DOM tree, inspector and HTML/CSS view, a JSON document model, daisyUI 5 + Heroicons blocks, safe server-side rendering and generators for custom blocks and templates.

Current section

Files

Jump to
Raw

README.md

# ElementorexCms

**A visual, drag & drop content builder for Phoenix LiveView**, in the spirit of
WordPress' Elementor: build pages, modals or landing sections out of blocks,
with a live canvas that renders your site's own CSS.

- **Editor** (`ElementorexCms.Editor`, a LiveComponent): blocks palette with
  search, **DOM tree**, a **canvas iframe** with your stylesheets (desktop,
  tablet and mobile widths trigger your real media queries), an **inspector**
  with *Content*, *Style* and *Advanced* tabs, **inline text editing** with a
  formatting bar, **drag and drop** between palette, canvas and tree, an
  **HTML / CSS / JSON** drawer, undo/redo with history, templates, keyboard
  shortcuts, image uploads and a media library.
- **34 blocks** built with **daisyUI 5** and **Heroicons** (inline SVG, so any
  icon picked at runtime works): sections, flex containers, responsive grids,
  hero, headings, rich text, buttons, icon boxes, lists, alerts, badges, cards,
  stats, steps, countdown, accordion, tabs, timeline, rating, progress, avatar,
  chat, testimonials, carousel, video, mockups, HTML…
- **Your own blocks** with a tiny DSL (`use ElementorexCms.Block` + `prop`), or
  `mix elementorex.gen.block`.
- **Documents are data**: a JSON tree you keep in a `jsonb` column
  (`ElementorexCms.Ecto.Document`), rendered on the server by
  `<ElementorexCms.Components.document>`.
- **Safe by design**: props are cast and sanitized every time a document is
  rendered, styles go through a per-property grammar, hand-written CSS is nested
  in its element's scope, and HTML is rebuilt from an allow-list. A stored
  document cannot inject script, markup or global CSS.
- **Generators**: a whole CMS (`mix elementorex.gen.cms`), blocks, templates,
  and `mix elementorex.install` to check the setup.
- English and Spanish UI (`ElementorexCms.I18n`), extensible.

## Installation

```elixir
# mix.exs
def deps do
  [
    {:elementorex_cms, "~> 0.1"}
  ]
end
```

Then `mix elementorex.install` checks the JavaScript and CSS steps below and
prints what is missing.

### 1. JavaScript

The JavaScript ships as plain ES modules inside the Hex package; Phoenix's
default esbuild configuration already resolves packages from `deps/`
(`NODE_PATH`). Register the hooks:

```js
// assets/js/app.js (or your admin entry point)
import {hooks as Elementorex} from "elementorex_cms"

const liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: {...colocatedHooks, ...Elementorex}
})
```

`hooks` has `ElementorexEditor` (the editor) and `ElementorexRuntime` (keeps
countdowns ticking on your site). Pages rendered by controllers can call
`initElementorex()` from `"elementorex_cms/runtime"` instead.

### 2. CSS

Blocks use daisyUI 5 and Tailwind classes, so the stylesheet of the pages that
show documents has to scan them:

```css
/* assets/css/app.css */
@import "tailwindcss" source(none);
@source "../../deps/elementorex_cms/lib";
@plugin "../vendor/daisyui";
```

The editor's own stylesheet is plain CSS included by the component; you don't
need to import anything for it (set `inline_css={false}` and import
`priv/static/elementorex.css` if you prefer).

### 3. Content-Security-Policy

The canvas is an `<iframe srcdoc>`: it works with `frame-src 'self'`. Video
embeds on your site need the players in `frame-src`
(`https://www.youtube-nocookie.com https://player.vimeo.com`); on the canvas
they are previews.

## Quick start

Store documents:

```elixir
schema "pages" do
  field :title, :string
  field :body, ElementorexCms.Ecto.Document, default: ElementorexCms.Document.new()
end
```

Edit them:

```heex
<.live_component
  module={ElementorexCms.Editor}
  id="page-editor"
  document={@page.body}
  title={@page.title}
  back={~p"/admin/pages"}
  canvas={ElementorexCms.Canvas.new(stylesheets: [~p"/assets/css/app.css"])}
  locale="es"
/>
```

```elixir
def handle_info({ElementorexCms.Editor, "page-editor", {:save, doc}}, socket) do
  {:ok, page} = Pages.update_page(socket.assigns.page, %{body: doc})
  ElementorexCms.Editor.saved("page-editor")
  {:noreply, assign(socket, :page, page)}
end
```

Show them:

```heex
<div phx-hook="ElementorexRuntime" id="page-body">
  <ElementorexCms.Components.document document={@page.body} />
</div>
```

Or generate all of it: `mix elementorex.gen.cms Pages Page pages`.

## The editor

| Area | What it does |
| --- | --- |
| Top bar | back link, title, your `:status` slot, device (desktop/tablet/mobile), zoom (fit, 50–150%), undo/redo, code drawer, preview, your `:actions` slot, *Save* |
| Left | the searchable palette by category (drag a tile, or click it to add after the selection or inside an empty container) and, below it, *Structure*: the DOM tree, where rows are dragged to reorder or nest. The rail switches to *Templates*, *History* and *Keyboard shortcuts* |
| Canvas | your site's CSS in an iframe; hover to see the block, click to select, double-click text to type, drag the blue chip to move; the path of the selection at the bottom |
| Inspector | *Content* (the block's props), *Style* (typography, spacing, size, background, border, effects, position — per device and for `:hover`), *Advanced* (name, CSS id, classes, hide per device, entrance animation, scoped custom CSS) |
| Code | HTML, CSS and JSON of the selection (or the whole document), with copy |

Shortcuts: ⌘S save · ⌘Z / ⇧⌘Z undo/redo · ⌘D duplicate · ⌘C / ⌘V copy/paste
blocks · ⌫ delete · ↑/↓ select siblings · ⌥↑/⌥↓ move · Esc parent · / search.

### Attributes

`id`, `document`, `title`, `subtitle`, `back`, `canvas`
(`ElementorexCms.Canvas`), `registry` (`ElementorexCms.Registry`), `templates`,
`media` + `media_context` (`ElementorexCms.Media`), `data` (passed to blocks),
`locale`, `notify_changes`, `fullscreen` (default `true`), `inline_css`,
`remount_key`. Slots: `:status`, `:actions`. See `ElementorexCms.Editor`.

### The canvas

```elixir
ElementorexCms.Canvas.new(
  stylesheets: [~p"/assets/css/app.css"],
  html_attrs: %{"data-theme" => "light"},
  body_class: "bg-base-200",
  # the document goes where the frame has data-exr-mount
  frame: ~s(<main class="mx-auto max-w-3xl bg-base-100 p-8" data-exr-mount></main>),
  widths: %{"desktop" => 1280, "tablet" => 768, "mobile" => 390}
)
```

The frame can be a rendered component: in the app this library was born in,
modals are edited inside the real modal of each brand.

## Documents

```elixir
%{
  "version" => 1,
  "root" => %{
    "id" => "Wq0m2Lx9Ka", "type" => "root", "props" => %{}, "style" => %{}, "attrs" => %{},
    "children" => [
      %{
        "id" => "b3Rt9Lm1Qe",
        "type" => "heading",
        "props" => %{"text" => "Hello", "level" => "h2"},
        "style" => %{"desktop" => %{"color" => "var(--color-primary)"}, "mobile" => %{"font-size" => "28px"}},
        "attrs" => %{"hide_on" => ["tablet"], "css" => "selector { letter-spacing: -0.02em }"},
        "children" => []
      }
    ]
  }
}
```

`ElementorexCms.Document` has the pure operations (`insert/5`, `move/5`,
`duplicate/3`, `put_style/5`, `normalize/2`…) and `ElementorexCms.Builder`
builds documents in code:

```elixir
import ElementorexCms.Builder

doc([
  section([
    block("heading", %{text: "Welcome", level: "h1"}),
    columns([[block("feature", %{icon: "bolt"})], [block("feature", %{icon: "heart"})]])
  ])
])
```

`ElementorexCms.to_html/2` renders to a string and `ElementorexCms.to_text/1`
extracts the text (excerpts, search).

## Styles

Styles are declarations per state — `desktop` (base), `tablet`
(`max-width: 1023.98px`), `mobile` (`max-width: 767.98px`) and `hover` — scoped
to the node (`[data-ex-id="…"]`). The breakpoints match Tailwind's `lg` and
`md`, so `max-lg:` / `max-md:` classes in your blocks agree with them. Colors
can be daisyUI theme variables (`var(--color-primary)`), so content follows the
theme of the page it is shown on.

## Custom blocks

```elixir
defmodule MyAppWeb.Blocks.Pricing do
  use ElementorexCms.Block,
    type: "pricing",
    label: "Pricing",
    icon: "currency-dollar",
    category: :components

  prop :plan, :string, default: "Pro", inline: true
  prop :price, :string, default: "$9.990"
  prop :featured, :boolean, default: false

  def render(assigns) do
    ~H"""
    <div {root(assigns, class: ["card bg-base-100 p-6", @featured && "border-2 border-primary"])}>
      <h3 class="text-xl font-bold" {inline(assigns, :plan)}>{@plan}</h3>
      <p class="text-4xl font-black">{@price}</p>
    </div>
    """
  end
end
```

```elixir
config :elementorex_cms, blocks: [MyAppWeb.Blocks.Pricing]
```

See the [blocks guide](guides/blocks.md) for prop types, controls, containers
and the rules for class names.

## Generators

| Task | Creates |
| --- | --- |
| `mix elementorex.gen.cms Pages Page pages` | migration, schema, context, admin list, editor page and public page |
| `mix elementorex.gen.block Pricing` | a block and its test |
| `mix elementorex.gen.template Marketing` | a module of ready-made sections |
| `mix elementorex.install` | nothing: checks the setup and prints what is missing |

## Configuration

```elixir
config :elementorex_cms,
  locale: "es",                          # default UI language
  blocks: [MyAppWeb.Blocks.Pricing],     # added to the built-in ones
  exclude_blocks: ["video"],             # built-in types to leave out
  templates: [MyAppWeb.EditorTemplates.Marketing],
  time_zone: "America/Santiago",         # of countdown targets (needs a tz database, e.g. :tz)
  translations: %{"es" => %{"Pricing" => "Precios"}}
```

## Security model

- Props are cast by type on every write **and** every render; rich text and HTML
  go through `ElementorexCms.HTML.Sanitizer` (allow-list, rebuilt from tokens).
- URLs keep only `http`, `https`, `mailto`, `tel` and relative paths.
- Style values are validated per property; shadows are presets; `url()` only
  takes safe URLs.
- Custom CSS loses `<`, `@import`, `expression()`, `behavior`, `javascript:`
  and unsafe `url()`s, and is nested inside its element with CSS nesting.
- Node ids are validated, so selectors cannot be broken out of.

## Development

```bash
mix deps.get
mix test
```

## License

MIT. Heroicons (in `priv/heroicons`) are MIT by Tailwind Labs.