ruby_cms 0.2.0.1 → 0.2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f997170922c55751d5a99a10253efbeaa4cc8c7f82ffa18e7083b5a932cacc26
4
- data.tar.gz: 179a045dd39b70960801f6b3901e218bc4103ffd767dbc48202d9722c2467d67
3
+ metadata.gz: 6812068b183da96c729d26ae664a39d599aea509a17a11ac0851b715c652cdcf
4
+ data.tar.gz: b087d0fe4f6bb7af6db0aef47e195e7f4f179ab65476a2b6f1048bef514444ab
5
5
  SHA512:
6
- metadata.gz: aa897c7d17c24754544357dea2d82797506f11623a4125fc1ca49653f9daa21aeed9e97c703016efee4a8003a64b8ee4f4060bedcbe25977b80c83268f633f62
7
- data.tar.gz: 7245bb0f650775224383ac1d4588241fe5f084713525a13003706ce27acc25dff00a6dd485cefb816b7faec02a4f03d66c89f3528a7ab39ee9feca9925205687
6
+ metadata.gz: e0f9ca986cdc2d3c5caa5681261e49d015940c9e19be90406e341fffa215c5d1092ae9f47a30d64a39db9a221d2add4e6caac4eccaa3b79f9a8d3afd8e79343f
7
+ data.tar.gz: 5043cc31c3d8adcd61480d3fafc64b851d468d57ee7daf6e251793150c663c4d6cae9c94e43a880bfbd940d414b38408a7d32148bcd63db18404cf8e8b579c07
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0.3] - 2026-04-02
4
+
5
+ - Whole repo was scanned for comiling so it was slow.
6
+
7
+ ## [0.2.0.2] - 2026-04-02
8
+
9
+ - Add commands page
10
+
3
11
  ## [0.2.0.1] - 2026-04-02
4
12
 
5
13
  - Fix compile
data/README.md CHANGED
@@ -493,9 +493,27 @@ rails ruby_cms:css:compile_gem
493
493
  ### Faster Docker Builds (`assets:precompile`)
494
494
 
495
495
  `assets:precompile` loads the Rails app in production mode and can be slow in Docker/Fly builds.
496
- RubyCMS now skips non-asset runtime initializers during this phase (navigation registration,
496
+ RubyCMS skips non-asset runtime initializers during this phase (navigation registration,
497
497
  dashboard registration, versioning hook, settings import, and permission seeding), which reduces
498
- precompile overhead.
498
+ precompile overhead. Detection uses `ARGV` (not only `$PROGRAM_NAME`) so it still applies when the
499
+ Ruby program name is `ruby` instead of `rails`.
500
+
501
+ **Tailwind CSS:** Do not add multiple `@source` globs that point at the same RubyCMS gem (e.g. both
502
+ `../../../../gems/ruby_cms/...` and `/usr/local/bundle/.../ruby_cms-*`). That scans the engine twice
503
+ and slows `tailwindcss:build` a lot. Instead, rely on [tailwindcss-rails engine support](https://github.com/rails/tailwindcss-rails#rails-engines-support-experimental):
504
+
505
+ 1. RubyCMS ships `app/assets/tailwind/ruby_cms_engine/engine.css` with `@source` paths relative to the gem
506
+ (the directory matches Rails’ `engine_name` for `RubyCms::Engine`, which is `ruby_cms_engine`).
507
+ 2. `rails tailwindcss:engines` (run automatically before `tailwindcss:build`) generates
508
+ `app/assets/builds/tailwind/ruby_cms_engine.css` in the host app.
509
+ 3. In the host’s `app/assets/tailwind/application.css`, add **once**:
510
+
511
+ ```css
512
+ @import "../builds/tailwind/ruby_cms_engine";
513
+ ```
514
+
515
+ Place it next to your other `@import` lines (e.g. after `@import "tailwindcss";`). Remove any
516
+ hand-written `@source` lines aimed at the RubyCMS gem.
499
517
 
500
518
  Recommended Docker layer order:
501
519
 
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ class CommandsController < BaseController
6
+ cms_page :commands
7
+
8
+ # HTML: POST/redirect/GET — URL blijft /settings/commands (geen /run in de adresbalk).
9
+ RUN_CACHE_PREFIX = "ruby_cms/commands/run/v1/"
10
+ FLASH_RUN = :ruby_cms_commands_run
11
+
12
+ def index
13
+ @commands = visible_commands
14
+ consume_run_payload_from_flash
15
+ end
16
+
17
+ def run
18
+ key = params.permit(:key)[:key].presence || params.dig(:command, :key)
19
+ cmd = RubyCms.find_command(key)
20
+ unless cmd
21
+ return respond_to do |format|
22
+ format.html do
23
+ redirect_to ruby_cms_admin_settings_commands_path,
24
+ alert: t("ruby_cms.admin.commands.unknown", default: "Unknown command.")
25
+ end
26
+ format.json { render json: { error: "Unknown command" }, status: :not_found }
27
+ end
28
+ end
29
+
30
+ require_permission!(cmd[:permission])
31
+
32
+ output = utf8_text(RubyCms::CommandRunner.run_rake(cmd[:rake_task]))
33
+ log_tail = utf8_text(RubyCms::CommandRunner.tail_log)
34
+
35
+ respond_to do |format|
36
+ format.html do
37
+ token = persist_run_payload_for_redirect(output: output, log_tail: log_tail)
38
+ redirect_to ruby_cms_admin_settings_commands_path,
39
+ status: :see_other,
40
+ flash: { FLASH_RUN => token }
41
+ end
42
+ format.json do
43
+ render json: {
44
+ command_output: output,
45
+ app_log_tail: log_tail
46
+ }
47
+ end
48
+ end
49
+ rescue StandardError => e
50
+ Rails.logger.error("[RubyCMS] Commands#run: #{e.class}: #{e.message}")
51
+ respond_to do |format|
52
+ format.html do
53
+ token = persist_run_payload_for_redirect(error: utf8_text(e.message))
54
+ redirect_to ruby_cms_admin_settings_commands_path,
55
+ status: :see_other,
56
+ flash: { FLASH_RUN => token }
57
+ end
58
+ format.json { render json: { error: e.message }, status: :unprocessable_entity }
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def persist_run_payload_for_redirect(output: nil, log_tail: nil, error: nil)
65
+ payload = { output: output, log_tail: log_tail, error: error }.compact
66
+ token = SecureRandom.urlsafe_base64(32)
67
+ if rails_cache_null_store?
68
+ session[session_run_key(token)] = payload
69
+ else
70
+ Rails.cache.write("#{RUN_CACHE_PREFIX}#{token}", payload, expires_in: 15.minutes)
71
+ end
72
+ token
73
+ end
74
+
75
+ def consume_run_payload_from_flash
76
+ token = flash[FLASH_RUN]
77
+ return if token.blank?
78
+
79
+ payload = load_run_payload(token)
80
+ assign_run_ivars_from_payload(payload) if payload.is_a?(Hash)
81
+ end
82
+
83
+ def load_run_payload(token)
84
+ if rails_cache_null_store?
85
+ session.delete(session_run_key(token))
86
+ else
87
+ key = "#{RUN_CACHE_PREFIX}#{token}"
88
+ data = Rails.cache.read(key)
89
+ Rails.cache.delete(key) if data
90
+ data
91
+ end
92
+ end
93
+
94
+ def assign_run_ivars_from_payload(payload)
95
+ p = payload.with_indifferent_access
96
+ @run_error = utf8_text(p[:error]) if p[:error].present?
97
+ @run_command_output = utf8_text(p[:output]) if p.key?(:output)
98
+ @run_app_log_tail = utf8_text(p[:log_tail]) if p.key?(:log_tail)
99
+ end
100
+
101
+ def rails_cache_null_store?
102
+ Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
103
+ end
104
+
105
+ def session_run_key(token)
106
+ "#{RUN_CACHE_PREFIX}#{token}"
107
+ end
108
+
109
+ # Rake / log IO is often ASCII-8BIT; ERB buffers are UTF-8 — normalize before render/JSON.
110
+ def utf8_text(value)
111
+ return +"" if value.nil?
112
+
113
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
114
+ end
115
+
116
+ def visible_commands
117
+ RubyCms.registered_commands.select {|c| current_user_cms&.can?(c[:permission]) }
118
+ .sort_by {|c| [ c[:label].to_s.downcase, c[:key] ] }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,72 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["commandOutput", "appLog"];
5
+ static values = { runUrl: String };
6
+
7
+ async run(event) {
8
+ const key =
9
+ event.currentTarget?.dataset?.commandKey || event.params?.key;
10
+ const runUrl = this.runUrlValue?.trim?.() || this.runUrlValue;
11
+
12
+ if (!key || !runUrl) {
13
+ const msg =
14
+ !runUrl
15
+ ? "Missing run URL (check data-ruby-cms--admin-commands-run-url-value)."
16
+ : "Missing command key on the button.";
17
+ if (this.hasCommandOutputTarget) {
18
+ this.commandOutputTarget.textContent = msg;
19
+ } else {
20
+ console.error("[ruby-cms--admin-commands]", msg);
21
+ }
22
+ return;
23
+ }
24
+
25
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
26
+ const button = event.currentTarget;
27
+ button.disabled = true;
28
+
29
+ if (this.hasCommandOutputTarget) {
30
+ this.commandOutputTarget.textContent = "Running…";
31
+ }
32
+ if (this.hasAppLogTarget) {
33
+ this.appLogTarget.textContent = "…";
34
+ }
35
+
36
+ try {
37
+ const response = await fetch(runUrl, {
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ Accept: "application/json",
42
+ "X-CSRF-Token": token || "",
43
+ },
44
+ body: JSON.stringify({ key }),
45
+ });
46
+
47
+ const data = await response.json().catch(() => ({}));
48
+
49
+ if (!response.ok) {
50
+ const err = data.error || `Request failed (${response.status})`;
51
+ if (this.hasCommandOutputTarget) {
52
+ this.commandOutputTarget.textContent = err;
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (this.hasCommandOutputTarget) {
58
+ this.commandOutputTarget.textContent =
59
+ data.command_output || "(no output)";
60
+ }
61
+ if (this.hasAppLogTarget) {
62
+ this.appLogTarget.textContent = data.app_log_tail || "(no log lines)";
63
+ }
64
+ } catch (e) {
65
+ if (this.hasCommandOutputTarget) {
66
+ this.commandOutputTarget.textContent = e?.message || String(e);
67
+ }
68
+ } finally {
69
+ button.disabled = false;
70
+ }
71
+ }
72
+ }
@@ -12,6 +12,7 @@ import ClickableRowController from "ruby_cms/clickable_row_controller";
12
12
  import AutoSavePreferenceController from "ruby_cms/auto_save_preference_controller";
13
13
  import NavOrderSortableController from "ruby_cms/nav_order_sortable_controller";
14
14
  import ContentBlockHistoryController from "ruby_cms/content_block_history_controller";
15
+ import AdminCommandsController from "ruby_cms/admin_commands_controller";
15
16
 
16
17
 
17
18
  export {
@@ -26,10 +27,16 @@ export {
26
27
  AutoSavePreferenceController,
27
28
  NavOrderSortableController,
28
29
  ContentBlockHistoryController,
30
+ AdminCommandsController,
29
31
  };
30
32
 
31
33
  // Helper function to register all RubyCms controllers with a Stimulus application
34
+ const registeredApplications = new WeakSet();
35
+
32
36
  export function registerRubyCmsControllers(application) {
37
+ if (!application || typeof application.register !== "function") return;
38
+ if (registeredApplications.has(application)) return;
39
+
33
40
  application.register("ruby-cms--visual-editor", VisualEditorController);
34
41
  application.register("ruby-cms--page-preview", PagePreviewController);
35
42
  application.register("ruby-cms--mobile-menu", MobileMenuController);
@@ -41,6 +48,7 @@ export function registerRubyCmsControllers(application) {
41
48
  application.register("ruby-cms--toggle", ToggleController);
42
49
  application.register("ruby-cms--locale-tabs", LocaleTabsController);
43
50
  application.register("ruby-cms--content-block-history", ContentBlockHistoryController);
51
+ application.register("ruby-cms--admin-commands", AdminCommandsController);
44
52
  application.register("clickable-row", ClickableRowController);
45
53
  application.register(
46
54
  "ruby-cms--auto-save-preference",
@@ -50,6 +58,8 @@ export function registerRubyCmsControllers(application) {
50
58
  "ruby-cms--nav-order-sortable",
51
59
  NavOrderSortableController,
52
60
  );
61
+
62
+ registeredApplications.add(application);
53
63
  }
54
64
 
55
65
  // Auto-register controllers when this module is imported
@@ -1,7 +1,19 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["tab"];
4
+ static targets = ["tab", "localeLabel"];
5
+
6
+ connect() {
7
+ const activeTab = this.tabTargets.find(
8
+ (t) => t.getAttribute("aria-selected") === "true"
9
+ );
10
+ if (!activeTab) return;
11
+
12
+ this.tabTargets.forEach((t) => {
13
+ this.setTabState(t, t === activeTab);
14
+ });
15
+ this.syncLocaleLabel(activeTab);
16
+ }
5
17
 
6
18
  switchTab(event) {
7
19
  const tab = event.currentTarget;
@@ -25,6 +37,7 @@ export default class extends Controller {
25
37
 
26
38
  // Highlight selected tab
27
39
  this.setTabState(tab, true);
40
+ this.syncLocaleLabel(tab);
28
41
  }
29
42
 
30
43
  findPanel(panelId) {
@@ -47,6 +60,13 @@ export default class extends Controller {
47
60
  tab.setAttribute("aria-selected", isActive ? "true" : "false");
48
61
  }
49
62
 
63
+ syncLocaleLabel(tab) {
64
+ if (!this.hasLocaleLabelTarget) return;
65
+
66
+ const label = tab.dataset.localeLabel;
67
+ if (label) this.localeLabelTarget.textContent = label;
68
+ }
69
+
50
70
  splitClasses(classListString) {
51
71
  return (classListString || "")
52
72
  .split(" ")
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RubyCms
6
+ # Runs whitelisted Rake tasks and reads log tails for the admin Commands UI.
7
+ class CommandRunner
8
+ class << self
9
+ def run_rake(task)
10
+ raise ArgumentError, "rake task blank" if task.to_s.strip.blank?
11
+
12
+ env = { "RAILS_ENV" => Rails.env.to_s }
13
+ argv = [ "bundle", "exec", "rake", task.to_s ]
14
+ stdout_and_stderr, status = Open3.capture2e(env, *argv, chdir: Rails.root.to_s)
15
+ <<~TEXT.strip
16
+ $ #{argv.join(" ")}
17
+ (exit #{status.exitstatus})
18
+
19
+ #{stdout_and_stderr}
20
+ TEXT
21
+ rescue Errno::ENOENT => e
22
+ <<~TEXT.strip
23
+ Failed to run command: #{e.message}
24
+ TEXT
25
+ end
26
+
27
+ def tail_log(lines: 400, max_bytes: 512 * 1024)
28
+ path = Rails.root.join("log", "#{Rails.env}.log")
29
+ return "(Log file not found: #{path})" unless path.file?
30
+
31
+ File.open(path, "rb") do |f|
32
+ size = f.size
33
+ f.seek([ 0, size - max_bytes ].max)
34
+ chunk = f.read
35
+ chunk.lines.last(lines.to_i.clamp(1, 10_000)).join
36
+ end
37
+ rescue StandardError => e
38
+ "(Could not read log: #{e.message})"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,104 @@
1
+ <%= render RubyCms::Admin::AdminPageHeader.new(
2
+ title: t("ruby_cms.admin.commands.title", default: "Commands"),
3
+ subtitle: t("ruby_cms.admin.commands.subtitle", default: "Run registered Rake tasks from the host app. Output and recent log lines appear on the right."),
4
+ breadcrumbs: [
5
+ { label: t("ruby_cms.admin.breadcrumb.admin", default: "Admin"), url: ruby_cms_admin_root_path },
6
+ { label: t("ruby_cms.admin.commands.title", default: "Commands") }
7
+ ]
8
+ ) %>
9
+
10
+ <div class="flex min-h-0 flex-col gap-4">
11
+ <% if @run_error.present? %>
12
+ <div class="w-full shrink-0 rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
13
+ <%= h(@run_error) %>
14
+ </div>
15
+ <% end %>
16
+
17
+ <%#
18
+ md+: 3-koloms grid — links 1/3 (commando's), rechts 2/3 (output + log).
19
+ Klein scherm: één kolom, eerst commando's, daaronder resultaat.
20
+ %>
21
+ <div
22
+ class="grid min-h-0 w-full min-w-0 grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 md:items-stretch md:min-h-[min(32rem,calc(100dvh-14rem))] md:max-h-[min(90vh,calc(100dvh-12rem))] md:overflow-hidden"
23
+ dir="ltr"
24
+ >
25
+ <%# Links: 1/3 — beschikbare commando's %>
26
+ <aside
27
+ class="flex min-h-[16rem] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm ring-1 ring-black/[0.03] md:col-span-1 md:row-start-1 md:h-full md:min-h-0"
28
+ >
29
+ <div class="shrink-0 border-b border-border/80 px-4 py-3">
30
+ <h2 class="text-sm font-semibold leading-tight tracking-tight text-foreground">
31
+ <%= t("ruby_cms.admin.commands.available", default: "Available commands") %>
32
+ </h2>
33
+ <p class="mt-1 text-xs leading-snug text-muted-foreground">
34
+ <%= t("ruby_cms.admin.commands.hint", default: "Commands are registered in the host app initializer (RubyCms.register_command).") %>
35
+ </p>
36
+ </div>
37
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
38
+ <% if @commands.any? %>
39
+ <div class="flex flex-col gap-3">
40
+ <% @commands.each do |cmd| %>
41
+ <div class="rounded-xl border border-border/60 bg-muted/20 p-3">
42
+ <div class="flex flex-col gap-2">
43
+ <div class="min-w-0">
44
+ <p class="text-sm font-medium leading-snug text-foreground"><%= cmd[:label] %></p>
45
+ <% if cmd[:description].present? %>
46
+ <p class="mt-1 text-xs leading-snug text-muted-foreground"><%= cmd[:description] %></p>
47
+ <% end %>
48
+ <p class="mt-2 break-all font-mono text-[10px] leading-tight text-muted-foreground">
49
+ rake <%= cmd[:rake_task] %>
50
+ </p>
51
+ </div>
52
+ <%= button_to t("ruby_cms.admin.commands.run", default: "Run"),
53
+ ruby_cms_admin_settings_commands_run_path,
54
+ method: :post,
55
+ params: { key: cmd[:key] },
56
+ form: {
57
+ data: { turbo: false },
58
+ class: "m-0 w-full shrink-0"
59
+ },
60
+ class: "inline-flex w-full cursor-pointer items-center justify-center rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90" %>
61
+ </div>
62
+ </div>
63
+ <% end %>
64
+ </div>
65
+ <% else %>
66
+ <p class="text-sm text-muted-foreground">
67
+ <%= t("ruby_cms.admin.commands.empty", default: "No commands are registered, or your role cannot run any of them.") %>
68
+ </p>
69
+ <% end %>
70
+ </div>
71
+ </aside>
72
+
73
+ <%# Rechts: 2/3 — commando-output + applicatielog %>
74
+ <section
75
+ aria-label="<%= t("ruby_cms.admin.commands.right_column_label", default: "Command result and application log") %>"
76
+ class="flex min-h-0 w-full min-w-0 flex-col gap-3 md:col-span-2 md:row-start-1 md:h-full md:min-h-0 md:overflow-hidden md:border-l md:border-border/60 md:pl-6"
77
+ >
78
+ <%# min-h-0: grid/flex item mag krimpen zodat max-h geldt en <pre> scrollt i.p.v. de pagina. %>
79
+ <div class="flex min-h-0 max-h-[50dvh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm ring-1 ring-black/[0.03]">
80
+ <div class="shrink-0 border-b border-border/80 px-4 py-3">
81
+ <h3 class="text-sm font-semibold text-foreground">
82
+ <%= t("ruby_cms.admin.commands.command_output", default: "Command output") %>
83
+ </h3>
84
+ </div>
85
+ <pre
86
+ class="ruby-cms-commands__pre min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-contain whitespace-pre-wrap break-words p-4 text-xs font-mono text-foreground [scrollbar-gutter:stable]"
87
+ aria-live="polite"
88
+ ><%= @run_command_output.present? ? h(@run_command_output) : t("ruby_cms.admin.commands.output_placeholder", default: "Output appears here after you run a command.") %></pre>
89
+ </div>
90
+
91
+ <div class="flex min-h-0 max-h-[50dvh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm ring-1 ring-black/[0.03] [scrollbar-gutter:stable]">
92
+ <div class="shrink-0 border-b border-border/80 px-4 py-3">
93
+ <h3 class="text-sm font-semibold text-foreground">
94
+ <%= t("ruby_cms.admin.commands.app_log", default: "Application log (tail)") %>
95
+ </h3>
96
+ </div>
97
+ <pre
98
+ class="ruby-cms-commands__pre min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-contain whitespace-pre-wrap break-words p-4 text-xs font-mono text-muted-foreground [scrollbar-gutter:stable]"
99
+ aria-live="polite"
100
+ ><%= @run_app_log_tail.present? ? h(@run_app_log_tail) : t("ruby_cms.admin.commands.log_placeholder", default: "Recent log lines appear here after a run.") %></pre>
101
+ </div>
102
+ </section>
103
+ </div>
104
+ </div>
@@ -28,7 +28,8 @@
28
28
  id: "content-block-form",
29
29
  data: { turbo: false } do |f| %>
30
30
  <div class="<%= RubyCms::Admin::AdminResourceCard::CARD_CLASS %>">
31
- <div class="<%= RubyCms::Admin::AdminResourceCard::GRID_CLASS %>">
31
+ <div class="<%= RubyCms::Admin::AdminResourceCard::GRID_CLASS %>"
32
+ data-controller="ruby-cms--locale-tabs">
32
33
  <%# ── Form Fields (Left 2/3) ── %>
33
34
  <div class="<%= RubyCms::Admin::AdminResourceCard::MAIN_CLASS %>">
34
35
  <% if @content_block.errors.any? %>
@@ -78,18 +79,23 @@
78
79
 
79
80
  <%# ── Locale Tabs ── %>
80
81
  <% if @blocks_by_locale.present? %>
81
- <div class="rounded-xl border border-border/60 overflow-hidden" data-controller="ruby-cms--locale-tabs">
82
+ <div class="rounded-xl border border-border/60 overflow-hidden">
82
83
  <div class="flex gap-1 p-2 bg-background border-b border-border/60" role="tablist">
83
84
  <% @blocks_by_locale.each_with_index do |(locale_s, _), idx| %>
85
+ <% tab_active = "bg-background text-primary shadow-sm ring-1 ring-border/60" %>
86
+ <% tab_inactive = "bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground" %>
84
87
  <button type="button"
85
88
  role="tab"
86
- aria-selected="<%= idx == 0 %>"
89
+ aria-selected="<%= idx == 0 ? "true" : "false" %>"
87
90
  aria-controls="locale-panel-<%= locale_s %>"
88
91
  id="locale-tab-<%= locale_s %>"
89
92
  data-ruby-cms--locale-tabs-target="tab"
90
93
  data-panel-id="locale-panel-<%= locale_s %>"
94
+ data-locale-label="<%= ruby_cms_locale_display_name(locale_s) %>"
95
+ data-active-classes="<%= tab_active %>"
96
+ data-inactive-classes="<%= tab_inactive %>"
91
97
  data-action="click->ruby-cms--locale-tabs#switchTab"
92
- class="px-3 py-1.5 rounded-md text-xs font-medium border-none cursor-pointer transition-colors <%= idx == 0 ? 'bg-background text-primary shadow-sm ring-1 ring-border/60' : 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground' %>">
98
+ class="px-3 py-1.5 rounded-md text-xs font-medium border-none cursor-pointer transition-colors <%= idx == 0 ? tab_active : tab_inactive %>">
93
99
  <%= ruby_cms_locale_display_name(locale_s) %>
94
100
  </button>
95
101
  <% end %>
@@ -154,7 +160,8 @@
154
160
 
155
161
  <div>
156
162
  <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Locale</dt>
157
- <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= ruby_cms_locale_display_name(@content_block.locale) %></dd>
163
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"
164
+ data-ruby-cms--locale-tabs-target="localeLabel"><%= ruby_cms_locale_display_name(@content_block.locale) %></dd>
158
165
  </div>
159
166
 
160
167
  <div>
@@ -80,6 +80,15 @@
80
80
  <% end %>
81
81
  </div>
82
82
  </div>
83
+
84
+ <div class="mt-4 rounded-xl border border-border/80 bg-muted/15 px-4 py-3 text-sm leading-relaxed text-muted-foreground">
85
+ <p class="m-0"><%= t("ruby_cms.admin.settings.commands_page_hint") %></p>
86
+ <p class="mt-2 mb-0">
87
+ <%= link_to t("ruby_cms.admin.settings.commands_open_link"),
88
+ ruby_cms_admin_settings_commands_path,
89
+ class: "font-medium text-foreground underline decoration-border underline-offset-2 hover:text-primary" %>
90
+ </p>
91
+ </div>
83
92
  <% elsif entries_for_grid.any? %>
84
93
  <h2 class="text-base font-semibold tracking-tight text-foreground"><%= settings_tab_label(@active_tab) %></h2>
85
94
  <p class="mt-1 text-sm text-muted-foreground">
data/config/importmap.rb CHANGED
@@ -36,3 +36,5 @@ pin "ruby_cms/nav_order_sortable_controller",
36
36
  pin "ruby_cms/page_preview_controller", to: "controllers/ruby_cms/page_preview_controller.js"
37
37
  pin "ruby_cms/content_block_history_controller",
38
38
  to: "controllers/ruby_cms/content_block_history_controller.js"
39
+ pin "ruby_cms/admin_commands_controller",
40
+ to: "controllers/ruby_cms/admin_commands_controller.js"
@@ -27,6 +27,7 @@ en:
27
27
  permissions: "Permissions"
28
28
  visitor_errors: "Visitor errors"
29
29
  users: "Users"
30
+ commands: "Commands"
30
31
  settings: "Settings"
31
32
  admin:
32
33
  breadcrumb:
@@ -40,6 +41,19 @@ en:
40
41
  de: "German"
41
42
  fr: "French"
42
43
  es: "Spanish"
44
+ commands:
45
+ title: "Commands"
46
+ subtitle: "Run registered Rake tasks from the host app. Output and recent log lines appear on the right."
47
+ available: "Available commands"
48
+ hint: "Commands are registered in the host app initializer (RubyCms.register_command)."
49
+ run: "Run"
50
+ empty: "No commands are registered, or your role cannot run any of them."
51
+ command_output: "Command output"
52
+ app_log: "Application log (tail)"
53
+ output_placeholder: "Output appears here after you run a command."
54
+ log_placeholder: "Recent log lines appear here after a run."
55
+ unknown: "Unknown command."
56
+ right_column_label: "Command result and application log"
43
57
  settings:
44
58
  title: "Settings"
45
59
  subtitle: "Configure admin interface preferences"
@@ -57,6 +71,8 @@ en:
57
71
  reset_inline_hint: "Restore factory defaults for the entire admin (all sections)."
58
72
  reset_confirm: "Reset all settings to default values?"
59
73
  no_settings: "No settings in this category"
74
+ commands_page_hint: "Registered rake tasks (Commands) are not a tab on this screen. Open “Commands” in the left sidebar under “Settings”, or use the link below."
75
+ commands_open_link: "Open Commands"
60
76
  analytics:
61
77
  title: "Analytics"
62
78
  subtitle: "Traffic and engagement overview"
@@ -16,6 +16,7 @@ nl:
16
16
  permissions: "Rechten"
17
17
  visitor_errors: "Bezoekersfouten"
18
18
  users: "Gebruikers"
19
+ commands: "Commando's"
19
20
  settings: "Instellingen"
20
21
  admin:
21
22
  breadcrumb:
@@ -29,6 +30,22 @@ nl:
29
30
  de: "Duits"
30
31
  fr: "Frans"
31
32
  es: "Spaans"
33
+ commands:
34
+ title: "Commando's"
35
+ subtitle: "Voer geregistreerde Rake-taken uit (host app). Uitvoer en recente logregels staan rechts."
36
+ available: "Beschikbare commando's"
37
+ hint: "Commando's registreer je in de host app (RubyCms.register_command)."
38
+ run: "Uitvoeren"
39
+ empty: "Er zijn geen commando's geregistreerd, of je rol mag er geen uitvoeren."
40
+ command_output: "Uitvoer commando"
41
+ app_log: "Applicatielog (tail)"
42
+ output_placeholder: "Uitvoer verschijnt hier na het uitvoeren."
43
+ log_placeholder: "Recente logregels verschijnen hier na een run."
44
+ unknown: "Onbekend commando."
45
+ right_column_label: "Commando-resultaat en applicatielog"
46
+ settings:
47
+ commands_page_hint: "Geregistreerde Rake-taken (Commando's) staan niet als tab op dit scherm. Open «Commando's» in de linkerzijbalk onder het kopje Instellingen, of gebruik de link hieronder."
48
+ commands_open_link: "Commando's openen"
32
49
  content_blocks:
33
50
  created: "Contentblok aangemaakt."
34
51
  updated: "Contentblok bijgewerkt."
data/config/routes.rb CHANGED
@@ -49,6 +49,9 @@ RubyCms::Engine.routes.draw do # rubocop:disable Metrics/BlockLength
49
49
  post "settings/nav_order", to: "settings#update_nav_order", as: :settings_nav_order
50
50
  post "settings/reset_defaults", to: "settings#reset_defaults", as: :settings_reset_defaults
51
51
 
52
+ get "settings/commands", to: "commands#index", as: :settings_commands
53
+ post "settings/commands/run", to: "commands#run", as: :settings_commands_run
54
+
52
55
  resources :users, only: %i[index create destroy] do
53
56
  collection do
54
57
  delete "bulk_delete", to: "users#bulk_delete"
@@ -855,18 +855,45 @@ module RubyCms
855
855
  end.join("\n")
856
856
  end
857
857
 
858
- # Helper: add @source for RubyCMS views/components so Tailwind finds utility classes.
859
- # Not a generator task.
858
+ # Adds a single @import for tailwindcss-rails' engine shim (see gem app/assets/tailwind/ruby_cms_engine/engine.css).
859
+ # Avoids duplicate @source globs to the gem, which makes tailwindcss:build much slower.
860
860
  def add_ruby_cms_tailwind_source(tailwind_css_path)
861
861
  return unless tailwind_css_path.to_s.present? && File.exist?(tailwind_css_path)
862
862
 
863
863
  content = File.read(tailwind_css_path)
864
- gem_source_lines = build_gem_source_lines(tailwind_css_path)
865
- return if gem_source_lines.all? {|line| content.include?(line) }
864
+ return if content.match?(%r{builds/tailwind/ruby_cms_engine})
866
865
 
867
- inject_tailwind_source(tailwind_css_path, content, gem_source_lines)
866
+ injection = +"\n/* RubyCMS: Tailwind content via tailwindcss-rails engine (see ruby_cms README). */\n"
867
+ injection << "@import \"../builds/tailwind/ruby_cms_engine\";\n"
868
+
869
+ inserted = inject_ruby_cms_engine_import_after_tailwind!(tailwind_css_path, content, injection)
870
+ unless inserted
871
+ inject_into_file tailwind_css_path.to_s, after: /\A/ do
872
+ injection
873
+ end
874
+ end
875
+
876
+ say "✓ Task tailwind/source: Added RubyCMS engine import to #{tailwind_css_path}.", :green
868
877
  rescue StandardError => e
869
- say "⚠ Task tailwind/source: Could not add @source: #{e.message}. Add manually.", :yellow
878
+ say "⚠ Task tailwind/source: Could not add engine import: #{e.message}. Add manually.", :yellow
879
+ end
880
+
881
+ def inject_ruby_cms_engine_import_after_tailwind!(tailwind_css_path, content, injection)
882
+ patterns = [
883
+ %(@import "tailwindcss";\n),
884
+ %(@import "tailwindcss";),
885
+ %(@import "tailwindcss"\n),
886
+ %(@import "tailwindcss")
887
+ ]
888
+ patterns.each do |after_pattern|
889
+ next unless content.include?(after_pattern)
890
+
891
+ inject_into_file tailwind_css_path.to_s, after: after_pattern do
892
+ injection
893
+ end
894
+ return true
895
+ end
896
+ false
870
897
  end
871
898
 
872
899
  # Tailwind v3 support (tailwind.config.js content array)
@@ -905,65 +932,6 @@ module RubyCms
905
932
  ]
906
933
  end
907
934
 
908
- def build_gem_source_lines(tailwind_css_path)
909
- css_dir = Pathname.new(tailwind_css_path).dirname
910
- gem_views = path_relative_to_css_or_absolute(RubyCms::Engine.root.join("app/views"),
911
- css_dir)
912
- gem_components = path_relative_to_css_or_absolute(
913
- RubyCms::Engine.root.join("app/components"), css_dir
914
- )
915
- [
916
- %(@source "#{gem_views}/**/*.erb";),
917
- %(@source "#{gem_components}/**/*.rb";)
918
- ]
919
- end
920
-
921
- def path_relative_to_css_or_absolute(target_path, css_dir)
922
- Pathname.new(target_path).relative_path_from(css_dir).to_s
923
- rescue ArgumentError
924
- # Different mount/volume: fall back to absolute path.
925
- Pathname.new(target_path).to_s
926
- end
927
-
928
- def inject_tailwind_source(tailwind_css_path, content, gem_source_lines)
929
- to_inject = build_tailwind_source_injection(gem_source_lines)
930
- inserted = try_insert_after_patterns?(tailwind_css_path, content, to_inject)
931
- inject_at_start(tailwind_css_path, to_inject) unless inserted
932
- say "✓ Task tailwind/source: Added @source for RubyCMS views/components to " \
933
- "tailwind/application.css.",
934
- :green
935
- end
936
-
937
- def build_tailwind_source_injection(gem_source_lines)
938
- to_inject = +"\n/* Include RubyCMS views/components so Tailwind finds utility classes. */\n"
939
- Array(gem_source_lines).each {|line| to_inject << line << "\n" }
940
- to_inject << "\n"
941
- to_inject
942
- end
943
-
944
- def try_insert_after_patterns?(tailwind_css_path, content, to_inject)
945
- patterns = [
946
- %(@import "tailwindcss";\n),
947
- %(@import "tailwindcss";),
948
- %(@import "tailwindcss"\n),
949
- %(@import "tailwindcss")
950
- ]
951
- patterns.each do |after_pattern|
952
- next unless content.include?(after_pattern)
953
-
954
- inject_into_file tailwind_css_path.to_s, after: after_pattern do
955
- to_inject
956
- end
957
- return true
958
- end
959
- false
960
- end
961
-
962
- def inject_at_start(tailwind_css_path, to_inject)
963
- inject_into_file tailwind_css_path.to_s, after: /\A/ do
964
- to_inject
965
- end
966
- end
967
935
  end
968
936
 
969
937
  def run_migrate
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # Host apps register runnable commands (usually Rake tasks) shown on Admin → Commands.
5
+ module CommandsRegistry
6
+ def registered_commands
7
+ @registered_commands ||= []
8
+ end
9
+
10
+ def registered_commands=(list)
11
+ @registered_commands = list
12
+ end
13
+
14
+ # Register a button-triggered command for the admin Commands screen.
15
+ #
16
+ # @param key [String, Symbol] Unique id (e.g. :copy_en_to_nl)
17
+ # @param label [String] Button label
18
+ # @param rake_task [String] Full rake task name, including args in brackets if needed
19
+ # @param description [String, nil] Optional help text under the button
20
+ # @param permission [Symbol] Required to run (+can?+); default +:manage_admin+
21
+ def register_command(key:, label:, rake_task:, description: nil, permission: :manage_admin)
22
+ k = key.to_s
23
+ entry = {
24
+ key: k,
25
+ label: label.to_s,
26
+ rake_task: rake_task.to_s,
27
+ description: description.to_s.presence,
28
+ permission: permission.to_sym
29
+ }
30
+ self.registered_commands = registered_commands.reject {|e| e[:key] == k } + [entry]
31
+ entry
32
+ end
33
+
34
+ def find_command(key)
35
+ registered_commands.find {|e| e[:key] == key.to_s }
36
+ end
37
+ end
38
+
39
+ extend CommandsRegistry
40
+ end
@@ -66,6 +66,15 @@ module RubyCms
66
66
  permission: :manage_permissions,
67
67
  order: 4
68
68
  )
69
+ RubyCms.register_page(
70
+ key: :commands,
71
+ label: "Commands",
72
+ path: lambda(&:ruby_cms_admin_settings_commands_path),
73
+ icon: :wrench,
74
+ section: :settings,
75
+ permission: :manage_admin,
76
+ order: 5
77
+ )
69
78
  RubyCms.register_page(
70
79
  key: :settings,
71
80
  label: "Settings",
@@ -73,7 +82,7 @@ module RubyCms
73
82
  icon: :cog_6_tooth,
74
83
  section: :settings,
75
84
  permission: :manage_admin,
76
- order: 5
85
+ order: 6
77
86
  )
78
87
  end
79
88
  end
@@ -100,7 +100,8 @@ module RubyCms
100
100
  # For importmap: ensure engine's importmap is loaded
101
101
  if app.config.respond_to?(:importmap)
102
102
  app.config.importmap.paths << config.root.join("config/importmap.rb")
103
- app.config.importmap.cache_sweepers << config.root.join("app/javascript")
103
+ # Only sweep the engine's Stimulus tree (not the whole app/javascript tree)
104
+ app.config.importmap.cache_sweepers << config.root.join("app/javascript/controllers/ruby_cms")
104
105
  end
105
106
  end
106
107
 
@@ -262,12 +263,18 @@ module RubyCms
262
263
  end
263
264
  end
264
265
 
266
+ # True during asset pipeline tasks so we skip DB-heavy initializers (permissions, settings import).
267
+ # Detect by ARGV first: $PROGRAM_NAME is often "ruby" when using `ruby bin/rails`, which would
268
+ # miss the old rake/rails basename check; tailwindcss:build runs as a prerequisite of
269
+ # assets:precompile and keeps the same ARGV, but a standalone `rails tailwindcss:build` must match too.
265
270
  def self.assets_precompile_phase?
266
- command = File.basename($PROGRAM_NAME.to_s)
267
- rake_assets_precompile = command == "rake" && ARGV.include?("assets:precompile")
268
- rails_assets_precompile = command == "rails" && ARGV.include?("assets:precompile")
271
+ argv = Array(ARGV).map(&:to_s)
272
+ return true if argv.include?("assets:precompile")
273
+ return true if argv.any? {|a| a.start_with?("tailwindcss:") }
274
+ return true if argv.include?("propshaft:compile")
269
275
 
270
- rake_assets_precompile || rails_assets_precompile
276
+ command = File.basename($PROGRAM_NAME.to_s)
277
+ (command == "rake" || command == "rails") && argv.include?("assets:precompile")
271
278
  end
272
279
  end
273
280
  end
@@ -240,6 +240,13 @@ module RubyCms
240
240
  category: :navigation,
241
241
  description: "Show Analytics in navigation"
242
242
  )
243
+ register(
244
+ key: :nav_show_commands,
245
+ type: :boolean,
246
+ default: true,
247
+ category: :navigation,
248
+ description: "Show Commands in navigation"
249
+ )
243
250
  register(
244
251
  key: :nav_order,
245
252
  type: :json,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCms
4
- VERSION = "0.2.0.1"
4
+ VERSION = "0.2.0.3"
5
5
  end
data/lib/ruby_cms.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "ruby_cms/engine"
10
10
  require_relative "ruby_cms/app_integration"
11
11
  require_relative "ruby_cms/content_blocks_sync"
12
12
  require_relative "ruby_cms/content_blocks_grouping"
13
+ require_relative "ruby_cms/commands_registry"
13
14
 
14
15
  module RubyCms
15
16
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.1
4
+ version: 0.2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Codebyjob
@@ -96,6 +96,7 @@ files:
96
96
  - app/controllers/concerns/ruby_cms/visitor_error_capture.rb
97
97
  - app/controllers/ruby_cms/admin/analytics_controller.rb
98
98
  - app/controllers/ruby_cms/admin/base_controller.rb
99
+ - app/controllers/ruby_cms/admin/commands_controller.rb
99
100
  - app/controllers/ruby_cms/admin/content_block_versions_controller.rb
100
101
  - app/controllers/ruby_cms/admin/content_blocks_controller.rb
101
102
  - app/controllers/ruby_cms/admin/dashboard_controller.rb
@@ -114,6 +115,7 @@ files:
114
115
  - app/helpers/ruby_cms/bulk_action_table_helper.rb
115
116
  - app/helpers/ruby_cms/content_blocks_helper.rb
116
117
  - app/helpers/ruby_cms/settings_helper.rb
118
+ - app/javascript/controllers/ruby_cms/admin_commands_controller.js
117
119
  - app/javascript/controllers/ruby_cms/auto_save_preference_controller.js
118
120
  - app/javascript/controllers/ruby_cms/bulk_action_table_controller.js
119
121
  - app/javascript/controllers/ruby_cms/clickable_row_controller.js
@@ -138,6 +140,7 @@ files:
138
140
  - app/models/ruby_cms/user_permission.rb
139
141
  - app/models/ruby_cms/visitor_error.rb
140
142
  - app/services/ruby_cms/analytics/report.rb
143
+ - app/services/ruby_cms/command_runner.rb
141
144
  - app/services/ruby_cms/security_tracker.rb
142
145
  - app/views/admin/content_block_versions/index.html.erb
143
146
  - app/views/admin/content_block_versions/show.html.erb
@@ -160,6 +163,7 @@ files:
160
163
  - app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb
161
164
  - app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb
162
165
  - app/views/ruby_cms/admin/analytics/visitor_details.html.erb
166
+ - app/views/ruby_cms/admin/commands/index.html.erb
163
167
  - app/views/ruby_cms/admin/content_block_versions/index.html.erb
164
168
  - app/views/ruby_cms/admin/content_block_versions/show.html.erb
165
169
  - app/views/ruby_cms/admin/content_blocks/_form.html.erb
@@ -215,6 +219,7 @@ files:
215
219
  - lib/ruby_cms.rb
216
220
  - lib/ruby_cms/app_integration.rb
217
221
  - lib/ruby_cms/cli.rb
222
+ - lib/ruby_cms/commands_registry.rb
218
223
  - lib/ruby_cms/content_blocks_grouping.rb
219
224
  - lib/ruby_cms/content_blocks_sync.rb
220
225
  - lib/ruby_cms/css_compiler.rb