trek 2.5.0 → 2.6.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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/AGENTS.md +7 -4
  4. data/CHANGELOG.md +37 -0
  5. data/Gemfile +5 -0
  6. data/Gemfile.lock +16 -3
  7. data/Makefile +2 -0
  8. data/app/components/trek/dashboard/tile_component/tile_component.css +9 -0
  9. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +4 -2
  10. data/app/components/trek/dashboard/tile_component.rb +12 -2
  11. data/app/components/trek/form/content_editor_component/content_editor_component.css +5 -0
  12. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +19 -93
  13. data/app/components/trek/form/content_editor_component/content_editor_component.js +94 -251
  14. data/app/components/trek/form/content_editor_component/extensions/block_view.js +40 -0
  15. data/app/components/trek/form/content_editor_component/extensions/image_block.js +28 -26
  16. data/app/components/trek/form/content_editor_component/extensions/prompts_block.js +15 -26
  17. data/app/components/trek/form/content_editor_component/extensions/table_of_contents.js +1 -3
  18. data/app/components/trek/form/content_editor_component.rb +15 -0
  19. data/app/components/trek/form/image_field_component/image_field_component.css +40 -1
  20. data/app/components/trek/form/image_field_component/image_field_component.html.slim +0 -9
  21. data/app/components/trek/form/image_field_component.rb +36 -9
  22. data/app/content/trek/content/cleaner.rb +53 -0
  23. data/app/content/trek/content/document.rb +2 -2
  24. data/app/content/trek/content/prompts_block.rb +1 -2
  25. data/app/content/trek/content.rb +6 -0
  26. data/app/controllers/concerns/trek/reorderable.rb +24 -6
  27. data/app/controllers/trek/csp_reports_controller.rb +14 -1
  28. data/app/controllers/trek/panels/prompts_controller.rb +1 -1
  29. data/app/controllers/trek/resource_controller.rb +5 -5
  30. data/app/jobs/trek/purge_expired_sessions_job.rb +4 -3
  31. data/app/models/concerns/trek/contentable.rb +15 -1
  32. data/app/models/concerns/trek/users/sign_in_code.rb +70 -0
  33. data/app/models/concerns/trek/users/sign_in_lockout.rb +4 -2
  34. data/app/views/trek/panels/links/_form.html.slim +2 -4
  35. data/app/views/trek/panels/prompts/create.turbo_stream.slim +0 -1
  36. data/config/locales/trek.en.yml +26 -11
  37. data/config/locales/trek.fr.yml +26 -11
  38. data/docs/.vitepress/config.mjs +4 -1
  39. data/docs/background-jobs.md +69 -0
  40. data/docs/content-editor.md +219 -0
  41. data/docs/customize.md +2 -0
  42. data/docs/deploy.md +2 -2
  43. data/docs/getting-started.md +1 -1
  44. data/docs/index.md +1 -1
  45. data/docs/reference/components.md +2 -2
  46. data/docs/reference/concerns/contentable.md +10 -4
  47. data/docs/reference/concerns/formattable.md +1 -1
  48. data/docs/reference/environment-variables.md +1 -0
  49. data/docs/reference/formatters.md +2 -30
  50. data/docs/reference/forms.md +1 -1
  51. data/docs/reference/generators/audit-log.md +2 -2
  52. data/docs/reference/generators/install.md +3 -2
  53. data/docs/reference/generators/public-accounts.md +141 -0
  54. data/docs/reference/generators/security-headers.md +1 -1
  55. data/docs/reference/index.md +2 -1
  56. data/docs/reference/models/user.md +3 -1
  57. data/docs/what-is-trek.md +1 -1
  58. data/lib/generators/trek/audit_log_generator.rb +54 -0
  59. data/lib/generators/trek/install/admin/users_generator.rb +6 -1
  60. data/lib/generators/trek/install/audit_log_generator.rb +9 -45
  61. data/lib/generators/trek/install/good_job_generator.rb +161 -0
  62. data/lib/generators/trek/install/model_specs_generator.rb +3 -3
  63. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  64. data/lib/generators/trek/install/scalingo_generator.rb +7 -45
  65. data/lib/generators/trek/install_generator.rb +15 -0
  66. data/lib/generators/trek/public_accounts_generator.rb +267 -0
  67. data/lib/generators/trek/scalingo_generator.rb +55 -0
  68. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  69. data/lib/generators/trek/templates/controllers/admin/password_renewals_controller.rb +11 -0
  70. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +1 -1
  71. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +0 -8
  72. data/lib/generators/trek/templates/initializers/content_security_policy.rb +3 -2
  73. data/lib/generators/trek/templates/initializers/good_job.rb.tt +38 -0
  74. data/lib/generators/trek/templates/jobs/mail_delivery_job.rb +6 -0
  75. data/lib/generators/trek/templates/layouts/admin.html.slim +5 -0
  76. data/lib/generators/trek/templates/mailers/admin_mailer.rb +4 -2
  77. data/lib/generators/trek/templates/package.json.tt +1 -1
  78. data/lib/generators/trek/templates/public_accounts/api/authentication.rb.tt +76 -0
  79. data/lib/generators/trek/templates/public_accounts/api/controllers/accounts_controller.rb.tt +22 -0
  80. data/lib/generators/trek/templates/public_accounts/api/controllers/active_sessions_controller.rb.tt +21 -0
  81. data/lib/generators/trek/templates/public_accounts/api/controllers/base_controller.rb.tt +71 -0
  82. data/lib/generators/trek/templates/public_accounts/api/controllers/email_changes_controller.rb.tt +55 -0
  83. data/lib/generators/trek/templates/public_accounts/api/controllers/email_confirmations_controller.rb.tt +19 -0
  84. data/lib/generators/trek/templates/public_accounts/api/controllers/password_resets_controller.rb.tt +28 -0
  85. data/lib/generators/trek/templates/public_accounts/api/controllers/passwords_controller.rb.tt +17 -0
  86. data/lib/generators/trek/templates/public_accounts/api/controllers/registrations_controller.rb.tt +17 -0
  87. data/lib/generators/trek/templates/public_accounts/api/controllers/sessions_controller.rb.tt +45 -0
  88. data/lib/generators/trek/templates/public_accounts/api/controllers/sign_in_codes_controller.rb.tt +15 -0
  89. data/lib/generators/trek/templates/public_accounts/jobs/sign_in_code_job.rb.tt +13 -0
  90. data/lib/generators/trek/templates/public_accounts/locales/accounts.en.yml.tt +205 -0
  91. data/lib/generators/trek/templates/public_accounts/locales/accounts.fr.yml.tt +205 -0
  92. data/lib/generators/trek/templates/public_accounts/mailers/account_mailer.rb.tt +85 -0
  93. data/lib/generators/trek/templates/public_accounts/migrations/create_accounts.rb.tt +46 -0
  94. data/lib/generators/trek/templates/public_accounts/models/account/session.rb.tt +47 -0
  95. data/lib/generators/trek/templates/public_accounts/models/account/sign_in_code.rb.tt +8 -0
  96. data/lib/generators/trek/templates/public_accounts/models/account.rb.tt +73 -0
  97. data/lib/generators/trek/templates/public_accounts/spec/api/accounts_spec.rb.tt +90 -0
  98. data/lib/generators/trek/templates/public_accounts/spec/api/sign_ins_spec.rb.tt +158 -0
  99. data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt +56 -0
  100. data/lib/generators/trek/templates/public_accounts/spec/models/account_spec.rb.tt +109 -0
  101. data/lib/generators/trek/templates/public_accounts/spec/web/accounts_spec.rb.tt +126 -0
  102. data/lib/generators/trek/templates/public_accounts/spec/web/authentication_spec.rb.tt +43 -0
  103. data/lib/generators/trek/templates/public_accounts/spec/web/sign_ins_spec.rb.tt +215 -0
  104. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.html.slim.tt +10 -0
  105. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.text.erb.tt +8 -0
  106. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.html.slim.tt +13 -0
  107. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.text.erb.tt +8 -0
  108. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.html.slim.tt +9 -0
  109. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.text.erb.tt +11 -0
  110. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.html.slim.tt +11 -0
  111. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.text.erb.tt +11 -0
  112. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.html.slim.tt +10 -0
  113. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.text.erb.tt +8 -0
  114. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.html.slim.tt +10 -0
  115. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.text.erb.tt +8 -0
  116. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.html.slim.tt +10 -0
  117. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.text.erb.tt +8 -0
  118. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.html.slim.tt +13 -0
  119. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.text.erb.tt +8 -0
  120. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.html.slim.tt +9 -0
  121. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.text.erb.tt +11 -0
  122. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.html.slim.tt +9 -0
  123. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.text.erb.tt +11 -0
  124. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.html.slim.tt +23 -0
  125. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.text.erb.tt +14 -0
  126. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.html.slim.tt +24 -0
  127. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.text.erb.tt +14 -0
  128. data/lib/generators/trek/templates/public_accounts/web/authentication.rb.tt +97 -0
  129. data/lib/generators/trek/templates/public_accounts/web/controllers/accounts_controller.rb.tt +33 -0
  130. data/lib/generators/trek/templates/public_accounts/web/controllers/active_sessions_controller.rb.tt +15 -0
  131. data/lib/generators/trek/templates/public_accounts/web/controllers/base_controller.rb.tt +52 -0
  132. data/lib/generators/trek/templates/public_accounts/web/controllers/email_changes_controller.rb.tt +85 -0
  133. data/lib/generators/trek/templates/public_accounts/web/controllers/email_confirmations_controller.rb.tt +19 -0
  134. data/lib/generators/trek/templates/public_accounts/web/controllers/password_resets_controller.rb.tt +41 -0
  135. data/lib/generators/trek/templates/public_accounts/web/controllers/passwords_controller.rb.tt +23 -0
  136. data/lib/generators/trek/templates/public_accounts/web/controllers/registrations_controller.rb.tt +24 -0
  137. data/lib/generators/trek/templates/public_accounts/web/controllers/sessions_controller.rb.tt +78 -0
  138. data/lib/generators/trek/templates/public_accounts/web/controllers/sign_in_codes_controller.rb.tt +27 -0
  139. data/lib/generators/trek/templates/public_accounts/web/views/_flash.html.slim.tt +5 -0
  140. data/lib/generators/trek/templates/public_accounts/web/views/accounts/show.html.slim.tt +52 -0
  141. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/edit.html.slim.tt +17 -0
  142. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/new.html.slim.tt +15 -0
  143. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/edit.html.slim.tt +26 -0
  144. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/new.html.slim.tt +15 -0
  145. data/lib/generators/trek/templates/public_accounts/web/views/passwords/edit.html.slim.tt +25 -0
  146. data/lib/generators/trek/templates/public_accounts/web/views/registrations/new.html.slim.tt +28 -0
  147. data/lib/generators/trek/templates/public_accounts/web/views/sessions/new.html.slim.tt +38 -0
  148. data/lib/generators/trek/templates/public_accounts/web/views/sign_in_codes/new.html.slim.tt +17 -0
  149. data/lib/generators/trek/templates/spec/jobs/mail_delivery_job_spec.rb +11 -0
  150. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +10 -0
  151. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +1 -1
  152. data/lib/generators/trek/templates/spec/requests/admin/good_job_spec.rb +34 -0
  153. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +1 -1
  154. data/lib/generators/trek/templates/spec/requests/admin/panels_spec.rb +16 -0
  155. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +2 -2
  156. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +1 -1
  157. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +2 -2
  158. data/lib/generators/trek/templates/views/admin/users/index.html.slim +1 -1
  159. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +1 -1
  160. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +1 -1
  161. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +1 -1
  162. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +1 -1
  163. data/lib/trek/csp_reports.rb +22 -4
  164. data/lib/trek/typography_formatter.rb +35 -4
  165. data/lib/trek/version.rb +1 -1
  166. data/package.json +12 -8
  167. data/trek.gemspec +1 -1
  168. data/vitest.config.mjs +24 -0
  169. data/yarn.lock +1004 -288
  170. metadata +90 -3
@@ -0,0 +1,15 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ p = t(".intro")
5
+
6
+ = form_with url: <%= route_prefix %>_password_reset_path do |f|
7
+ = f.label :email, <%= class_name %>.human_attribute_name(:email)
8
+ = f.email_field :email,
9
+ value: @email,
10
+ required: true,
11
+ autofocus: true,
12
+ autocomplete: "username"
13
+ = f.submit t(".submit")
14
+
15
+ p = link_to t(".sign_in"), <%= route_prefix %>_sign_in_path
@@ -0,0 +1,25 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ ruby:
5
+ rules = {
6
+ min: @<%= file_name %>.password_min_length,
7
+ passphrase: @<%= file_name %>.passphrase_length
8
+ }
9
+ p = t(".intro", **rules)
10
+
11
+ = form_with model: @<%= file_name %>, url: <%= route_prefix %>_password_path, method: :patch do |f|
12
+ - if @<%= file_name %>.errors.any?
13
+ ul(role="alert")
14
+ - @<%= file_name %>.errors.full_messages.each do |message|
15
+ li = message
16
+ = f.label :password
17
+ = f.password_field :password,
18
+ required: true,
19
+ autofocus: true,
20
+ autocomplete: "new-password",
21
+ maxlength: 72,
22
+ minlength: @<%= file_name %>.password_min_length
23
+ = f.submit t(".submit")
24
+
25
+ p = link_to t(".back"), <%= route_prefix %>_account_path
@@ -0,0 +1,28 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+
5
+ = form_with model: @<%= file_name %>, url: <%= route_prefix %>_sign_up_path do |f|
6
+ - if @<%= file_name %>.errors.any?
7
+ ul(role="alert")
8
+ - @<%= file_name %>.errors.full_messages.each do |message|
9
+ li = message
10
+ = f.label :email
11
+ = f.email_field :email, required: true, autofocus: true, autocomplete: "email"
12
+ = f.label :name
13
+ = f.text_field :name, autocomplete: "name"
14
+ = f.label :password
15
+ ruby:
16
+ rules = {
17
+ min: @<%= file_name %>.password_min_length,
18
+ passphrase: @<%= file_name %>.passphrase_length
19
+ }
20
+ p = t(".rules", **rules)
21
+ = f.password_field :password,
22
+ required: true,
23
+ autocomplete: "new-password",
24
+ maxlength: 72,
25
+ minlength: @<%= file_name %>.password_min_length
26
+ = f.submit t(".submit")
27
+
28
+ p = link_to t(".sign_in"), <%= route_prefix %>_sign_in_path
@@ -0,0 +1,38 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ <% if code? -%>
5
+ - validity = distance_of_time_in_words(0, <%= class_name %>::SignInCode.validity)
6
+ p = t(".intro", email: @email, validity:)
7
+
8
+ = form_with url: <%= route_prefix %>_sign_in_code_path do |f|
9
+ = f.label :otp, t(".code")
10
+ = f.text_field :otp,
11
+ required: true,
12
+ autofocus: true,
13
+ autocomplete: "one-time-code",
14
+ inputmode: "numeric",
15
+ pattern: "[0-9 ]*",
16
+ maxlength: 7
17
+ = f.submit t(".submit")
18
+
19
+ p = link_to t(".other_email"), <%= route_prefix %>_sign_in_path
20
+ <% else -%>
21
+
22
+ = form_with url: <%= route_prefix %>_sign_in_path do |f|
23
+ = f.label :email, <%= class_name %>.human_attribute_name(:email)
24
+ = f.email_field :email,
25
+ value: @email,
26
+ required: true,
27
+ autofocus: true,
28
+ autocomplete: "username"
29
+ = f.label :password, <%= class_name %>.human_attribute_name(:password)
30
+ = f.password_field :password,
31
+ required: true,
32
+ autocomplete: "current-password",
33
+ maxlength: 72
34
+ = f.submit t(".submit")
35
+
36
+ p = link_to t(".forgot_password"), new_<%= route_prefix %>_password_reset_path
37
+ p = link_to t(".sign_up"), <%= route_prefix %>_sign_up_path
38
+ <% end -%>
@@ -0,0 +1,17 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ p = t(".intro")
5
+
6
+ = form_with url: <%= route_prefix %>_sign_in_path do |f|
7
+ = f.label :email, <%= class_name %>.human_attribute_name(:email)
8
+ = f.email_field :email,
9
+ value: @email,
10
+ required: true,
11
+ autofocus: true,
12
+ autocomplete: "email"
13
+ = f.submit t(".submit")
14
+ <% if password? -%>
15
+
16
+ p = link_to t(".password"), <%= route_prefix %>_password_sign_in_path
17
+ <% end -%>
@@ -0,0 +1,11 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe MailDeliveryJob, type: :job do
4
+ it "delivers the mail enqueued by deliver_later" do
5
+ expect(ActionMailer::Base.delivery_job).to eq(described_class)
6
+ end
7
+
8
+ it "retries a failed delivery instead of discarding it" do
9
+ expect(described_class.rescue_handlers.map(&:first)).to include("StandardError")
10
+ end
11
+ end
@@ -15,6 +15,9 @@ RSpec.describe AdminMailer do
15
15
  end
16
16
 
17
17
  describe "the new sign-in email" do
18
+ # No DNS lookup in specs: an address without a host name, unless an example gives one
19
+ before { allow(Trek::ReverseDns).to receive(:hostname).and_return(nil) }
20
+
18
21
  # In English whatever the app's default locale: the examples read English words
19
22
  let(:user) { users.create(role: :editor, locale: "en") }
20
23
  let(:mail) do
@@ -30,6 +33,13 @@ RSpec.describe AdminMailer do
30
33
  expect(mail.text_part.body.to_s).to include("Firefox on Windows").and include("203.0.113.7")
31
34
  end
32
35
 
36
+ it "names the address's host, as the active sessions do", :aggregate_failures do
37
+ allow(Trek::ReverseDns).to receive(:hostname).with("203.0.113.7").and_return("host.example.net")
38
+
39
+ expect(mail.text_part.body.to_s).to include("203.0.113.7 (host.example.net)")
40
+ expect(mail.html_part.body.to_s).to include("203.0.113.7 (host.example.net)")
41
+ end
42
+
33
43
  it "links to the active sessions and to a password reset" do
34
44
  expect(mail.html_part.body.to_s).to include("#sessions").and include("user_password_resets/new")
35
45
  end
@@ -53,7 +53,7 @@ RSpec.describe "Admin fragments", type: :request do
53
53
  it "refuses a key already used in the namespace" do
54
54
  post admin_fragments_path, params: { fragment: { key: "footer", namespace: "layout", title: "Another footer" } }
55
55
 
56
- expect(response).to have_http_status(:unprocessable_entity)
56
+ expect(response).to have_http_status(:unprocessable_content)
57
57
  end
58
58
 
59
59
  it "renders the edit form" do
@@ -0,0 +1,34 @@
1
+ require "rails_helper"
2
+
3
+ # The GoodJob dashboard is mounted outside the admin controllers (config/routes.rb), so the guard
4
+ # lives in config/initializers/good_job.rb. Anyone but an administrator must get a 404.
5
+ RSpec.describe "GoodJob dashboard", type: :request do
6
+ let(:password) { "secret-password-123" }
7
+
8
+ def sign_in(user)
9
+ post admin_user_session_path, params: { email: user.email, password: }
10
+ end
11
+
12
+ it "lets an administrator in" do
13
+ sign_in User.create!(email: "admin@example.com", password:, role: "admin")
14
+
15
+ get "/admin/good_job"
16
+ follow_redirect!
17
+
18
+ expect(response).to have_http_status(:ok)
19
+ end
20
+
21
+ it "hides the dashboard from a privileged user who is not an administrator" do
22
+ sign_in User.create!(email: "editor@example.com", password:, role: "editor")
23
+
24
+ get "/admin/good_job"
25
+
26
+ expect(response).to have_http_status(:not_found)
27
+ end
28
+
29
+ it "hides the dashboard from a visitor" do
30
+ get "/admin/good_job"
31
+
32
+ expect(response).to have_http_status(:not_found)
33
+ end
34
+ end
@@ -34,7 +34,7 @@ RSpec.describe "Admin pages", type: :request do
34
34
  it "refuses a page without a title" do
35
35
  post admin_pages_path, params: { page: { title: "" } }
36
36
 
37
- expect(response).to have_http_status(:unprocessable_entity)
37
+ expect(response).to have_http_status(:unprocessable_content)
38
38
  end
39
39
 
40
40
  it "renders the edit form" do
@@ -51,4 +51,20 @@ RSpec.describe "Admin content editor panels", type: :request do
51
51
 
52
52
  expect(response).to have_http_status(:ok)
53
53
  end
54
+
55
+ it "asks for a link's title, and gives it to the editor", :aggregate_failures do
56
+ get new_admin_panels_link_path(link: { turbo_frame_id: "page_1_content" })
57
+ expect(stacked_panel.at_css("input[name='link[title]']")).to be_present
58
+
59
+ post admin_panels_links_path(format: :turbo_stream),
60
+ params: { link: { turbo_frame_id: "page_1_content", href: "https://example.com/", title: "An example" } }
61
+ expect(response.body).to include("&quot;title&quot;:&quot;An example&quot;")
62
+ end
63
+
64
+ it "inserts a call to action into the editor" do
65
+ post admin_panels_prompts_path(format: :turbo_stream),
66
+ params: { prompt: { turbo_frame_id: "page_1_content", href: "https://example.com/", text: "Sign up" } }
67
+
68
+ expect(response.body).to include("<prompts-block", "Sign up")
69
+ end
54
70
  end
@@ -12,7 +12,7 @@ RSpec.describe "Admin password renewal", type: :request do
12
12
  editor
13
13
  sign_in(admin)
14
14
 
15
- post require_password_renewal_admin_users_path
15
+ post admin_password_renewal_path
16
16
 
17
17
  expect(editor.reload.password_renewal_required).to be(true)
18
18
  end
@@ -21,7 +21,7 @@ RSpec.describe "Admin password renewal", type: :request do
21
21
  admin
22
22
  sign_in(editor)
23
23
 
24
- post require_password_renewal_admin_users_path
24
+ post admin_password_renewal_path
25
25
 
26
26
  expect(admin.reload.password_renewal_required).to be(false)
27
27
  end
@@ -24,7 +24,7 @@ RSpec.describe "Admin sign-in lockout", type: :request do
24
24
 
25
25
  sign_in
26
26
 
27
- expect(response).to have_http_status(:unprocessable_entity)
27
+ expect(response).to have_http_status(:unprocessable_content)
28
28
  end
29
29
 
30
30
  it "answers a locked account as a wrong password, so it can't be told apart" do
@@ -31,7 +31,7 @@ RSpec.describe "Admin sign-in", type: :request do
31
31
  it "refuses a wrong password" do
32
32
  post admin_user_session_path, params: { email: editor.email, password: "not the password" }
33
33
 
34
- expect(response).to have_http_status(:unprocessable_entity)
34
+ expect(response).to have_http_status(:unprocessable_content)
35
35
  end
36
36
 
37
37
  it "keeps the email typed after a wrong password" do
@@ -44,7 +44,7 @@ RSpec.describe "Admin sign-in", type: :request do
44
44
  reader = users.create(role: :reader)
45
45
  post admin_user_session_path, params: { email: reader.email, password: users.password }
46
46
 
47
- expect(response).to have_http_status(:unprocessable_entity)
47
+ expect(response).to have_http_status(:unprocessable_content)
48
48
  end
49
49
 
50
50
  it "answers an unknown email as a wrong password, so it can't be used to find accounts" do
@@ -14,7 +14,7 @@
14
14
 
15
15
  if allowed_to?(:require_password_renewal?, model)
16
16
  c.with_button(
17
- href: [:require_password_renewal, :admin, :users],
17
+ href: [:admin, :password_renewal],
18
18
  text: t(".require_password_renewal"),
19
19
  data: {
20
20
  turbo_method: :post,
@@ -5,7 +5,7 @@ p
5
5
  ul
6
6
  li = l(@at, format: :long)
7
7
  li = user_agent_summary(@user_agent)
8
- li = @ip
8
+ li = @address
9
9
  p If it was you, there's nothing to do.
10
10
  p
11
11
  | If it wasn't, change your password right away,
@@ -4,7 +4,7 @@ Your <%%= t("application_name") %> account was just signed in to from a browser
4
4
 
5
5
  - <%%= l(@at, format: :long) %>
6
6
  - <%%= user_agent_summary(@user_agent) %>
7
- - <%%= @ip %>
7
+ - <%%= @address %>
8
8
 
9
9
  If it was you, there's nothing to do.
10
10
 
@@ -5,7 +5,7 @@ p
5
5
  ul
6
6
  li = l(@at, format: :long)
7
7
  li = user_agent_summary(@user_agent)
8
- li = @ip
8
+ li = @address
9
9
  p Si c'était vous, il n'y a rien à faire.
10
10
  p
11
11
  | Sinon, changez votre mot de passe tout de suite,
@@ -4,7 +4,7 @@ Votre compte <%%= t("application_name") %> vient d'être connecté depuis un nav
4
4
 
5
5
  - <%%= l(@at, format: :long) %>
6
6
  - <%%= user_agent_summary(@user_agent) %>
7
- - <%%= @ip %>
7
+ - <%%= @address %>
8
8
 
9
9
  Si c'était vous, il n'y a rien à faire.
10
10
 
@@ -16,7 +16,9 @@ module Trek
16
16
  # report, it must be plausible too: a known directive, and a keyword or a host name blocked.
17
17
  #
18
18
  # A violation keeps the page's path, without its query, which may carry a token, and the blocked
19
- # resource's origin.
19
+ # resource's origin. To tell what was blocked, it also keeps where (the source file's URL, without
20
+ # its query, and the line), and the sample: the start of the inline script or style, which browsers
21
+ # only send under `'report-sample'`.
20
22
  module CspReports
21
23
  EXTENSION_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
22
24
 
@@ -32,9 +34,13 @@ module Trek
32
34
  SCHEMES = %w[http https ws wss].freeze
33
35
  HOST_NAME = /\A(?=.{1,253}\z)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\z/i
34
36
 
35
- Violation = Struct.new(:directive, :blocked, :page, :disposition, keyword_init: true) do
37
+ # Browsers send 40 characters at most; a forged report could send any number
38
+ SAMPLE_LENGTH = 40
39
+
40
+ Violation = Struct.new(:directive, :blocked, :page, :disposition, :source, :sample, keyword_init: true) do
36
41
  def to_s
37
- "#{directive} blocked #{blocked} on #{page} (#{disposition})"
42
+ # The sample inspected, as it may hold a line break
43
+ "#{directive} blocked #{blocked} on #{page} (#{disposition})#{" at #{source}" if source}#{": #{sample.inspect}" if sample}"
38
44
  end
39
45
  end
40
46
 
@@ -68,11 +74,23 @@ module Trek
68
74
  directive:,
69
75
  blocked:,
70
76
  page: page.path.presence || "/",
71
- disposition: fields["disposition"] || "enforce"
77
+ disposition: fields["disposition"] || "enforce",
78
+ source: location(source, fields["line-number"] || fields["lineNumber"]),
79
+ sample: (fields["script-sample"] || fields["sample"]).to_s.first(SAMPLE_LENGTH).presence
72
80
  )
73
81
  end
74
82
  private_class_method :violation
75
83
 
84
+ # Where the violation happened: the source file's origin and path, and the line
85
+ def self.location(source, line)
86
+ uri = parse_uri(source)
87
+ return unless uri&.host && (base = origin(source))
88
+
89
+ line = Integer(line, exception: false)
90
+ "#{base}#{uri.path}#{":#{line}" if line}"
91
+ end
92
+ private_class_method :location
93
+
76
94
  # A URL's origin, or the keyword a browser reports instead ("inline", "eval", "data"…); nil when
77
95
  # it's neither
78
96
  def self.origin(value)
@@ -1,20 +1,33 @@
1
+ require "json"
2
+
1
3
  module Trek
2
4
  # `TypographyFormatter` formats strings to enforce French typography rules.
3
5
  #
4
6
  # Supports both plain strings and ProseMirror JSONB hashes. When given
5
7
  # a Hash, it walks the node tree and formats only `text` node values
6
- # in place, leaving the structure intact.
8
+ # in place, leaving the structure intact. A ProseMirror document given
9
+ # as a JSON string, as the content editor submits it, is parsed first,
10
+ # and returned as a Hash.
7
11
  class TypographyFormatter
8
12
  NBSP = [160].pack("U*").freeze
9
13
  NNBSP = [8239].pack("U*").freeze # U+202F narrow no-break space
14
+ # Signs preceded by a non-breaking space, whether one was typed or not
10
15
  SPACE_BEFORE_CHARS = [
11
- [NBSP, %w[: % € \$ » – —]],
16
+ [NBSP, %w[» – —]]
17
+ ].freeze
18
+ # Signs preceded by a non-breaking space when one was typed, or when they end a word: followed by a
19
+ # space, punctuation or the end of the text. Inside a word, as in a time (10:30), a URL
20
+ # (https://example.com/?a=50%25) or a ratio (3:1), they aren't punctuation, and are left alone.
21
+ SPACE_BEFORE_WORD_ENDING_CHARS = [
22
+ [NBSP, %w[: % € \$]],
12
23
  [NNBSP, %w[; ! \?]]
13
24
  ].freeze
25
+ WORD_ENDING_SIGNS = %w[: ; ! \?].freeze
14
26
  NBSP_AFTER_CHARS = %w[« – —].freeze
15
27
  NBSP_BEFORE_UNITS = %w[°C °F g h ha kg km Ko ko kW L l m min ml Mo mo s].freeze
16
28
 
17
29
  def initialize(input)
30
+ input = JSON.parse(input) if prosemirror_json?(input)
18
31
  @source = input
19
32
  @prosemirror = input.is_a?(Hash)
20
33
  @output = @prosemirror ? deep_dup_hash(input) : input.dup
@@ -34,6 +47,11 @@ module Trek
34
47
 
35
48
  private
36
49
 
50
+ # Formatted as plain text, the JSON would get non-breaking spaces before its colons, and no longer parse
51
+ def prosemirror_json?(input)
52
+ input.is_a?(String) && input.start_with?('{"type":"doc"')
53
+ end
54
+
37
55
  def format_string!
38
56
  remove_double_spaces
39
57
  add_nbsp
@@ -79,6 +97,18 @@ module Trek
79
97
  regexp = /(?<=\S)(\p{Zs}?(#{chars.join("|")}))/
80
98
  @output.gsub!(regexp, "#{space}\\2")
81
99
  end
100
+
101
+ word_end = "\\p{Zs}|\\z|[#{WORD_ENDING_SIGNS.join}.,…»”)\\]]"
102
+ # Not after another sign: "Quoi?!" gets one space, before the group
103
+ not_after_sign = "(?<![\\p{Zs}\\s#{WORD_ENDING_SIGNS.join}])"
104
+
105
+ SPACE_BEFORE_WORD_ENDING_CHARS.each do |space, chars|
106
+ signs = chars.join("|")
107
+ # Repairs what earlier versions spaced inside a word: "10 :30", "https ://"
108
+ @output.gsub!(/[#{NBSP}#{NNBSP}](#{signs})(?!#{word_end})/, "\\1")
109
+ @output.gsub!(/(?<=\S)\p{Zs}+(#{signs})(?=#{word_end})/, "#{space}\\1")
110
+ @output.gsub!(/#{not_after_sign}(#{signs})(?=#{word_end})/, "#{space}\\1")
111
+ end
82
112
  end
83
113
 
84
114
  def add_nbsp_before_units
@@ -87,7 +117,7 @@ module Trek
87
117
  end
88
118
 
89
119
  def add_nbsp_after_chars
90
- regexp = /((#{NBSP_AFTER_CHARS.join("|")})\p{Zs}?)(?=\w)/
120
+ regexp = /((#{NBSP_AFTER_CHARS.join("|")})\p{Zs}?)(?=[\p{L}\p{N}])/
91
121
  @output.gsub!(regexp, "\\2#{NBSP}")
92
122
  end
93
123
 
@@ -109,7 +139,8 @@ module Trek
109
139
  end
110
140
 
111
141
  def replace_apostrophes
112
- @output.gsub!(/(?<=\w)'(?=\w)/, "\u2019")
142
+ # Letters with accents too (l'\u00e9t\u00e9), which `\w` doesn't match
143
+ @output.gsub!(/(?<=[\p{L}\p{N}])'(?=[\p{L}\p{N}])/, "\u2019")
113
144
  end
114
145
 
115
146
  def remove_spaces_before_attached_punctuation
data/lib/trek/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trek
4
- VERSION = "2.5.0"
4
+ VERSION = "2.6.0"
5
5
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trek",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "A modern content management & back-office system for Ruby on Rails",
5
5
  "homepage": "https://trek.etamin.studio",
6
6
  "license": "MIT",
@@ -20,16 +20,15 @@
20
20
  },
21
21
  "author": "Etamin Studio",
22
22
  "dependencies": {
23
+ "@floating-ui/dom": "^1.8.0",
23
24
  "@fontsource/inter": "^5.3.0",
24
25
  "@hotwired/stimulus": "^3.2.2",
25
26
  "@hotwired/turbo-rails": "^8.0.20",
26
27
  "@radix-ui/colors": "^3.0.0",
27
- "@tiptap/core": "^2.1.11",
28
- "@tiptap/extension-floating-menu": "^2.1.11",
29
- "@tiptap/extension-link": "^2.1.11",
30
- "@tiptap/extension-placeholder": "^2.1.11",
31
- "@tiptap/pm": "^2.1.11",
32
- "@tiptap/starter-kit": "^2.1.11",
28
+ "@tiptap/core": "^3.32.0",
29
+ "@tiptap/extension-floating-menu": "^3.32.0",
30
+ "@tiptap/pm": "^3.32.0",
31
+ "@tiptap/starter-kit": "^3.32.0",
33
32
  "@uppy/core": "^3.0.4",
34
33
  "@uppy/locales": "^3.0.1",
35
34
  "@uppy/thumbnail-generator": "^3.0.2",
@@ -42,11 +41,13 @@
42
41
  "tom-select": "^2.2.2"
43
42
  },
44
43
  "devDependencies": {
44
+ "@vitest/browser-playwright": "^5.0.5",
45
45
  "esbuild": "^0.28.0",
46
46
  "esbuild-rails": "^1.0.7",
47
47
  "esbuild-style-plugin": "^1.6.3",
48
48
  "eslint": "^8.49.0",
49
49
  "eslint-config-prettier": "^8.10.2",
50
+ "playwright": "^1.64.1",
50
51
  "postcss": "^8.4.30",
51
52
  "postcss-flexbugs-fixes": "^5.0.2",
52
53
  "postcss-import": "^15.1.0",
@@ -57,11 +58,14 @@
57
58
  "stylelint": "^15.11.0",
58
59
  "stylelint-config-standard": "^31.0.0",
59
60
  "stylelint-order": "^6.0.4",
61
+ "vite": "^8.3.5",
60
62
  "vitepress": "^1.6.4",
61
- "vitepress-plugin-llms": "^1.13.1"
63
+ "vitepress-plugin-llms": "^1.13.1",
64
+ "vitest": "^5.0.5"
62
65
  },
63
66
  "packageManager": "yarn@4.18.1",
64
67
  "scripts": {
68
+ "test": "vitest run",
65
69
  "docs:dev": "vitepress dev docs",
66
70
  "docs:build": "vitepress build docs",
67
71
  "docs:preview": "vitepress preview docs"
data/trek.gemspec CHANGED
@@ -50,7 +50,7 @@ Gem::Specification.new do |spec|
50
50
  spec.add_dependency "mobility", "~> 1.3.2"
51
51
  spec.add_dependency "phonelib", "~> 0.8"
52
52
  spec.add_dependency "postmark-rails"
53
- spec.add_dependency "prosemirror", ">= 0.7.1", "< 2"
53
+ spec.add_dependency "prosemirror", ">= 1.0", "< 2"
54
54
  spec.add_dependency "rails", [">= 8.0", "< 8.2"]
55
55
  spec.add_dependency "rails-healthcheck"
56
56
  spec.add_dependency "rails-i18n", ">= 6.0"
data/vitest.config.mjs ADDED
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import { playwright } from "@vitest/browser-playwright";
3
+
4
+ // JavaScript tests (yarn test): the admin's Stimulus controllers, with their libraries, in a headless Chrome.
5
+ export default defineConfig({
6
+ test: {
7
+ include: ["spec/javascript/**/*.test.js"],
8
+ browser: {
9
+ enabled: true,
10
+ headless: true,
11
+ // The Chrome installed on the machine, as the system specs use: Playwright downloads none. CI's image
12
+ // has Debian's Chromium instead, at CHROME_PATH.
13
+ provider: playwright({
14
+ launchOptions: process.env.CHROME_PATH
15
+ ? { executablePath: process.env.CHROME_PATH }
16
+ : { channel: "chrome" },
17
+ }),
18
+ instances: [{ browser: "chromium" }],
19
+ },
20
+ },
21
+ // The components import their CSS, which the tests don't need: without this, Vite would load
22
+ // postcss.config.js, written for the esbuild build.
23
+ css: { postcss: {} },
24
+ });