trek 2.0.1 → 2.2.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.prettierignore +2 -0
  3. data/.rubocop.yml +11 -0
  4. data/AGENTS.md +5 -2
  5. data/CHANGELOG.md +47 -1
  6. data/Gemfile.lock +1 -1
  7. data/Makefile +13 -0
  8. data/app/assets/icons/trek/audit.svg +1 -0
  9. data/app/assets/icons/trek/print.svg +1 -0
  10. data/app/components/trek/dashboard/analytics_component/analytics_component.html.slim +2 -0
  11. data/app/components/trek/dashboard/analytics_component.rb +1 -0
  12. data/app/components/trek/dashboard/chart_component/chart_component.css +192 -0
  13. data/app/components/trek/dashboard/chart_component/chart_component.html.slim +6 -0
  14. data/app/components/trek/dashboard/chart_component/chart_component.js +1 -0
  15. data/app/components/trek/dashboard/chart_component.rb +27 -0
  16. data/app/components/trek/dashboard/tile_component/tile_component.css +40 -0
  17. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +14 -4
  18. data/app/components/trek/dashboard/tile_component.rb +6 -2
  19. data/app/components/trek/entries/item_component/item_component.css +1 -0
  20. data/app/components/trek/form/actions_component/actions_component.css +6 -7
  21. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +10 -12
  22. data/app/components/trek/form/content_editor_component/content_editor_component.js +6 -0
  23. data/app/components/trek/form/content_editor_component.rb +4 -2
  24. data/app/components/trek/form/fieldset_component/fieldset_component.css +4 -3
  25. data/app/components/trek/form/group_component/group_component.css +5 -2
  26. data/app/components/trek/layout_component/layout_component.css +20 -0
  27. data/app/components/trek/layout_component/layout_component.html.slim +16 -0
  28. data/app/components/trek/layout_component/layout_component.js +35 -6
  29. data/app/components/trek/menu_component.rb +15 -2
  30. data/app/controllers/concerns/trek/model.rb +13 -6
  31. data/app/controllers/trek/resource_controller.rb +9 -1
  32. data/app/formatters/prosemirror_to_html/global_id_to_links_formatter.rb +3 -1
  33. data/app/helpers/trek/i18n_helper.rb +1 -1
  34. data/app/models/prosemirror.rb +5 -2
  35. data/app/views/trek/panels/images/_form.html.slim +1 -1
  36. data/app/views/trek/panels/images/create.turbo_stream.slim +3 -3
  37. data/app/views/trek/panels/images/new.html.slim +1 -1
  38. data/app/views/trek/panels/links/_form.html.slim +1 -1
  39. data/app/views/trek/panels/links/create.turbo_stream.slim +3 -3
  40. data/app/views/trek/panels/links/new.html.slim +1 -1
  41. data/app/views/trek/panels/prompts/_form.html.slim +1 -1
  42. data/app/views/trek/panels/prompts/create.turbo_stream.slim +3 -3
  43. data/app/views/trek/panels/prompts/new.html.slim +1 -1
  44. data/docs/.vitepress/config.mjs +2 -1
  45. data/docs/deploy.md +57 -0
  46. data/docs/getting-started.md +1 -0
  47. data/docs/reference/controllers.md +23 -0
  48. data/docs/reference/generators/install.md +2 -0
  49. data/docs/reference/generators/scaffold.md +12 -0
  50. data/lib/generators/trek/install/config_generator.rb +9 -1
  51. data/lib/generators/trek/install/postmark_generator.rb +2 -0
  52. data/lib/generators/trek/install/prosopite_generator.rb +56 -0
  53. data/lib/generators/trek/install/scalingo_generator.rb +19 -7
  54. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  55. data/lib/generators/trek/install_generator.rb +1 -0
  56. data/lib/generators/trek/scaffold_generator.rb +37 -5
  57. data/lib/generators/trek/templates/controllers/admin/scaffold_controller.rb.tt +2 -2
  58. data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +4 -3
  59. data/lib/generators/trek/templates/spec/requests/admin/n_plus_one_spec.rb +28 -0
  60. data/lib/generators/trek/templates/views/admin/scaffold/show.html.slim.tt +2 -2
  61. data/lib/generators/trek/upgrade/v2_generator.rb +31 -0
  62. data/lib/trek/version.rb +1 -1
  63. data/package.json +1 -1
  64. data/vendor/tom_select/css/tom-select.custom.css +0 -1
  65. metadata +11 -1
@@ -23,6 +23,7 @@ export class Controller extends BaseController {
23
23
  ];
24
24
  static values = {
25
25
  placeholder: String,
26
+ autofocus: Boolean,
26
27
  };
27
28
 
28
29
  connect() {
@@ -40,6 +41,10 @@ export class Controller extends BaseController {
40
41
  // }),
41
42
  Link.configure({
42
43
  openOnClick: false,
44
+ // Internal links are stored as GlobalIDs (resolved to URLs at render
45
+ // time); without this the gid:// scheme fails Link's URI validation
46
+ // and setLink silently does nothing.
47
+ protocols: ["gid"],
43
48
  HTMLAttributes: {
44
49
  "data-action": "trek--form--content-editor#selectLink",
45
50
  target: null,
@@ -55,6 +60,7 @@ export class Controller extends BaseController {
55
60
  PromptsBlock,
56
61
  ],
57
62
  content: this.contentJson,
63
+ autofocus: this.autofocusValue ? "end" : false,
58
64
  });
59
65
 
60
66
  this.editor.on("update", () => this.updateContent());
@@ -8,7 +8,7 @@ module Trek
8
8
  include Turbo::FramesHelper
9
9
  include ViewComponent::Translatable
10
10
 
11
- attr_reader :nodes, :blocks, :floating
11
+ attr_reader :nodes, :blocks, :floating, :autofocus
12
12
 
13
13
  def initialize(form, object_name, method_name, options = {})
14
14
  @nodes = options.delete(:nodes)
@@ -17,6 +17,7 @@ module Trek
17
17
  @blocks = true if @blocks.nil?
18
18
  @floating = options.delete(:floating)
19
19
  @floating = true if @floating.nil?
20
+ @autofocus = options.delete(:autofocus) || false
20
21
 
21
22
  super
22
23
  end
@@ -29,7 +30,8 @@ module Trek
29
30
  }.merge(
30
31
  stimulus_class_hash("active", class_for("is-active")),
31
32
  stimulus_class_hash("empty", class_for("is-empty")),
32
- stimulus_value_hash("placeholder", t(".placeholder"))
33
+ stimulus_value_hash("placeholder", t(".placeholder")),
34
+ stimulus_value_hash("autofocus", autofocus)
33
35
  )
34
36
  end
35
37
 
@@ -10,6 +10,10 @@
10
10
  }
11
11
 
12
12
  .title {
13
+ color: var(--slate-12);
14
+ display: block;
15
+ font-weight: var(--semibold);
16
+ margin-bottom: 1.2rem;
13
17
  margin-left: -1rem;
14
18
  padding: 0 1rem;
15
19
  }
@@ -35,8 +39,5 @@
35
39
  }
36
40
 
37
41
  .root.section .title {
38
- color: var(--slate-12);
39
42
  font-size: 1.2em;
40
- font-weight: var(--semibold);
41
- margin-bottom: 1.2rem;
42
43
  }
@@ -32,6 +32,7 @@
32
32
  .root input[type="text"],
33
33
  .root input[type="url"],
34
34
  .root input[type="date"],
35
+ .root input[type="time"],
35
36
  .root select,
36
37
  .root textarea {
37
38
  background-color: var(--slate-2);
@@ -55,7 +56,8 @@
55
56
 
56
57
  .root input[type="number"],
57
58
  .root input[name*="color"],
58
- .root input[type="date"] {
59
+ .root input[type="date"],
60
+ .root input[type="time"] {
59
61
  max-width: 16rem;
60
62
  }
61
63
 
@@ -63,7 +65,8 @@
63
65
  max-width: 19rem;
64
66
  }
65
67
 
66
- .root input[type="date"] {
68
+ .root input[type="date"],
69
+ .root input[type="time"] {
67
70
  color: var(--slate-11);
68
71
  font-weight: var(--light);
69
72
  }
@@ -34,6 +34,18 @@
34
34
  transform: translateX(0);
35
35
  }
36
36
 
37
+ /* Stacked panel: overlays the primary panel. */
38
+ .panel-stacked {
39
+ box-shadow: 0 0 16px var(--slate-a8);
40
+ z-index: 3;
41
+ }
42
+
43
+ /* Only when it actually stacks on an open primary panel, make it 1rem
44
+ narrower so a sliver of the panel underneath stays visible. */
45
+ .panel.open + .panel-stacked {
46
+ width: 41rem;
47
+ }
48
+
37
49
  .panel-close {
38
50
  --icon-size: 1.6rem;
39
51
 
@@ -65,4 +77,12 @@
65
77
  .content {
66
78
  margin: 6rem 3rem;
67
79
  }
80
+
81
+ .panel {
82
+ width: 100vw;
83
+ }
84
+
85
+ .panel.open + .panel-stacked {
86
+ width: calc(100vw - 1rem);
87
+ }
68
88
  }
@@ -18,3 +18,19 @@ div(
18
18
  data-action=stimulus_action("closePanel")
19
19
  )
20
20
  = render(Trek::IconComponent.new("trek/close"))
21
+ / Stacked panel: opens on top of the primary panel, so a panel-hosted form
22
+ (e.g. a content editor in a section panel) survives sub-panels like the
23
+ link/image/prompt inserters.
24
+ aside(
25
+ class=[class_for("panel"), class_for("panel-stacked")]
26
+ data=stimulus_target_hash("stackedPanel")
27
+ )
28
+ = turbo_frame_tag( \
29
+ "#{identifier}-stacked-panel",
30
+ data: stimulus_target_hash("stackedPanelFrame"),
31
+ )
32
+ button(
33
+ class=class_for("panel-close")
34
+ data-action=stimulus_action("closeStackedPanel")
35
+ )
36
+ = render(Trek::IconComponent.new("trek/close"))
@@ -5,7 +5,14 @@ import { useClickOutside } from "stimulus-use";
5
5
 
6
6
  export class Controller extends BaseController {
7
7
  static classes = ["open"];
8
- static targets = ["panel", "panelFrame", "panelCloser"];
8
+ static targets = [
9
+ "panel",
10
+ "panelFrame",
11
+ "panelCloser",
12
+ "stackedPanel",
13
+ "stackedPanelFrame",
14
+ "stackedPanelCloser",
15
+ ];
9
16
 
10
17
  connect() {
11
18
  useClickOutside(this, { element: this.panelTarget });
@@ -17,31 +24,53 @@ export class Controller extends BaseController {
17
24
  this.closePanel();
18
25
  }
19
26
 
27
+ stackedPanelCloserTargetConnected() {
28
+ this.closeStackedPanel();
29
+ }
30
+
20
31
  openPanelWhenLoaded() {
21
32
  this.panelFrameTarget.loaded.then(() => this.openPanel());
22
33
  }
23
34
 
35
+ openStackedPanelWhenLoaded() {
36
+ this.stackedPanelFrameTarget.loaded.then(() => this.openStackedPanel());
37
+ }
38
+
24
39
  openPanel() {
25
40
  this.panelTarget.classList.add(this.openClass);
26
41
  }
27
42
 
43
+ openStackedPanel() {
44
+ this.stackedPanelTarget.classList.add(this.openClass);
45
+ }
46
+
28
47
  closePanel() {
48
+ this.closeStackedPanel();
29
49
  this.panelTarget.classList.remove(this.openClass);
30
- this.emptyFrame();
50
+ this.emptyFrame(this.panelFrameTarget);
51
+ }
52
+
53
+ closeStackedPanel() {
54
+ this.stackedPanelTarget.classList.remove(this.openClass);
55
+ this.emptyFrame(this.stackedPanelFrameTarget);
31
56
  }
32
57
 
33
58
  clickOutside(e) {
34
59
  if (
35
60
  e.target.dataset.action &&
36
- e.target.dataset.action.includes("trek--layout#openPanel")
61
+ (e.target.dataset.action.includes("trek--layout#openPanel") ||
62
+ e.target.dataset.action.includes("trek--layout#openStackedPanel"))
37
63
  )
38
64
  return;
65
+ // Clicks inside the stacked panel are outside the primary panel element,
66
+ // but must not close anything.
67
+ if (this.stackedPanelTarget.contains(e.target)) return;
39
68
  this.closePanel();
40
69
  }
41
70
 
42
- emptyFrame() {
43
- while (this.panelFrameTarget.firstChild) {
44
- this.panelFrameTarget.removeChild(this.panelFrameTarget.firstChild);
71
+ emptyFrame(frame) {
72
+ while (frame.firstChild) {
73
+ frame.removeChild(frame.firstChild);
45
74
  }
46
75
  }
47
76
  }
@@ -57,6 +57,11 @@ module Trek
57
57
  option :href
58
58
  option :text
59
59
  option :allowed_to, default: -> { true }
60
+ # Overrides the active state for a section whose sub-resources live under other
61
+ # controllers: a String is a controller_path prefix ("admin/shop"), a Regexp is matched
62
+ # against it, a boolean forces the state. By default the item is active when its href
63
+ # matches the current controller.
64
+ option :active, optional: true
60
65
 
61
66
  def call
62
67
  tag.li(
@@ -79,10 +84,18 @@ module Trek
79
84
  private
80
85
 
81
86
  def active?
82
- href_path = Array.wrap(href).map do |part|
87
+ case active
88
+ when nil then href_path == controller_path
89
+ when String then controller_path.start_with?(active)
90
+ when Regexp then active.match?(controller_path)
91
+ else active
92
+ end
93
+ end
94
+
95
+ def href_path
96
+ Array.wrap(href).map do |part|
83
97
  part.respond_to?(:model_name) ? part.model_name.collection : part
84
98
  end.join("/").sub("root", "dashboards")
85
- href_path == controller_path
86
99
  end
87
100
  end
88
101
  end
@@ -21,7 +21,7 @@ module Trek
21
21
  end
22
22
 
23
23
  # Returns model collection as an array of symbols for route building.
24
- # For namespaced models like Missive::List, returns [:missive, :lists].
24
+ # For namespaced models like Shop::Product, returns [:shop, :products].
25
25
  # For non-namespaced models like Page, returns [:pages].
26
26
  def model_collection_route
27
27
  model_name.collection.split("/").map(&:to_sym)
@@ -32,13 +32,20 @@ module Trek
32
32
  end
33
33
 
34
34
  # Returns model element as an array of symbols for route building.
35
- # For namespaced models like Missive::List, returns [:missive, :list].
36
35
  # For non-namespaced models like Page, returns [:page].
37
- # Derived from collection (which preserves namespace) rather than element
38
- # (which demodulizes).
36
+ # For namespaced models, it depends on the route key: callers build member
37
+ # routes as `[:admin, *model_element_route[0...-1], record]`, and the
38
+ # record's route key must not carry the namespace twice.
39
+ # - Shop::Product (plain app namespace, route key `shop_product`)
40
+ # returns [:shop_product] — a [:shop, :product] prefix would yield
41
+ # `admin_shop_shop_product_path`.
42
+ # - Blog::Post from an isolated engine (`isolate_namespace` → relative
43
+ # model naming, route key `post`) returns [:blog, :post], since the
44
+ # namespace segment is needed for `admin_blog_post_path`.
39
45
  def model_element_route
40
- parts = model_name.collection.split("/")
41
- (parts[0...-1] + [parts.last.singularize]).map(&:to_sym)
46
+ element = model_name.singular_route_key
47
+ namespace = element == model_name.element ? model_name.collection.split("/")[0...-1] : []
48
+ (namespace + [element]).map(&:to_sym)
42
49
  end
43
50
 
44
51
  def model_intro(action: nil)
@@ -79,8 +79,16 @@ module Trek
79
79
  authorize! @object
80
80
  end
81
81
 
82
+ # The form posts under model_name.param_key, which keeps the namespace
83
+ # (shop_product for Shop::Product) unlike model_element (product).
82
84
  def object_params
83
- authorized(params.require(model_element))
85
+ authorized(params.require(model_name.param_key))
86
+ end
87
+
88
+ # action_policy infers the target from controller_name.classify, which
89
+ # doesn't exist for a namespaced model (Product for Shop::Product).
90
+ def implicit_authorization_target
91
+ @object || model
84
92
  end
85
93
  end
86
94
  end
@@ -2,7 +2,9 @@ module ProsemirrorToHtml
2
2
  class GlobalIdToLinksFormatter
3
3
  include Rails.application.routes.url_helpers
4
4
 
5
- GLOBAL_ID_REGEX = %r{(gid://[[:alnum:]]+/[\w:]+/[\w-]+)}
5
+ # The app segment allows hyphens and dots: GlobalID derives it from the
6
+ # Rails application name (e.g. "au-poste").
7
+ GLOBAL_ID_REGEX = %r{(gid://[\w.-]+/[\w:]+/[\w-]+)}
6
8
 
7
9
  def initialize(str)
8
10
  @str = str
@@ -11,7 +11,7 @@ module Trek
11
11
  end
12
12
 
13
13
  def i18n_model_gender(model_name)
14
- t("activerecord.models.#{model_name.element}.gender", default: "n")
14
+ t("activerecord.models.#{model_name.i18n_key}.gender", default: "n")
15
15
  end
16
16
 
17
17
  def boolean_collection(key: "boolean_labels")
@@ -10,9 +10,12 @@ class Prosemirror
10
10
  def format_content(parsed_content)
11
11
  return if parsed_content.blank?
12
12
 
13
- content = renderer.render(parsed_content)
13
+ # GlobalIDs must be resolved to URLs before rendering: the renderer's
14
+ # sanitizer strips href attributes with unknown protocols, so a gid://
15
+ # link would lose its href entirely.
16
+ content = ProsemirrorToHtml::GlobalIdToLinksFormatter.new(parsed_content.to_json).to_s
17
+ content = renderer.render(JSON.parse(content))
14
18
  content = ProsemirrorToHtml::RemoveEmptyParagraphsFormatter.new(content).to_s
15
- content = ProsemirrorToHtml::GlobalIdToLinksFormatter.new(content).to_s
16
19
  # rubocop:disable Rails/OutputSafety
17
20
  content.html_safe
18
21
  # rubocop:enable Rails/OutputSafety
@@ -24,7 +24,7 @@ h1.c-trek--layout-panel-title = t(".title")
24
24
  = render Trek::Form::ActionsComponent.new do |c|
25
25
  ruby:
26
26
  c.with_button(
27
- data: { action: "trek--layout#closePanel" },
27
+ data: { action: "trek--layout#closeStackedPanel" },
28
28
  text: t("admin.actions.cancel"),
29
29
  )
30
30
 
@@ -1,6 +1,6 @@
1
1
  - if @object.valid?
2
- = turbo_stream.append("trek--layout-panel")
3
- div(data-trek--layout-target="panelCloser")
2
+ = turbo_stream.append("trek--layout-stacked-panel")
3
+ div(data-trek--layout-target="stackedPanelCloser")
4
4
 
5
5
  = turbo_stream.append(@object.turbo_frame_id)
6
6
  div(
@@ -15,5 +15,5 @@
15
15
  text=@object.text
16
16
  )
17
17
  - else
18
- = turbo_stream.update("trek--layout-panel")
18
+ = turbo_stream.update("trek--layout-stacked-panel")
19
19
  = render "form"
@@ -1,2 +1,2 @@
1
- = turbo_frame_tag("trek--layout-panel")
1
+ = turbo_frame_tag("trek--layout-stacked-panel")
2
2
  = render "form"
@@ -28,7 +28,7 @@ h1.c-trek--layout-panel-title = t(".title")
28
28
  = render Trek::Form::ActionsComponent.new do |c|
29
29
  ruby:
30
30
  c.with_button(
31
- data: { action: "trek--layout#closePanel" },
31
+ data: { action: "trek--layout#closeStackedPanel" },
32
32
  text: t("admin.actions.cancel"),
33
33
  )
34
34
 
@@ -1,6 +1,6 @@
1
1
  - if @object.valid?
2
- = turbo_stream.append("trek--layout-panel")
3
- div(data-trek--layout-target="panelCloser")
2
+ = turbo_stream.append("trek--layout-stacked-panel")
3
+ div(data-trek--layout-target="stackedPanelCloser")
4
4
 
5
5
  = turbo_stream.append(@object.turbo_frame_id)
6
6
  div(
@@ -8,5 +8,5 @@
8
8
  data-params=@object.to_json
9
9
  )
10
10
  - else
11
- = turbo_stream.update("trek--layout-panel")
11
+ = turbo_stream.update("trek--layout-stacked-panel")
12
12
  = render "form"
@@ -1,2 +1,2 @@
1
- = turbo_frame_tag("trek--layout-panel")
1
+ = turbo_frame_tag("trek--layout-stacked-panel")
2
2
  = render "form"
@@ -26,7 +26,7 @@ h1.c-trek--layout-panel-title = t(".title")
26
26
  = render Trek::Form::ActionsComponent.new do |c|
27
27
  ruby:
28
28
  c.with_button(
29
- data: { action: "trek--layout#closePanel" },
29
+ data: { action: "trek--layout#closeStackedPanel" },
30
30
  text: t("admin.actions.cancel"),
31
31
  )
32
32
 
@@ -1,6 +1,6 @@
1
1
  - if @object.valid?
2
- = turbo_stream.append("trek--layout-panel")
3
- div(data-trek--layout-target="panelCloser")
2
+ = turbo_stream.append("trek--layout-stacked-panel")
3
+ div(data-trek--layout-target="stackedPanelCloser")
4
4
 
5
5
  = turbo_stream.append(@object.turbo_frame_id)
6
6
  div(
@@ -14,5 +14,5 @@
14
14
  title=@object.title
15
15
  )
16
16
  - else
17
- = turbo_stream.update("trek--layout-panel")
17
+ = turbo_stream.update("trek--layout-stacked-panel")
18
18
  = render "form"
@@ -1,2 +1,2 @@
1
- = turbo_frame_tag("trek--layout-panel")
1
+ = turbo_frame_tag("trek--layout-stacked-panel")
2
2
  = render "form"
@@ -33,7 +33,8 @@ export default defineConfig({
33
33
  {
34
34
  text: 'Guides',
35
35
  items: [
36
- { text: 'How to customize Trek', link: '/customize' }
36
+ { text: 'How to customize Trek', link: '/customize' },
37
+ { text: 'Deploying to Scalingo', link: '/deploy' }
37
38
  ]
38
39
  },
39
40
  {
data/docs/deploy.md ADDED
@@ -0,0 +1,57 @@
1
+ # Deploying to Scalingo
2
+
3
+ Trek apps deploy to [Scalingo](https://scalingo.com) with the dedicated [trek-buildpack](https://github.com/etaminstudio/trek-buildpack), a single buildpack that replaces the usual `apt` + `ruby` buildpack pair and knows how a Trek app is built.
4
+
5
+ ## What the buildpack does
6
+
7
+ - installs the APT packages listed in `Aptfile` (image processing libraries, jemalloc), skipping `apt-get update` and package resolution entirely when the `Aptfile` is unchanged
8
+ - installs Ruby with [rv](https://rv.dev) — precompiled, in seconds — using the version pinned in `.ruby-version`
9
+ - installs Node.js pinned by `.node-version` (generated by `rails new`), and the exact Yarn release pinned by `package.json`'s `packageManager` field — no unpinned defaults
10
+ - runs `yarn install` exactly once, with a persistent cache, before `assets:precompile`
11
+ - slims the shipped image: `node_modules`, Yarn caches and Node.js itself are dropped after the build, roughly halving the image size of a typical Trek app — faster deploys, restarts and scaling
12
+
13
+ Compared with the stock buildpacks, deploys typically go from around 3 minutes to about 1 minute 30 with a warm cache.
14
+
15
+ ## Set up the app
16
+
17
+ Generate the Scalingo files (an `Aptfile` and a `Procfile` with the web process and post-deploy migrations):
18
+
19
+ ```sh
20
+ rails g trek:install:scalingo
21
+ ```
22
+
23
+ Then configure the Scalingo app's environment, pinning the buildpack to a release tag:
24
+
25
+ ```sh
26
+ scalingo --app <app> env-set BUILDPACK_URL=https://github.com/etaminstudio/trek-buildpack#v0.1.0
27
+ scalingo --app <app> env-set 'DATABASE_URL=$SCALINGO_POSTGRESQL_URL'
28
+ scalingo --app <app> env-set RAILS_MASTER_KEY=$(cat config/master.key)
29
+ ```
30
+
31
+ Make sure `config/database.yml` reads `DATABASE_URL` in production — the section `rails new` generates targets a local socket with a password variable instead:
32
+
33
+ ```yml
34
+ production:
35
+ primary: &primary_production
36
+ <<: *default
37
+ url: <%= ENV["DATABASE_URL"] %>
38
+ ```
39
+
40
+ Provision a PostgreSQL addon (`scalingo --app <app> addons-add postgresql <plan>`), push, and the app boots with `RAILS_ENV`, `RAILS_LOG_TO_STDOUT` and `RAILS_SERVE_STATIC_FILES` already set by the buildpack.
41
+
42
+ ## Options
43
+
44
+ | Variable | Effect |
45
+ | --- | --- |
46
+ | `TREK_BUILDPACK_RUNTIME_NODE=1` | Ship Node.js in the image (only if the app runs JavaScript at runtime, e.g. execjs) |
47
+ | `TREK_BUILDPACK_PRUNE_SOURCEMAPS=1` | Also remove `.map` files from the compiled assets |
48
+
49
+ ## Migrating an existing app
50
+
51
+ Apps deployed with the stock `.buildpacks` (apt + ruby) migrate in three steps:
52
+
53
+ 1. delete `.buildpacks` (re-running `rails g trek:install:scalingo` does it) and check that `.node-version` exists — apps generated with `rails new -j esbuild` have it
54
+ 2. set `BUILDPACK_URL` as above, and map `DATABASE_URL` if the app doesn't read it yet
55
+ 3. deploy — the first build fills the caches, the next ones benefit from them
56
+
57
+ The buildpack's [README](https://github.com/etaminstudio/trek-buildpack#readme) details the cache layout and the `Aptfile` syntax.
@@ -132,3 +132,4 @@ Next steps:
132
132
 
133
133
  - [Customize Trek](/customize) — branding, favicon, components
134
134
  - [Reference manual](/reference/) — generators, models, components and more
135
+ - [Deploying to Scalingo](/deploy) — `trek:install:scalingo`, a few environment variables, and the dedicated trek-buildpack handles the rest
@@ -93,3 +93,26 @@ end
93
93
  ```
94
94
 
95
95
  Like `Trek::Filters`, forgetting to call `apply_scopes!` raises `Trek::Scopes::NotAppliedError` outside production.
96
+
97
+ ## N+1 detection
98
+
99
+ `trek:install:prosopite` (part of `trek:install`) sets up [Prosopite](https://github.com/charkost/prosopite)
100
+ in development and test: `config/initializers/prosopite.rb` mounts its Rack middleware, so **every request** —
101
+ including Trek's own engine controllers — is scanned. A detected N+1 raises `Prosopite::NPlusOneQueriesError`
102
+ in test (so request specs fail) and is logged in development.
103
+
104
+ The generator also adds `spec/requests/admin/n_plus_one_spec.rb`, a smoke request spec that renders the
105
+ dashboard, the pages index and the fragments index as an admin — extend its route table with the app's other
106
+ admin pages (scaffolded indexes and edit pages in particular), with a few records created beforehand, since an
107
+ empty list can't reveal an N+1.
108
+
109
+ Legitimate repeated queries (e.g. one `COUNT` per row) can be exempted with the escape hatch:
110
+
111
+ ```ruby
112
+ Prosopite.pause do
113
+ scopes.index_with { |scope| relation.public_send(scope).count }
114
+ end
115
+ ```
116
+
117
+ Trek's own concerns already do this where needed (`Trek::Scopes#scope_counts`). Guard with
118
+ `if defined?(Prosopite)` in code that must also run in production, where the gem isn't loaded.
@@ -32,7 +32,9 @@ The installer orchestrates dedicated sub-generators, all under the `trek:install
32
32
  | Tooling | esbuild, PostCSS, EditorConfig, Procfiles, Makefile, health check endpoint |
33
33
  | Linting | Standard, ESLint, Stylelint, Prettier, Slim Lint, Lefthook git hooks |
34
34
  | Security | Brakeman, bundler-audit, ruby_audit |
35
+ | N+1 detection | Prosopite (development/test), scanning every request — see [Controllers › N+1 detection](/reference/controllers#n-1-detection) |
35
36
  | CI | GitLab CI configuration |
37
+ | Deployment | Scalingo files (`trek:install:scalingo`, run on demand) — see [Deploying to Scalingo](/deploy) |
36
38
 
37
39
  ## ENV variables read by the installer
38
40
 
@@ -18,6 +18,18 @@ Akin to Rails', Trek's scaffold generates, with a single command:
18
18
 
19
19
  Standard Rails attribute types are supported (`string`, `text`, `integer`, `float`, `decimal`, `boolean`, `date`, `datetime`, `references`…) and mapped to the matching [form helpers](/reference/forms): `text` renders the content editor, `boolean` a switch box, `references` a collection select, and so on.
20
20
 
21
+ ## Namespaced models
22
+
23
+ ```sh
24
+ rails g trek:scaffold Shop::Product name price:decimal
25
+ ```
26
+
27
+ The controller and policy are nested accordingly (`Admin::Shop::ProductsController`, `Admin::Shop::ProductPolicy`), the routes go under `namespace :shop`, and everything else follows the namespace.
28
+
29
+ The generated code references the model as `::Shop::Product`, top-level qualified, and the dashboard menu entry is `::Shop::Product` too. This is deliberate: once `Admin::Shop` exists, a bare `Shop` written anywhere under `module Admin` resolves to that namespace instead of the `Shop` model. Existing admin code that references such a model — an `Admin::ShopsController#model` returning `Shop`, or a `Shop` entry in `Admin::Dashboard::MODELS_IN_MENU` — must be updated to `::Shop` once a scaffold introduces a namespace with the same name (the menu would otherwise silently list the module).
30
+
31
+ Also note that `rails g model Shop::Product` (which the scaffold runs) writes `app/models/shop.rb` as a `module Shop` namespace file; if a `Shop` model already lives there, the generator warns and Rails asks before overwriting it — move the model or answer `n`.
32
+
21
33
  ## Opt-in concerns
22
34
 
23
35
  The generated model ships with commented-out hooks for Trek's most useful [concerns](/reference/), ready to be enabled when needed:
@@ -25,7 +25,15 @@ module Trek
25
25
 
26
26
  # Mailers run outside a request, so in production they need the same host for
27
27
  # link helpers. Development and test keep the host from their environment files.
28
- ActionMailer::Base.default_url_options = Rails.application.routes.default_url_options if Rails.env.production?
28
+ # Deferred with on_load: referencing ActionMailer::Base here would load it
29
+ # before the app's autoloader can resolve app classes, and its load hook
30
+ # constantizes config.action_mailer.delivery_job — NameError at boot when
31
+ # the app configures a custom delivery job.
32
+ if Rails.env.production?
33
+ ActiveSupport.on_load(:action_mailer) do
34
+ self.default_url_options = Rails.application.routes.default_url_options
35
+ end
36
+ end
29
37
  RUBY
30
38
  end
31
39
  end
@@ -28,6 +28,8 @@ module Trek
28
28
  end
29
29
 
30
30
  def inject_postmark_to_config
31
+ return if File.read("config/environments/production.rb").include?("delivery_method = :postmark")
32
+
31
33
  inject_into_file "config/environments/production.rb",
32
34
  config_injection,
33
35
  after: %r{# Specify outgoing SMTP server\. Remember to add smtp/\* credentials via (?:bin/)?rails credentials:edit\.\n}