recourse 7.9.0 → 7.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '024485f23e83a52580fa55b3d73f858bf737ab1a2fd5e53819f283aaf29a57e3'
4
- data.tar.gz: f1aaa536fd9085f5baa7b54cf9590d0387aa79bc65173a9ab97a853ffacbdbe9
3
+ metadata.gz: 610ac68c2716a148665b9f985093497b80e2f0ea6a81c36d67dd755f893e9578
4
+ data.tar.gz: 1873ac63684cc244015ebf85e2f546c153d8bb9f25dac4fc6af3edf111eae9e2
5
5
  SHA512:
6
- metadata.gz: bbad80f3cda8598fb3773e8caad5b887aa02354a8dd2ba56f289669ae456a4ebca178830d20900cb0ec95a73c641cf95d9d47698c85654a48abd5a5eaa78214e
7
- data.tar.gz: 07daefa230e19c573044eb613716eafa6d347cdeceb19e1d72af511696a458abb3e4771fb2d91f5784adb5e8053ef8f172e6f08dab09b3011acab7f6336b933c
6
+ metadata.gz: aa8bb20d6ff74bacae5c30b6c7c2e88b2c38650e95a8c00f0057709489f187c8a3d34d2b05400234cf016ab9616516cb53f8fb3d94b33d7b639b4467a7cd15d5
7
+ data.tar.gz: aa25b208196893588ed3289b70461cfa161e727b99dba194aae89cd8dc14c8815bf2d66da5d9b10edda6cf675b0a1638d707934af52a125258ef69542a77dbdd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 7.11.0 - 2026-09-26
11
+
12
+ * [FEATURE] A host can say which pages are the same for everybody, and they are sent to be kept
13
+
14
+ `Recourse.public_pages = -> { !signed_in? }` runs in the controller for every request. A GET
15
+ it approves is sent `Cache-Control: public` with `max-age`, `s-maxage`, `stale-while-revalidate`
16
+ and `stale-if-error` (`Recourse.public_cache` sets the seconds), and `Vary: Turbo-Frame`. It is
17
+ drawn once for everybody, so none of what the reader's own browser tells the server is in
18
+ it: not their zone, not their page size, not their density (the layout widens the page in
19
+ the browser instead), no form token and no way out. `Recourse.public_version = -> { … }` gives
20
+ such a page an ETag, so a CDN or a browser asks whether it stands and is told `304`. A frame's
21
+ request is never public: the same address answers a page.
22
+
23
+ ## 7.10.0 - 2026-09-26
24
+
25
+ * [FEATURE] The sidebar's foot says what each icon does
26
+
27
+ The moon, the sun, the way in and the way out each open a tooltip to the right, away
28
+ from the sidebar's edge, the way a column's icon opens one above it: `Dark theme`,
29
+ `Light theme`, `Sign in` and `Sign out`. A host that answers `exit_name` with whoever is
30
+ signed in earns `Sign out N-100L`. The words are `recourse.dark_theme`, `light_theme`,
31
+ `sign_out` and `sign_out_as` in the locale.
32
+
10
33
  ## 7.9.0 - 2026-09-26
11
34
 
12
35
  * [FEATURE] A pin or an area on a map leads to its row's page
@@ -8,7 +8,12 @@ module Recourse
8
8
  # a stranger can write, and an unchecked one is `?limit=100000` by another route.
9
9
  # Which is also why pagy's `max_limit` stays unset: the query string is still not
10
10
  # asked, and this is the one thing that is.
11
+ #
12
+ # A page drawn once for everybody has the first size, whatever this reader chose: the
13
+ # switch under the table redraws the table alone, which is not that page.
11
14
  def recourse_limit
15
+ return Recourse::LIMITS.first if public_page?
16
+
12
17
  limit = cookies[Recourse::LIMIT_STORAGE].to_i
13
18
 
14
19
  Recourse::LIMITS.include?(limit) ? limit : Recourse::LIMITS.first
@@ -0,0 +1,45 @@
1
+ module Recourse
2
+ # A page the host says is the same for everybody is sent for everybody to keep: a CDN
3
+ # in front of the host serves it for a day, and asks the host only when it must.
4
+ module PublicCaching
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :cache_for_everybody
9
+ helper_method :public_page?
10
+ end
11
+
12
+ private
13
+
14
+ # Whether this page is drawn once for everybody. Only a read: a page that changes
15
+ # something is nobody else's, and a frame's request is answered with the frame alone,
16
+ # which the same address answers with a whole page. Asked once per request, since the
17
+ # host's answer may be a query and the layout, the zone and the paging all ask.
18
+ def public_page?
19
+ return @public_page if defined? @public_page
20
+
21
+ @public_page = Recourse.public_pages.present? && (request.get? || request.head?) &&
22
+ !turbo_frame_request? && instance_exec(&Recourse.public_pages)
23
+ end
24
+
25
+ def cache_for_everybody
26
+ return unless public_page?
27
+
28
+ response.cache_control.merge! public_cache_control
29
+ response.headers['Vary'] = [response.headers['Vary'], 'Turbo-Frame'].compact_blank.join ', '
30
+ version = Recourse.public_version
31
+ fresh_when etag: instance_exec(&version) if version
32
+ end
33
+
34
+ # `s-maxage` is the CDN's alone: the reader's browser keeps the page for `max-age`.
35
+ def public_cache_control
36
+ keep = Recourse.public_cache
37
+
38
+ {
39
+ public: true, max_age: keep[:max_age], extras: ["s-maxage=#{keep[:shared_max_age]}"],
40
+ stale_while_revalidate: keep[:stale_while_revalidate],
41
+ stale_if_error: keep[:stale_if_error],
42
+ }
43
+ end
44
+ end
45
+ end
@@ -20,7 +20,11 @@ module Recourse
20
20
  # Nil where the browser has not said, or said something no zone answers to — and
21
21
  # `Time.zone = nil` falls back to the host's own setting, so an absent cookie and a
22
22
  # forged one both leave the page exactly as it is drawn today.
23
+ # And nil for a page drawn once for everybody, whose times are the host's own: this
24
+ # reader's zone is no business of the next reader's page.
23
25
  def recourse_zone
26
+ return if public_page?
27
+
24
28
  name = cookies[Recourse::ZONE_STORAGE]
25
29
 
26
30
  ActiveSupport::TimeZone[name] if name.present?
@@ -5,8 +5,8 @@ module Recourse
5
5
  class BaseController < ApplicationController
6
6
  include Pagy::Method, Positioned, AttachmentResolution, AttachmentWriting,
7
7
  Landing, Paging, ListResolution, ParameterResolution, ParentNaming,
8
- ParentResolution, ReferenceResolution, ResourceResolution, Retrieving,
9
- Weeks, Zoning
8
+ ParentResolution, PublicCaching, ReferenceResolution, ResourceResolution,
9
+ Retrieving, Weeks, Zoning
10
10
 
11
11
  helper Helpers
12
12
 
@@ -11,7 +11,8 @@
11
11
  never has to be remembered here. -%>
12
12
  <meta name='turbo-refresh-method' content='morph'>
13
13
  <meta name='turbo-refresh-scroll' content='preserve'>
14
- <%= csrf_meta_tags %>
14
+ <%# No token on a page everybody is shown: the next reader would submit this one's. -%>
15
+ <%= csrf_meta_tags unless public_page? %>
15
16
  <%= csp_meta_tag %>
16
17
 
17
18
  <%= yield :head %>
@@ -52,6 +53,9 @@
52
53
  own: every screen the gem draws is one to read against their clock. -%>
53
54
  <body class='<%= class_names 'recourse-shell', 'recourse-expanded': expanded? %>'
54
55
  data-controller='timezone' data-timezone-storage-value='<%= Recourse::ZONE_STORAGE %>'>
56
+ <%# What the reader's browser says, put back before anything is painted, on a page the
57
+ server drew for everybody: the words beside the sidebar's icons. -%>
58
+ <%= render 'recourses/density' if public_page? %>
55
59
  <%# First in the page, since a phone reads it first: the row of icons at the top,
56
60
  above the trail. From 768px the layout's grid seats it beside the content instead,
57
61
  under the navbar, whatever order it was written in. -%>
@@ -0,0 +1,10 @@
1
+ <%# The density a reader chose last time, back before the page is painted, as the server was
2
+ not told: a page drawn once for everybody is compact, and this widens it. Classic and
3
+ inline for the reason the scheme is: a module runs after the first paint. -%>
4
+ <script>
5
+ (function () {
6
+ if (/(?:^|; )<%= Recourse::DENSITY_STORAGE %>=expanded(?:;|$)/.test(document.cookie)) {
7
+ document.body.classList.add('recourse-expanded')
8
+ }
9
+ })()
10
+ </script>
@@ -11,7 +11,8 @@
11
11
 
12
12
  <%# The foot of the sidebar: a reader's own say in how the page looks and, where the
13
13
  host drew a route named `exit`, the way out beside it — a button, since ending a
14
- session is never a GET. Each takes half the row and centers its icon in it, so the
14
+ session is never a GET. Each icon says what it does in a tooltip that opens to the right,
15
+ away from the sidebar's edge. Each takes half the row and centers its icon in it, so the
15
16
  toggle alone sits in the middle and the two together sit at a quarter and at three
16
17
  quarters. Which side of the fold the foot lands on is the layout's business, and so
17
18
  is which of the two mode icons shows — the mode may be the system's, which only CSS
@@ -40,11 +41,11 @@
40
41
  <%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
41
42
  data: scheme_data do %>
42
43
  <span class='recourse-scheme-light'>
43
- <i class='bi bi-moon-fill'></i>
44
+ <%= tag.i class: 'bi bi-moon-fill', data: tooltip_data(t('recourse.dark_theme'), placement: 'right') %>
44
45
  <span class='recourse-foot-word'><%= t 'recourse.darken' %></span>
45
46
  </span>
46
47
  <span class='recourse-scheme-dark'>
47
- <i class='bi bi-sun-fill'></i>
48
+ <%= tag.i class: 'bi bi-sun-fill', data: tooltip_data(t('recourse.light_theme'), placement: 'right') %>
48
49
  <span class='recourse-foot-word'><%= t 'recourse.lighten' %></span>
49
50
  </span>
50
51
  <% end %>
@@ -55,14 +56,16 @@
55
56
  leads to is a form rather than a change. -%>
56
57
  <% if enter? %>
57
58
  <%= link_to enter_path, class: 'nav-link border-0 bg-transparent' do %>
58
- <i class='bi bi-box-arrow-in-right'></i>
59
+ <%= tag.i class: 'bi bi-box-arrow-in-right', data: tooltip_data(t('recourse.enter'), placement: 'right') %>
59
60
  <span class='recourse-foot-word'><%= t 'recourse.enter' %></span>
60
61
  <% end %>
61
62
  <% end %>
62
- <% if exit? %>
63
+ <%# Nobody in particular is signed in on a page drawn once for everybody, and the button
64
+ carries a token that is theirs alone. -%>
65
+ <% if exit? && !public_page? %>
63
66
  <%= button_to exit_path, method: :delete, class: 'nav-link border-0 bg-transparent',
64
67
  form: { data: { turbo: false } } do %>
65
- <i class='bi bi-box-arrow-right'></i>
68
+ <%= tag.i class: 'bi bi-box-arrow-right', data: tooltip_data(exit_tooltip, placement: 'right') %>
66
69
  <span class='recourse-foot-word'><%= t 'recourse.exit' %></span>
67
70
  <% end %>
68
71
  <% end %>
@@ -47,12 +47,15 @@ en:
47
47
  create: Create
48
48
  created: "%{record} was created."
49
49
  created_error: "%{model} could not be created."
50
+ dark_theme: Dark theme
50
51
  darken: Dark
51
52
  delete: Delete %{model}
52
53
  deleted: "%{model} was deleted."
53
54
  deleted_error: "%{model} could not be deleted."
54
55
  destroy: Delete
55
56
  exit: Exit
57
+ sign_out: Sign out
58
+ sign_out_as: "Sign out %{name}"
56
59
  enter: Sign in
57
60
  displaying:
58
61
  one: Displaying 1 item
@@ -67,6 +70,7 @@ en:
67
70
  items:
68
71
  one: 1 item
69
72
  other: "%{count} items"
73
+ light_theme: Light theme
70
74
  lighten: Light
71
75
  missing_model: You declared `recourses :%{name}` in your routes file, but this app
72
76
  has no %{model} model.
@@ -5,8 +5,9 @@ module Recourse
5
5
  private
6
6
 
7
7
  # Whether the reader asked for the words: the cookie the arrows write, read back so
8
- # the page arrives expanded rather than compact and then widened by a script.
9
- def expanded? = cookies[Recourse::DENSITY_STORAGE] == 'expanded'
8
+ # the page arrives expanded rather than compact and then widened by a script. Not on a
9
+ # page drawn once for everybody, which the layout's own script widens in the browser.
10
+ def expanded? = !public_page? && cookies[Recourse::DENSITY_STORAGE] == 'expanded'
10
11
 
11
12
  # What the arrows need to switch: the class they toggle and the cookie they write.
12
13
  def density_data
@@ -17,6 +17,15 @@ module Recourse
17
17
  # in still sees it is the host's to say, by answering this for them.
18
18
  def enter? = respond_to? :enter_path
19
19
 
20
+ # Who is signed in, as the way out names them: `Sign out N-100L`. Nil, as it is where
21
+ # the host does not say, leaves the tooltip at `Sign out`.
22
+ def exit_name = nil
23
+
24
+ # What the way out's tooltip says.
25
+ def exit_tooltip
26
+ exit_name.present? ? t('recourse.sign_out_as', name: exit_name) : t('recourse.sign_out')
27
+ end
28
+
20
29
  # Sidebar entries as [name, title, path, key], in the order routes.rb declares
21
30
  # them, `key` being where in the title the letter that reaches it sits.
22
31
  def sidebar_resources
@@ -0,0 +1,36 @@
1
+ # Reopened for what a host says about the pages a shared cache may keep for everybody.
2
+ module Recourse
3
+ # How long each keeper may hold a page a host called cacheable: the reader's browser
4
+ # (`max_age`), a CDN in front of the host (`shared_max_age`), and for how long past
5
+ # that either may show an old page while a new one is fetched (`stale_while_revalidate`)
6
+ # or when the host does not answer (`stale_if_error`). In seconds.
7
+ PUBLIC_CACHE = {
8
+ max_age: 300, shared_max_age: 86_400, stale_while_revalidate: 86_400, stale_if_error: 604_800,
9
+ }.freeze
10
+
11
+ class << self
12
+ # A Proc answering, for one request, whether its page is the same for everybody: run
13
+ # in the controller, so `request` and whatever the host knows of who is reading are
14
+ # there to ask. Nil, as it is until a host says, keeps every page private to the
15
+ # reader, as before. A page it approves is sent with `public` cache headers, and is
16
+ # drawn with none of what a reader's own browser tells the server — their zone, their
17
+ # page size, their density, a form's token — since that page will be shown to others.
18
+ attr_accessor :public_pages
19
+
20
+ # A Proc answering what a public page's ETag is made of, in the controller: whatever
21
+ # changes when the page would, and nothing that changes when it would not. A browser or
22
+ # a CDN then asks whether it still stands and is told `304`, which costs a
23
+ # `SELECT` and no render. Nil sends no ETag of the gem's own.
24
+ attr_accessor :public_version
25
+
26
+ # The numbers PUBLIC_CACHE holds, or a host's own where it has said.
27
+ attr_writer :public_cache
28
+
29
+ # @return [Hash{Symbol => Integer}] the seconds each keeper keeps a public page for.
30
+ def public_cache = PUBLIC_CACHE.merge(@public_cache || {})
31
+ end
32
+
33
+ @public_pages = nil
34
+ @public_version = nil
35
+ @public_cache = nil
36
+ end
@@ -1,4 +1,4 @@
1
1
  module Recourse
2
2
  # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
- VERSION = '7.9.0'
3
+ VERSION = '7.11.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -25,6 +25,7 @@ require_relative 'recourse/maps'
25
25
  require_relative 'recourse/orders'
26
26
  require_relative 'recourse/positions'
27
27
  require_relative 'recourse/positioning'
28
+ require_relative 'recourse/public_caches'
28
29
  require_relative 'recourse/writes'
29
30
  require_relative 'recourse/zones'
30
31
  require_relative 'recourse/positionable'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recourse
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.9.0
4
+ version: 7.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -144,6 +144,7 @@ files:
144
144
  - app/controllers/concerns/recourse/parent_naming.rb
145
145
  - app/controllers/concerns/recourse/parent_resolution.rb
146
146
  - app/controllers/concerns/recourse/positioned.rb
147
+ - app/controllers/concerns/recourse/public_caching.rb
147
148
  - app/controllers/concerns/recourse/reference_resolution.rb
148
149
  - app/controllers/concerns/recourse/resource_resolution.rb
149
150
  - app/controllers/concerns/recourse/retrieving.rb
@@ -159,6 +160,7 @@ files:
159
160
  - app/views/recourses/_card.html.erb
160
161
  - app/views/recourses/_color.html.erb
161
162
  - app/views/recourses/_combobox.html.erb
163
+ - app/views/recourses/_density.html.erb
162
164
  - app/views/recourses/_fields.html.erb
163
165
  - app/views/recourses/_footer.html.erb
164
166
  - app/views/recourses/_form.html.erb
@@ -254,6 +256,7 @@ files:
254
256
  - lib/recourse/positionable.rb
255
257
  - lib/recourse/positioning.rb
256
258
  - lib/recourse/positions.rb
259
+ - lib/recourse/public_caches.rb
257
260
  - lib/recourse/recoursive.rb
258
261
  - lib/recourse/registry.rb
259
262
  - lib/recourse/routes.rb