Formatter & linter

One parser serves the compiler, the formatter and the linter, so all three read a template the same way. Both commands split the templates between every core.

Formatter

super-slim format [PATH…]           # rewrite templates in place
super-slim format --check [PATH…]   # list the ones it would rewrite, exit 1 if any

One layout: two spaces per level, - code and = output spacing, p = output after a tag, no redundant div, no trailing whitespace, single blank lines, one final newline. Text blocks move as a whole, so the whitespace they render stays put.

Before writing a file, the formatter compiles the template before and after, and refuses if the Ruby differs: formatting cannot change a page. A refused file is reported and left untouched, and the command exits 1.

With format: max_line_length in .super-slim.yml, longer lines are wrapped: a tag’s attributes one per line inside attribute_delimiters (() by default, or [], {}), then the arguments of its Ruby code after each comma, which Slim reads as a line that goes on. When the line opens a block, its arguments go four spaces deeper, apart from the block’s children. Lines already spread over several lines stay as they are, and a wrap is only kept when Prism reads the same compiled Ruby.

a.btn(
  href=edit_post_path(post)
  data-turbo-frame="modal"
) = t ".edit",
  title: post.title

Paths default to the current directory; .git, node_modules, tmp and vendor are skipped.

Linter

$ super-slim lint [PATH…]
app/views/posts/_card.html.slim:3: [ImageAlt] Describe the image with `alt`, or mark it decorative with `alt=""`

33 rules taken from slim-lint, haml-lint, erb_lint, Herb and eslint-plugin-jsx-a11y: layout, template hygiene, Ruby in templates, Rails conventions, HTML and accessibility. It exits 1 when it reports an offense.

Ruby in templates

The Ruby inside templates is RuboCop’s job. SuperSlim ships a RuboCop plugin that hands it each - and = line, code attribute, splat, #{} interpolation and ruby: block, read by SuperSlim’s own parser:

# .rubocop-slim.yml
inherit_from: .rubocop.yml
plugins:
  - super-slim
AllCops:
  Include:
    - "**/*.slim"
$ bundle exec rubocop -c .rubocop-slim.yml app
app/views/posts/show.html.slim:4:20: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings…

Offenses point at their line and column in the .slim file, and --autocorrect writes back into it. The cops that cannot hold in a template, such as indentation across lines or frozen string comments, are off for .slim files. It replaces rubocop-slim, and with it slimi and Temple.

Configuration

Both commands read .super-slim.yml from the current directory. Every key is optional:

tailwind_classes: true     # read templates the way the app compiles them

format:
  max_line_length: 120     # wrap longer lines, default off
  attribute_delimiters: "{}" # around wrapped attributes: "()", "[]" or "{}", default "()"

linters:
  LineLength:
    max: 150                 # default 120
  ConsecutiveControlStatements:
    max: 3                   # lines of `-` before asking for a ruby: block, default 2
  ButtonType:
    enabled: true            # off by default: ButtonType, EmbeddedEngines, HardcodedText, InlineStyles, StrictLocals, UnescapedOutput
  DeprecatedClasses:
    preset: bootstrap5       # Bootstrap 4 classes that Bootstrap 5.3 renamed or dropped
    classes:
      media: ~               # drops an entry of the preset
      legacy-*: modern-*     # a prefix: legacy-card becomes modern-card
  DeprecatedAttributes:
    preset: bootstrap5       # data-toggle becomes data-bs-toggle, and so on
  EmbeddedEngines:
    enabled: true
    forbidden_engines: [javascript, css]
  HardcodedText:
    enabled: true
    include: ["app/views/shop/**/*.slim"]   # every rule takes include: and exclude: globs
  InlineStyles:
    enabled: true
    exclude: ["app/views/*_mailer/**/*"]   # relative to .super-slim.yml
  InstanceVariables:
    enabled: false

In CI

# .github/workflows/lint.yml
- run: bundle exec super-slim format --check app
- run: bundle exec super-slim lint app
- run: bundle exec rubocop -c .rubocop-slim.yml app

Both print one line per file or offense, path:line: [Rule] message for the linter, and exit 0 when there is nothing to report, 1 otherwise, 64 on a usage error.

Rules

Each example below is run through the linter when the page is generated: the flagged one must be flagged, the clean one must pass.

Layout — fixed by super-slim format

IndentationIndent with 2 spaces, not 3

Flagged

ul
   li Home

Clean

ul
  li Home
TabIndent with spaces, not tabs

Flagged

ul
→   li Home

Clean

ul
  li Home
TrailingWhitespaceTrailing whitespace renders: write it with ' or > instead

Flagged

p Hello·

Clean

p Hello
EmptyLinesRemove this extra empty line

Flagged

h1 Title


p Text

Clean

h1 Title

p Text
TrailingBlankLinesRemove the empty lines at the end of the file

Flagged

p Text

Clean

p Text
ControlStatementSpacingPut a space after -

Flagged

-if admin?
  p Admin

Clean

- if admin?
  p Admin
RedundantDivdiv is implied by the .class or #id shortcut

Flagged

div.card
  p Text

Clean

.card
  p Text

Template hygiene — from slim-lint

LineLengthLine is too long [127/120]

Flagged

p word word word word word word word word word word word word word word word word word word word word word word word word word 

Clean

p
  | word word word word word word word word word word
CommentControlStatementWrite comments with /, not - #: they cost nothing at render time

Flagged

- # TODO
p Text

Clean

/ TODO
p Text
EmptyControlStatementRemove this empty -

Flagged

-
p Text

Clean

p Text
ConsecutiveControlStatementsMerge these 3 lines of Ruby into a ruby: block

Flagged

- a = 1
- b = 2
- c = 3
p = a + b + c

Clean

ruby:
  a = 1
  b = 2
  c = 3
p = a + b + c
TagCaseWrite p in lowercase

Flagged

P Text

Clean

p Text
ZwspRemove the invisible zero-width character

Flagged

p Hello\u200Bworld

Clean

p Hello world

Ruby in templates — from haml-lint and erb_lint

UnnecessaryInterpolationWrite = post.title instead of interpolating it alone

Flagged

p #{post.title}

Clean

p = post.title
UnnecessaryStringOutputWrite the text | Hello instead of outputting a string

Flagged

p
  = "Hello"

Clean

p
  | Hello
DeprecatedClassestext-muted is deprecated: use text-body-secondary

Flagged

p.text-muted Text

Clean

p.text-body-secondary Text
DeprecatedAttributesdata-toggle is deprecated: use data-bs-toggle

Flagged

button data-toggle="modal" type="button" Open

Clean

button data-bs-toggle="modal" type="button" Open
InlineStylesoff by defaultMove inline styles to a stylesheet

Flagged

p style="color: red" Text

Clean

p.text-danger Text
UnescapedOutputoff by default== skips HTML escaping: output with =, or render HTML from a translation key ending in _html

Flagged

p == post.intro

Clean

p = t(".intro_html")
EmbeddedEnginesoff by defaultThe javascript: engine is forbidden here: move this code to a file of its own

Flagged

javascript:
  track("view")

Clean

div data-controller="tracking"
HardcodedTextoff by defaultTranslate Welcome instead of writing it in the template

Flagged

h1 Welcome

Clean

h1 = t(".welcome")

Rails conventions

InstanceVariablesPass @post to the partial as a local

Flagged

p = @post.title

Clean

/# locals: (post:)
p = post.title
StrictLocalsoff by defaultDeclare the partial's locals: /# locals: (name:)

Flagged

p = post.title

Clean

/# locals: (post:)
p = post.title

HTML and accessibility — from Herb and jsx-a11y

DuplicateIdThe id menu is already used in this template

Flagged

#menu
#menu

Clean

#menu
#footer-menu
ImageAltDescribe the image with alt, or mark it decorative with alt=""

Flagged

img src=post.image_url

Clean

img src=post.image_url alt=post.title
IframeTitleName the frame with title

Flagged

iframe src=video_url

Clean

iframe src=video_url title="Intro video"
AnchorHrefA link without href is not a link: use a button for actions

Flagged

a Read more

Clean

a href=post_path(post) Read more
NestedLinksLinks cannot contain links

Flagged

a href="/a"
  a href="/b" B

Clean

a href="/a" A
a href="/b" B
PositiveTabindexA positive tabindex breaks the reading order: use 0 or -1

Flagged

button tabindex="3" type="button" Save

Clean

button tabindex="0" type="button" Save
BooleanAttributeValueWrite disabled=true, or disabled alone inside an attribute list

Flagged

input disabled="disabled"

Clean

input disabled=true
AriaRolebuton is not an ARIA role

Flagged

div role="buton"

Clean

div role="button"
AriaAttributearia-lable is not an ARIA attribute

Flagged

button aria-lable="Close" type="button"

Clean

button aria-label="Close" type="button"
ButtonTypeoff by defaultSay what the button does with type="button" or type="submit"

Flagged

button Save

Clean

button type="submit" Save