Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ config.use_local_editor = false # or true
## Installation

> [!NOTE]
> This guide assumes that you use ferbe on a standard rails 8.1 app that already has stimulus and importmap set up.
> This guide assumes that you use ferbe on a standard rails 8.0/8.1 app that already has stimulus and importmap set up.

Add this line to your application's Gemfile:

Expand All @@ -91,35 +91,13 @@ $ rails generate ferbe:install

### Layout

In your layout, add these in the head section:
Now, in your layout, add these in the head section:

```erb
<%= ferbe_styles_tag %>
<%= ferbe_javascript_tag %>
```

Add the `ferbe_editor_tag` and wrap everything in an element with the `ferbe__page` class.

```erb
<div class="ferbe__page">
<main>
<%= yield %>
</main>

<%= ferbe_editor_tag %>
</div>
```

### Add the Stimulus controller

The final step is to add the provided Stimulus controller to your `javascript/controllers/index.js`:

```js
import FerbeEditorController from "ferbe/controllers/ferbe_editor_controller";
// ...
application.register("ferbe-editor", FerbeEditorController);
```

---

Copyright 2026 by Renuo AG
5 changes: 3 additions & 2 deletions app/assets/javascripts/ferbe/application.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FerbeTemplate from "ferbe/elements/ferbe_template";
import { application } from "controllers/application";
import FerbeEditorController from "ferbe/controllers/ferbe_editor_controller";

customElements.define("ferbe-template", FerbeTemplate);
application.register("ferbe-editor", FerbeEditorController);
50 changes: 25 additions & 25 deletions app/assets/javascripts/ferbe/controllers/ferbe_editor_controller.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import editorUrl from "ferbe/utils/editor_url";

import { Controller } from "@hotwired/stimulus";
import { CodeJar } from "codejar";
import hljs from "highlight.js/lib/core";
Expand All @@ -6,23 +8,41 @@ import xml from "highlight.js/lib/languages/xml";
import ruby from "highlight.js/lib/languages/ruby";

export default class FerbeEditorController extends Controller {
static targets = ["editor", "form", "input", "errorContainer"];
static targets = ["editor", "form", "input"];

connect() {
this.#setupHighlighting();
this.#highlight(this.editorTarget);
this.#preventUnsavedClosing();
this.#setupErrorHandling();
}

disconnect() {
this.jar.destroy();
window.onbeforeunload = null;
this.errorContainerTarget.innerHTML = "";
}

close() {
this.element.remove();
open(event) {
const template = event.detail;

const currentParams = new URLSearchParams(document.location.search);
const url = currentParams.get("url");

const params = new URLSearchParams();
params.append("url", url);
params.append("template[path]", template.filePath);
template.renderPath.forEach((p) =>
params.append("template[render_path][]", p),
);

fetch(`/ferbe/template/edit?${params.toString()}`, {
headers: { Accept: "text/vnd.turbo-stream.html" },
})
.then((r) => r.text())
.then((html) => {
Turbo.renderStreamMessage(html);
window.history.pushState({}, "", editorUrl(template));
})
.catch((err) => console.error("Failed to open editor:", err));
}

save() {
Expand Down Expand Up @@ -58,24 +78,4 @@ export default class FerbeEditorController extends Controller {
return "There are unsaved changes in the ferbe editor.";
};
}

#setupErrorHandling() {
addEventListener("turbo:before-fetch-response", (event) => {
const response = event.detail.fetchResponse;
if (response.statusCode !== 500) return;

event.preventDefault();
document.documentElement.removeAttribute("aria-busy");
this.#displayError({
message: `${response.statusCode} ${response.response.statusText}`,
url: response.response.url,
});
});
}

#displayError({ message, url }) {
this.errorContainerTarget.innerHTML = `<strong>There is an error that was likely caused by your edit:</strong>
${message}
<a href="${url}" target="_blank">Open in new tab</a>`;
}
}
22 changes: 9 additions & 13 deletions app/assets/javascripts/ferbe/elements/ferbe_template.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,15 @@ export default class FerbeTemplate extends HTMLElement {
}

#openInEditor() {
const path = this.getAttribute("path");
const renderPath = this.#getRenderPath();

const params = new URLSearchParams();
params.append("template[path]", path);
renderPath.forEach((p) => params.append("template[render_path][]", p));

fetch(`/ferbe/template/edit?${params.toString()}`, {
headers: { Accept: "text/vnd.turbo-stream.html" },
})
.then((r) => r.text())
.then((html) => Turbo.renderStreamMessage(html))
.catch((err) => console.error("Failed to open editor:", err));
window.top.dispatchEvent(
new CustomEvent("ferbe:open-editor", {
detail: {
url: window.location.toString(),
filePath: this.getAttribute("path"),
renderPath: this.#getRenderPath(),
},
}),
);
}

#getRenderPath() {
Expand Down
12 changes: 12 additions & 0 deletions app/assets/javascripts/ferbe/host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import FerbeTemplate from "ferbe/elements/ferbe_template";
import editorUrl from "ferbe/utils/editor_url";

customElements.define("ferbe-template", FerbeTemplate);

addEventListener("ferbe:open-editor", (event) => {
const editor = document.getElementById("ferbe-editor");
if (editor) return;

const template = event.detail;
window.location.href = editorUrl(template);
});
11 changes: 11 additions & 0 deletions app/assets/javascripts/ferbe/utils/editor_url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function editorUrl(template) {
const params = new URLSearchParams();

params.append("url", template.url);
params.append("template[path]", template.filePath);
template.renderPath.forEach((p) =>
params.append("template[render_path][]", p),
);

return `/ferbe/template/edit?${params.toString()}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are still static paths.

}
30 changes: 13 additions & 17 deletions app/assets/stylesheets/ferbe/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@
--ferbe-color-light-grey: #fafafa;
--ferbe-color-grey: #dedede;
--ferbe-color-blue: #0033b3;
--ferbe-color-red: #f6817f;
--ferbe-color-light-red: #ff9b99;
--ferbe-border: 2px solid var(--ferbe-color-grey);
}

body {
margin: 0;
}

.ferbe__page {
display: flex;
height: 100vh;
min-height: 100vh;

& > main {
iframe {
all: unset;
flex-grow: 1;
}

.ferbe__editor * {
all: revert;
}

.ferbe__editor > div {
width: min(50vw, 60rem);
padding: 1rem;
Expand Down Expand Up @@ -55,6 +54,12 @@
}
}

a.ferbe__button {
box-sizing: border-box;
text-decoration: none;
text-align: center;
}

.ferbe__header {
display: flex;
align-items: baseline;
Expand Down Expand Up @@ -83,14 +88,5 @@
}
}
}

.ferbe__error-container {
border-color: var(--ferbe-color-red);
background-color: var(--ferbe-color-light-red);

&:empty {
display: none;
}
}
}
}
3 changes: 3 additions & 0 deletions app/assets/stylesheets/ferbe/host.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ferbe-template {
display: contents;
}
22 changes: 13 additions & 9 deletions app/controllers/ferbe/templates_controller.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
module Ferbe
class TemplatesController < ApplicationController
def edit
template_params = params.require(:template).permit(:path, render_path: [])
return head :bad_request unless valid_path? template_params[:path]
template = edit_params[:template]
return head :bad_request unless valid_path? template[:path]

@template = {
content: File.read(template_params[:path]),
path: template_params[:path],
render_path: template_params[:render_path]
content: File.read(template[:path]),
path: template[:path],
render_path: template[:render_path]
}

respond_to do |format|
format.turbo_stream
format.html { head :no_content }
end
@url = edit_params[:url]
end

# :nocov: -> covered by manual system tests
Expand All @@ -40,6 +37,13 @@ def update

private

def edit_params
{
template: params.require(:template).permit(:path, render_path: []),
url: params.require(:url)
}
end

def valid_path?(path)
expanded_path = File.expand_path path

Expand Down
33 changes: 33 additions & 0 deletions app/views/ferbe/templates/_editor.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<%# locals: (url:, template:) %>

<div data-controller="ferbe-editor"
data-action="ferbe:open-editor@window->ferbe-editor#open">
<div class="ferbe__header">
<%= link_to "❌", url, class: "ferbe__button" %>
<h2><%= Pathname.new(template[:path]).relative_path_from(Rails.root) %></h2>
</div>
<pre class="ferbe__render-path">
<% template[:render_path].each_with_index do |path, level| %>
<%= " " * level %>> <%= link_to(Pathname.new(path).relative_path_from(Rails.root),
edit_template_url(url: ,
template: {
path: path,
render_path: template[:render_path]
}),
data: { turbo_stream: true }) %>
<% end %>
</pre>
<%= form_with url: template_url,
method: :patch,
scope: :template,
data: { ferbe_editor_target: "form" } do |form| %>
<%= form.hidden_field :path, value: template[:path] %>
<%= form.hidden_field :content,
value: template[:content],
data: { ferbe_editor_target: "input" } %>
<pre><code data-ferbe-editor-target="editor"
data-action="keydown.ctrl+s->ferbe-editor#save:prevent
keydown.meta+s->ferbe-editor#save:prevent"><%= template[:content] %></code></pre>
<%= form.submit "💾", class: "ferbe__button ferbe__save-button" %>
<% end %>
</div>
11 changes: 11 additions & 0 deletions app/views/ferbe/templates/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="ferbe__page">
<iframe src="<%= @url %>"></iframe>

<%= tag.div class: "ferbe__editor", id: "ferbe-editor", data: {
modifier_key: Ferbe.configuration.modifier_key,
use_local_editor: Ferbe.configuration.use_local_editor,
turbo_permanent: true
} do %>
<%= render partial: "editor", locals: { url: @url, template: @template } %>
<% end %>
</div>
31 changes: 1 addition & 30 deletions app/views/ferbe/templates/edit.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -1,32 +1,3 @@
<%= turbo_stream.update "ferbe-editor" do %>
<div data-controller="ferbe-editor">
<div class="ferbe__header">
<button data-action="ferbe-editor#close" class="ferbe__button">❌</button>
<h2><%= Pathname.new(@template[:path]).relative_path_from(Rails.root) %></h2>
</div>
<pre class="ferbe__render-path">
<% @template[:render_path].each_with_index do |path, level| %>
<%= " " * level %>> <%= link_to( Pathname.new(path).relative_path_from(Rails.root),
edit_template_url(template: {
path: path,
render_path: @template[:render_path]
}),
data: { turbo_stream: true }) %>
<% end %>
</pre>
<%= form_with url: template_url,
method: :patch,
scope: :template,
data: { ferbe_editor_target: "form" } do |form| %>
<%= form.hidden_field :path, value: @template[:path] %>
<%= form.hidden_field :content,
value: @template[:content],
data: { ferbe_editor_target: "input" } %>
<pre><code data-ferbe-editor-target="editor"
data-action="keydown.ctrl+s->ferbe-editor#save:prevent
keydown.meta+s->ferbe-editor#save:prevent"><%= @template[:content] %></code></pre>
<%= form.submit "💾", class: "ferbe__button ferbe__save-button" %>
<% end %>
<pre class="ferbe__error-container" data-ferbe-editor-target="errorContainer"></pre>
</div>
<%= render partial: "editor", locals: { url: @url, template: @template } %>
<% end %>
21 changes: 21 additions & 0 deletions app/views/layouts/ferbe/application.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Ferbe Editor</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

<%= yield :head %>

<%= javascript_importmap_tags %>
<%= javascript_import_module_tag "ferbe/application" %>

<%= stylesheet_link_tag "ferbe/highlight", media: "all" %>
<%= stylesheet_link_tag "ferbe/application", media: "all" %>
</head>
<body>

<%= yield %>

</body>
</html>
Loading