command_deck 0.1.0

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.
@@ -0,0 +1,305 @@
1
+ import { el, jsonPretty } from '../core/dom.js';
2
+ import { store } from '../core/store.js';
3
+ import { PositionManager } from './position_manager.js';
4
+ import { ThemeManager } from './theme_manager.js';
5
+
6
+ export class Overlay {
7
+ constructor({ mount, panelsApi, actionsApi }) {
8
+ this.mount = mount;
9
+ this.panelsApi = panelsApi;
10
+ this.actionsApi = actionsApi;
11
+ this._loaded = false;
12
+ }
13
+
14
+ refreshCurrentTab() {
15
+ // Re-fetch panels and re-render current panel/tab without changing selection
16
+ this.panelsApi.fetchPanels().then((data) => {
17
+ const panels = (data && data.panels) || [];
18
+ this.panels = panels;
19
+ if (this.panelSelector) this.panelSelector.setPanels(this.panels);
20
+ const panel = this.panels.find(p => p.key === this.currentPanelKey) || this.panels[0];
21
+ if (!panel) return;
22
+ if (this.panelSelector) this.panelSelector.setActiveKey(panel.key);
23
+ this.renderTabs(panel, this.currentTabIndex || 0);
24
+ }).catch(() => {/* ignore refresh errors */});
25
+ }
26
+
27
+ init() {
28
+ if (this._loaded) return; this._loaded = true;
29
+ // Root pieces
30
+ this.toggle = el('div', { id: 'command-deck-toggle', title: 'Command Deck' }, ['CD']);
31
+ this.panel = el('div', { id: 'command-deck-panel', style: { display: 'none' } });
32
+ // Keep these nodes across Turbo Drive navigations
33
+ this.toggle.setAttribute('data-turbo-permanent', '');
34
+ this.panel.setAttribute('data-turbo-permanent', '');
35
+
36
+ const headerWrap = el('div', { id: 'cd-header' });
37
+ const title = el('h4', null, ['Command Deck']);
38
+ headerWrap.appendChild(title);
39
+ const headerRight = el('div', { id: 'cd-header-right' });
40
+ headerWrap.appendChild(headerRight);
41
+
42
+ this.tabsWrap = el('div', { id: 'cd-tabs-wrap' });
43
+ this.actionsWrap = el('div', { id: 'cd-actions-wrap' });
44
+ this.resultPre = el('pre', { id: 'command-deck-output' });
45
+
46
+ this.panel.appendChild(headerWrap);
47
+ this.panel.appendChild(this.tabsWrap);
48
+ this.panel.appendChild(this.actionsWrap);
49
+ this.panel.appendChild(this.resultPre);
50
+
51
+ document.body.appendChild(this.toggle);
52
+ document.body.appendChild(this.panel);
53
+
54
+ this.toggle.addEventListener('click', () => {
55
+ this.panel.style.display = (this.panel.style.display === 'none' || !this.panel.style.display) ? 'block' : 'none';
56
+ });
57
+
58
+ this.position = new PositionManager(this.toggle, this.panel);
59
+ this.position.set(this.position.load());
60
+
61
+ this.theme = new ThemeManager(this.toggle, this.panel);
62
+ this.theme.set(this.theme.load());
63
+ }
64
+
65
+ attachSettings(dropdown) {
66
+ // Append settings into right side of header
67
+ const headerRight = this.panel.querySelector('#cd-header-right');
68
+ dropdown.mount(headerRight);
69
+ if (this.theme) dropdown.setTheme && dropdown.setTheme(this.theme.load());
70
+ }
71
+
72
+ attachPanelSelector(selector) {
73
+ this.panelSelector = selector;
74
+ const headerRight = this.panel.querySelector('#cd-header-right');
75
+ selector.mount(headerRight);
76
+ }
77
+
78
+ loadPanels() {
79
+ return this.panelsApi.fetchPanels().then(data => this.setPanels(data));
80
+ }
81
+
82
+ setPanels(data) {
83
+ this.tabsWrap.textContent = '';
84
+ this.actionsWrap.textContent = '';
85
+ this.panels = (data && data.panels) || [];
86
+ if (!this.panels.length) {
87
+ this.tabsWrap.appendChild(el('div', null, ['No panels defined. Add files under app/command_deck/**/*.rb']))
88
+ return;
89
+ }
90
+ // Initialize selector
91
+ if (this.panelSelector) {
92
+ this.panelSelector.setPanels(this.panels);
93
+ }
94
+ const saved = store.get('panel-key');
95
+ const exists = this.panels.find(p => p.key === saved);
96
+ const key = exists ? saved : this.panels[0].key;
97
+ this.selectPanel(key);
98
+ }
99
+
100
+ selectPanel(key) {
101
+ const panel = (this.panels || []).find(p => p.key === key);
102
+ if (!panel) return;
103
+ this.currentPanelKey = key;
104
+ store.set('panel-key', key);
105
+ if (this.panelSelector) this.panelSelector.setActiveKey(key);
106
+
107
+ const headerTitle = this.panel.querySelector('#cd-header h4');
108
+ headerTitle.textContent = 'Command Deck';
109
+ this.renderTabs(panel, this.currentTabIndex || 0);
110
+ }
111
+
112
+ renderTabs(panel, selectedIndex = 0) {
113
+ this.tabsWrap.textContent = '';
114
+ this.actionsWrap.textContent = '';
115
+ this.currentTabIndex = selectedIndex;
116
+ (panel.tabs || []).forEach((tab, i) => {
117
+ const btn = el('button', { class: 'cd-tab-btn' }, [tab.title]);
118
+ btn.addEventListener('click', () => { this.currentTabIndex = i; this.renderActions(tab); });
119
+ this.tabsWrap.appendChild(btn);
120
+ if (i === selectedIndex) this.renderActions(tab);
121
+ });
122
+ }
123
+
124
+ renderActions(tab) {
125
+ this.actionsWrap.textContent = '';
126
+ (tab.actions || []).forEach(action => {
127
+ const box = el('div', { class: 'cd-action' });
128
+ box.appendChild(el('div', { class: 'cd-action-title' }, [action.title]));
129
+ const form = el('div', { class: 'cd-form' });
130
+
131
+ const inputs = {};
132
+ const paramsMeta = {};
133
+ (action.params || []).forEach(p => {
134
+ const labelChildren = [p.label || p.name];
135
+ if (p.required === true) {
136
+ labelChildren.push(el('span', { class: 'cd-required', title: 'Required' }, ['*']));
137
+ }
138
+ const label = el('label', null, labelChildren);
139
+ let input;
140
+ if (p.type === 'boolean') {
141
+ input = el('input', { type: 'checkbox' });
142
+ } else if (p.type === 'integer') {
143
+ input = el('input', { type: 'number', step: '1' });
144
+ } else if (p.type === 'selector' || (p.choices && p.choices.length)) {
145
+ input = el('select');
146
+ if (p.include_blank) {
147
+ input.appendChild(el('option', { value: '' }, ['']));
148
+ }
149
+ (p.choices || []).forEach(ch => {
150
+ const attrs = { 'data-val': JSON.stringify(ch.value) };
151
+ if (ch.meta != null) attrs['data-meta'] = JSON.stringify(ch.meta);
152
+ const opt = el('option', attrs, [ch.label != null ? String(ch.label) : String(ch.value)]);
153
+ input.appendChild(opt);
154
+ });
155
+
156
+ // Restore last selection (per panel/action/param)
157
+ const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${p.name}`;
158
+ const savedRaw = store.get(selKey);
159
+ if (savedRaw != null) {
160
+ let matched = false;
161
+ for (let i = 0; i < input.options.length; i++) {
162
+ const o = input.options[i];
163
+ const raw = o.getAttribute('data-val');
164
+ if (raw === savedRaw) {
165
+ input.selectedIndex = i;
166
+ matched = true;
167
+ break;
168
+ }
169
+ }
170
+ if (!matched && p.include_blank && savedRaw === '') {
171
+ for (let i = 0; i < input.options.length; i++) {
172
+ if (input.options[i].value === '') { input.selectedIndex = i; break; }
173
+ }
174
+ }
175
+ }
176
+ } else {
177
+ input = el('input', { type: 'text' });
178
+ }
179
+ if (p.required === true && input) {
180
+ input.setAttribute('aria-required', 'true');
181
+ input.setAttribute('required', '');
182
+ }
183
+ form.appendChild(label);
184
+ form.appendChild(input);
185
+
186
+ // Optional hint below inputs (used for selector current value display)
187
+ let hint;
188
+ if (input.tagName === 'SELECT') {
189
+ hint = el('div', { class: 'cd-param-hint' });
190
+ const updateHint = () => {
191
+ const sel = input.options[input.selectedIndex];
192
+ if (!sel) { hint.textContent = ''; return; }
193
+ const metaRaw = sel.getAttribute('data-meta');
194
+ if (!metaRaw) { hint.textContent = ''; return; }
195
+ try {
196
+ const meta = JSON.parse(metaRaw);
197
+ if (typeof meta.enabled === 'boolean') {
198
+ const status = meta.enabled ? 'ON' : 'OFF';
199
+ hint.textContent = 'Current: ';
200
+ const badge = el('span', { class: `cd-badge ${meta.enabled ? 'cd-on' : 'cd-off'}` }, [status]);
201
+ hint.appendChild(badge);
202
+ } else {
203
+ hint.textContent = '';
204
+ }
205
+ } catch(_) { hint.textContent = ''; }
206
+ };
207
+ input.addEventListener('change', () => {
208
+ // Persist selection
209
+ const sel = input.options[input.selectedIndex];
210
+ const raw = sel ? sel.getAttribute('data-val') : '';
211
+ const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${p.name}`;
212
+ store.set(selKey, raw || '');
213
+ updateHint();
214
+ validate();
215
+ });
216
+ // Initialize after options are appended
217
+ setTimeout(updateHint, 0);
218
+ form.appendChild(hint);
219
+ }
220
+
221
+ inputs[p.name] = input;
222
+ paramsMeta[p.name] = p;
223
+ });
224
+
225
+ // Validation helpers
226
+ const requiredParams = (action.params || []).filter(p => p.required === true);
227
+ const isFilled = (p) => {
228
+ const inp = inputs[p.name];
229
+ if (!inp) return false;
230
+ if (p.type === 'integer') return inp.value !== '';
231
+ if (p.type === 'selector' || (p.choices && p.choices.length)) {
232
+ if (p.include_blank) return inp.value !== '' && inp.selectedIndex > -1;
233
+ return inp.selectedIndex > -1; // any selection counts
234
+ }
235
+ if (p.type === 'boolean') return true; // checkbox always provides a value
236
+ // string or others
237
+ return String(inp.value || '').trim() !== '';
238
+ };
239
+ const validate = () => {
240
+ const ok = requiredParams.every(isFilled);
241
+ const running = run.classList.contains('loading');
242
+ run.disabled = !ok || running;
243
+ };
244
+
245
+ const run = el('button', { class: 'cd-run', title: 'Run' }, ['\u25B6']); // ▶
246
+ run.addEventListener('click', () => {
247
+ if (run.disabled) return;
248
+ run.classList.add('loading');
249
+ run.setAttribute('aria-busy', 'true');
250
+ run.disabled = true;
251
+ const payload = {};
252
+ Object.keys(inputs).forEach(k => {
253
+ const inp = inputs[k];
254
+ const meta = paramsMeta[k] || {};
255
+ if (inp.tagName === 'SELECT') {
256
+ const sel = inp.options[inp.selectedIndex];
257
+ const raw = sel && sel.getAttribute('data-val');
258
+ const label = sel ? sel.textContent : '';
259
+ let value;
260
+ try { value = raw != null ? JSON.parse(raw) : inp.value; } catch(_) { value = inp.value; }
261
+
262
+ if (meta.return === 'label') {
263
+ payload[k] = label;
264
+ } else if (meta.return === 'both' || meta.return === 'object') {
265
+ payload[k] = { label: label, value: value };
266
+ } else {
267
+ payload[k] = value; // default: value
268
+ }
269
+
270
+ // Persist selection on run as well
271
+ const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${k}`;
272
+ store.set(selKey, raw || '');
273
+ } else {
274
+ payload[k] = (inp.type === 'checkbox') ? !!inp.checked : inp.value;
275
+ }
276
+ });
277
+ this.setRunning();
278
+ this.actionsApi
279
+ .submit(action.key, payload)
280
+ .then((res) => { this.setResult(res); this.refreshCurrentTab(); })
281
+ .catch((e) => this.setResult({ ok:false, error: String(e) }))
282
+ .finally(() => { run.classList.remove('loading'); run.removeAttribute('aria-busy'); validate(); });
283
+ });
284
+ const actionsRow = el('div', { class: 'cd-actions-row' });
285
+ actionsRow.appendChild(run);
286
+ form.appendChild(actionsRow);
287
+
288
+ box.appendChild(form);
289
+ this.actionsWrap.appendChild(box);
290
+
291
+ // Hook up validation listeners
292
+ (action.params || []).forEach(p => {
293
+ const inp = inputs[p.name];
294
+ if (!inp) return;
295
+ const evt = (inp.tagName === 'SELECT' || inp.type === 'checkbox') ? 'change' : 'input';
296
+ inp.addEventListener(evt, validate);
297
+ });
298
+ // Initial state
299
+ validate();
300
+ });
301
+ }
302
+
303
+ setRunning(){ this.resultPre.textContent = 'Running...'; }
304
+ setResult(obj){ this.resultPre.textContent = jsonPretty(obj); }
305
+ }
@@ -0,0 +1,32 @@
1
+ import { el } from '../core/dom.js';
2
+
3
+ export class PanelSelector {
4
+ constructor(onSelect) {
5
+ this.onSelect = onSelect;
6
+ this.root = el('div', { id: 'cd-panel-selector' });
7
+ this.select = el('select');
8
+ this.root.appendChild(this.select);
9
+
10
+ this.select.addEventListener('change', () => {
11
+ const key = this.select.value;
12
+ this.onSelect && this.onSelect(key);
13
+ });
14
+ }
15
+
16
+ mount(container) { container.appendChild(this.root); }
17
+
18
+ setPanels(panels) {
19
+ // panels: [{ key, title, owner, group }]
20
+ this.select.textContent = '';
21
+ (panels || []).forEach(p => {
22
+ const label = [p.group, p.title].filter(Boolean).join(' • ');
23
+ const opt = el('option', { value: p.key }, [label || p.title || p.key]);
24
+ this.select.appendChild(opt);
25
+ });
26
+ }
27
+
28
+ setActiveKey(key) {
29
+ if (!key) return;
30
+ this.select.value = key;
31
+ }
32
+ }
@@ -0,0 +1,26 @@
1
+ import { store } from '../core/store.js';
2
+
3
+ const POSITIONS = ['tl','tr','bl','br'];
4
+
5
+ export class PositionManager {
6
+ constructor(toggle, panel) {
7
+ this.toggle = toggle;
8
+ this.panel = panel;
9
+ }
10
+ load() { return store.get('position', 'br'); }
11
+ save(pos) { store.set('position', pos); }
12
+ apply(pos) {
13
+ ['cd-pos-tl','cd-pos-tr','cd-pos-bl','cd-pos-br'].forEach(c => {
14
+ this.toggle.classList.remove(c);
15
+ this.panel.classList.remove(c);
16
+ });
17
+ const cls = 'cd-pos-' + pos;
18
+ this.toggle.classList.add(cls);
19
+ this.panel.classList.add(cls);
20
+ }
21
+ set(pos) {
22
+ if (!POSITIONS.includes(pos)) return;
23
+ this.save(pos);
24
+ this.apply(pos);
25
+ }
26
+ }
@@ -0,0 +1,64 @@
1
+ import { el } from '../core/dom.js';
2
+
3
+ export class SettingsDropdown {
4
+ constructor(onSelectPosition, onThemeChange) {
5
+ this.onSelectPosition = onSelectPosition;
6
+ this.onThemeChange = onThemeChange;
7
+
8
+ this.root = el('div', { id: 'cd-settings-panel', style: { display: 'none' } });
9
+ this.button = el('button', { id: 'cd-settings-btn', title: 'Settings' }, ['\u2699']);
10
+
11
+ // Position section
12
+ const titlePos = el('div', { class: 'cd-settings-title' }, ['Position']);
13
+ const menu = el('div', { class: 'cd-menu' });
14
+ const items = [
15
+ ['tl','\u2196 Top-left'],
16
+ ['tr','\u2197 Top-right'],
17
+ ['bl','\u2199 Bottom-left'],
18
+ ['br','\u2198 Bottom-right']
19
+ ];
20
+ items.forEach(([key, label]) => {
21
+ const item = el('button', { class: 'cd-menu-item', 'data-pos': key }, [label]);
22
+ item.addEventListener('click', (e) => { e.stopPropagation(); this.onSelectPosition(key); this.hide(); });
23
+ menu.appendChild(item);
24
+ });
25
+
26
+ // Theme toggle (no title/label; just icon button)
27
+ this.themeBtn = el('button', { id: 'cd-theme-toggle', title: 'Toggle theme' }, ['\u2600']); // sun
28
+ this.themeBtn.addEventListener('click', (e) => {
29
+ e.stopPropagation();
30
+ const next = (this.currentTheme === 'dark') ? 'light' : 'dark';
31
+ this.setTheme(next);
32
+ this.onThemeChange && this.onThemeChange(next);
33
+ });
34
+
35
+ this.root.appendChild(titlePos);
36
+ this.root.appendChild(menu);
37
+ this.root.appendChild(this.themeBtn);
38
+
39
+ this.button.addEventListener('click', (e) => {
40
+ e.stopPropagation();
41
+ this.toggle();
42
+ });
43
+ this.root.addEventListener('click', (e) => e.stopPropagation());
44
+ document.addEventListener('click', () => this.hide());
45
+ }
46
+
47
+ setTheme(theme) {
48
+ this.currentTheme = theme === 'dark' ? 'dark' : 'light';
49
+ if (this.themeBtn) this.themeBtn.textContent = (this.currentTheme === 'dark') ? '\u263D' : '\u2600';
50
+ // ☽ (U+263D) for moon, ☀ (U+2600) for sun
51
+ }
52
+
53
+ mount(container) {
54
+ container.appendChild(this.button);
55
+ container.appendChild(this.root);
56
+ }
57
+
58
+ toggle() {
59
+ const willOpen = (this.root.style.display === 'none' || !this.root.style.display);
60
+ this.root.style.display = willOpen ? 'block' : 'none';
61
+ this.button.classList.toggle('open', willOpen);
62
+ }
63
+ hide() { this.root.style.display = 'none'; this.button.classList.remove('open'); }
64
+ }
@@ -0,0 +1,34 @@
1
+ import { store } from '../core/store.js';
2
+
3
+ export class ThemeManager {
4
+ constructor(toggle, panel) {
5
+ this.toggle = toggle;
6
+ this.panel = panel;
7
+ }
8
+
9
+ load() {
10
+ const saved = store.get('theme');
11
+ if (saved) return saved;
12
+ try {
13
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
14
+ return 'dark';
15
+ }
16
+ } catch(_) {}
17
+ return 'light';
18
+ }
19
+
20
+ save(theme) { store.set('theme', theme); }
21
+
22
+ apply(theme) {
23
+ const isDark = theme === 'dark';
24
+ [this.toggle, this.panel].forEach(node => {
25
+ node.classList.toggle('cd-theme-dark', isDark);
26
+ });
27
+ }
28
+
29
+ set(theme) {
30
+ if (theme !== 'light' && theme !== 'dark') return;
31
+ this.save(theme);
32
+ this.apply(theme);
33
+ }
34
+ }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require_relative "middleware"
5
+
6
+ module CommandDeck
7
+ # Rails engine for Command Deck
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace CommandDeck
10
+
11
+ initializer "command_deck.mount_point" do
12
+ # Default mount point. If the host app has a relative_url_root, respect it.
13
+ mp = "/command_deck"
14
+ if defined?(Rails) && Rails.application.config.respond_to?(:relative_url_root)
15
+ root = Rails.application.config.relative_url_root
16
+ mp = File.join(root, mp) if root.present?
17
+ end
18
+ CommandDeck::Middleware.mount_point = mp
19
+ end
20
+
21
+ initializer "command_deck.middleware" do |app|
22
+ # Dev-only injection. Safe no-op in other environments.
23
+ if defined?(Rails) && Rails.env.development?
24
+ app.middleware.insert_after ActionDispatch::DebugExceptions, CommandDeck::Middleware
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandDeck
4
+ # Executor for running actions
5
+ class Executor
6
+ def self.call(key:, params:)
7
+ action = Registry.find_action(key)
8
+ raise ArgumentError, "Unknown action #{key}" unless action
9
+
10
+ coerced = coerce(action.params, params || {})
11
+ action.block.call(coerced, {})
12
+ end
13
+
14
+ def self.coerce(schema, raw)
15
+ return {} if schema.nil? || schema.empty?
16
+
17
+ schema.each_with_object({}) do |param, out|
18
+ name = param[:name]
19
+ value = raw[name] || raw[name.to_s]
20
+ out[name] = coerce_value(param[:type], value)
21
+ end
22
+ end
23
+
24
+ def self.coerce_value(type, value)
25
+ case type
26
+ when :boolean then coerce_boolean(value)
27
+ when :integer then coerce_integer(value)
28
+ when :selector then coerce_selector(value)
29
+ else coerce_string(value)
30
+ end
31
+ end
32
+
33
+ def self.coerce_boolean(value)
34
+ boolean_true?(value)
35
+ end
36
+
37
+ def self.coerce_integer(value)
38
+ integer_or_nil(value)
39
+ end
40
+
41
+ def self.coerce_selector(value)
42
+ return symbolize_keys_shallow(value) if value.is_a?(Hash)
43
+
44
+ value
45
+ end
46
+
47
+ def self.coerce_string(value)
48
+ value&.to_s
49
+ end
50
+
51
+ def self.symbolize_keys_shallow(hash)
52
+ hash.each_with_object({}) do |(k, v), out|
53
+ key = k.respond_to?(:to_sym) ? k.to_sym : k
54
+ out[key] = v
55
+ end
56
+ end
57
+
58
+ def self.boolean_true?(value)
59
+ return false if value.nil?
60
+
61
+ value == true || %w[true 1 on].include?(value.to_s.strip.downcase)
62
+ end
63
+
64
+ def self.integer_or_nil(value)
65
+ return nil if value.nil? || value == ""
66
+
67
+ value.to_i
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal response body injector adapted from the web-console project (MIT License).
4
+ # It safely appends content before </body> when present and adjusts Content-Length.
5
+ module CommandDeck
6
+ # Tiny middleware that injects a tiny floating UI into HTML responses.
7
+ class Injector
8
+ def initialize(body, headers)
9
+ @body = "".dup
10
+ body.each { |part| @body << part }
11
+ body.close if body.respond_to?(:close)
12
+ @headers = headers
13
+ end
14
+
15
+ def inject(content)
16
+ @headers[Rack::CONTENT_LENGTH] = (@body.bytesize + content.bytesize).to_s if @headers[Rack::CONTENT_LENGTH]
17
+
18
+ [
19
+ if (position = @body.rindex("</body>"))
20
+ [@body.insert(position, content)]
21
+ else
22
+ [@body << content]
23
+ end,
24
+ @headers
25
+ ]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require_relative "injector"
5
+
6
+ module CommandDeck
7
+ # Dev-only middleware that injects a tiny floating UI into HTML responses.
8
+ class Middleware
9
+ class << self
10
+ attr_accessor :mount_point
11
+ end
12
+ self.mount_point = "/command_deck"
13
+
14
+ def initialize(app)
15
+ @app = app
16
+ end
17
+
18
+ def call(env)
19
+ status, headers, body = @app.call(env)
20
+
21
+ begin
22
+ return [status, headers, body] unless html_response?(headers)
23
+ return [status, headers, body] if engine_request?(env)
24
+
25
+ body, headers = Injector.new(body, headers).inject(overlay_snippet)
26
+ [status, headers, body]
27
+ rescue StandardError
28
+ # Fail open: on any injection error, return original response
29
+ [status, headers, body]
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def html_response?(headers)
36
+ ctype = headers[Rack::CONTENT_TYPE].to_s
37
+ return true if ctype.empty?
38
+
39
+ ctype.include?("html")
40
+ end
41
+
42
+ def engine_request?(env)
43
+ path = env["PATH_INFO"].to_s
44
+ path.start_with?(self.class.mount_point)
45
+ end
46
+
47
+ def overlay_snippet
48
+ mp = self.class.mount_point
49
+ <<~HTML
50
+ <!-- Command Deck assets (ESM) -->
51
+ <link rel="stylesheet" href="#{mp}/assets/css/main.css" />
52
+ <script type="module" src="#{mp}/assets/js/main.js" data-mount="#{mp}"></script>
53
+ <!-- /Command Deck assets -->
54
+ HTML
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module CommandDeck
6
+ # Rails engine for Command Deck
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "command_deck.load_panels", after: :load_config_initializers do
9
+ path = Rails.root.join("app/command_deck")
10
+ Dir[path.join("**/*.rb")].sort.each { |f| load f } if Dir.exist?(path)
11
+ end
12
+ end
13
+ end