Module: ExpoTurbo::Rails::PairedTemplates

Defined in:
lib/expo_turbo/rails/paired_templates.rb

Overview

A lint over a foo.html.erb / foo.expo_turbo.erb pair. A host that keeps two templates for one screen has no compiler telling it when they drift, and the drift that matters is not visual: an id the client targets, the Frame a request navigates, where a form posts and how, the names a controller reads out of params, and the Turbo behavior attributes.

It compares ELEMENT TO ELEMENT, not value list to value list. Comparing the two templates' sets of ids, srcs and actions passes whenever the values are the same somewhere, which is exactly what happens when two Frames exchange their src or two forms exchange their action: every set matches and nothing is reported. So elements are paired first and each pair's attributes are compared against its own counterpart.

Elements pair by id, which the protocol already requires to be unique within a document. The id matches that appear in the same relative order on both sides also anchor the file: what is left pairs by document order inside the runs between them, so a difference is contained to its own run.

Inside a run, position means something only when the two sides put the same number of elements there. Then every element counts as a position, including one carrying nothing this compares, which is what makes an attribute that moved onto a plain element visible. When the counts differ the two sides are shaped differently, which for a pair of templates is ordinary rather than wrong: one audience needs a wrapper the other does not. The run then lines up only the elements that carry something to compare, so a wrapper costs nothing.

Order is checked as well, and reported as its own kind rather than forced into an attribute mismatch, because it is a different thing: two audiences handed the same targets in a different sequence receive different documents, whatever the attributes say. See #reorderings.

It reads template source and never renders. Nothing here runs during a request; the module is autoloaded and its only entry points are the rake task and a host's own test.

Element names are deliberately not compared. Serving both audiences from two templates is what aliases are for, so <p> opposite <DemoText> is the expected shape, not a finding.

What it cannot detect:

  • A value a helper produces. <%= form_with %>, <%= link_to %>, and <%= turbo_frame_tag %> are opaque; their action, href, ids, and control names are invisible here.
  • A value that differs at run time from identical source. dom_id is itself format-aware, so the same <%= dom_id(post, :frame) %> on both sides can still produce two different ids.
  • Anything a partial, a layout, or a helper module contributes. Only the two paired files are read, and a partial pair is linted as its own pair, against its own counterpart.
  • A branch that only one audience takes. <% %> control flow is stripped, so every branch of a conditional is read as if taken; a template that branches on expo_turbo_request? reports the markup of both branches.
  • Nesting. Start tags are scanned and never built into a tree, so an element that moved to a different parent while keeping its place in the start-tag order is invisible. This is the one structural difference the order check above does not reach.
  • A reordering with no id to name it. Only an element carrying an id can be said to have moved, because without one nothing identifies it across the two files. Two id-less elements swapping surfaces as the attribute divergence it is indistinguishable from when they carry compared attributes, and not at all when they carry none.
  • A reordering whose relative id order is unchanged and whose two sides hold a different number of elements. Absolute position is compared only when the counts match, because an insertion and a move are otherwise the same picture.
  • Movement of an attribute inside a run whose two sides hold a different number of elements, for the same reason: that run compares only the elements carrying something, so an attribute that moved onto a plain one there is invisible. An id on either element restores this and the one above.
  • Semantics behind equal source: two method="post" forms that post to different places through different routes agree here. The reverse costs a false report rather than a miss: an expression is compared as text after whitespace runs collapse, so dom_id( post ) and dom_id(post) are reported as two values.
  • An implicit default. An HTML <form> with no method is a GET; a component that requires the attribute is not. Absent is compared with absent, not with the default it stands for.
  • Any screen whose two templates are not a discovered pair, including a single shared template, which has nothing to diverge from.

Where it over-reports: a run whose two sides hold the same number of elements but line them up differently is compared position by position, so one structural difference can surface as several findings. That is the same trust in position that makes movement visible, so it is deliberate. Giving elements ids removes the ambiguity in both directions.

Defined Under Namespace

Classes: Element, Finding, Pair

Constant Summary collapse

EXPRESSION_OPEN =
"«"
EXPRESSION_CLOSE =
"»"
EXPO_TURBO_FORMAT =
"expo_turbo"
HTML_FORMAT =
"html"
TRACKED_ATTRIBUTES =

src is Frame navigation, action and method are form owners and Stream actions, name is a control the server reads out of params. None is confined to one element name, because a shared screen spells its components differently on each side.

%w[action id method name src].freeze
TURBO_DATA_PREFIX =
"data-turbo"
ASPECT_LABELS =
{
  action: "action",
  data_turbo: "Turbo data attribute",
  element: "element",
  id: "id",
  method: "method",
  name: "control name",
  reordered: "reordered",
  src: "src",
  unreadable: "unreadable template"
}.freeze
ERB_TAG =
/<%(={1,2}|-|\#|%)?(.*?)-?%>/m
ELEMENT =
/<([A-Za-z_][^\s\/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)\/?>/m
ATTRIBUTE =
/([^\s=\/>"'][^\s=\/>]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/
TEMPLATE_NAME =
/\A(?<logical>.+)\.(?<format>#{HTML_FORMAT}|#{EXPO_TURBO_FORMAT})\.(?<handler>[^.]+)\z/

Class Method Summary collapse

Class Method Details

.default_rootsObject

What the rake task lints when a host names no roots. Empty outside a Rails application, so the linter stays usable as a plain object.



171
172
173
174
175
# File 'lib/expo_turbo/rails/paired_templates.rb', line 171

def default_roots
  return [] unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root

  [::Rails.root.join("app/views").to_s]
end

.lint(*roots) ⇒ Object



202
203
204
# File 'lib/expo_turbo/rails/paired_templates.rb', line 202

def lint(*roots)
  pairs(*roots).flat_map { |pair| lint_pair(pair) }
end

.pairs(*roots) ⇒ Object

Every discovered pair under the given roots, sorted by logical name so a CI report reads the same way twice.



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/expo_turbo/rails/paired_templates.rb', line 179

def pairs(*roots)
  found = {}
  roots.flatten.each do |root|
    root = root.to_s
    next unless File.directory?(root)

    Dir.glob("**/*.*.*", base: root).sort.each do |relative|
      match = TEMPLATE_NAME.match(File.basename(relative))
      next unless match && File.file?(File.join(root, relative))

      name = File.join(File.dirname(relative), match[:logical]).delete_prefix("./")
      entry = found[name] ||= Pair.new(name, nil, nil)
      path = File.join(root, relative)
      if match[:format] == HTML_FORMAT
        entry.html_path ||= path
      else
        entry.expo_turbo_path ||= path
      end
    end
  end
  found.values.select { |pair| pair.html_path && pair.expo_turbo_path }.sort_by(&:name)
end