Skip to content

Instantly share code, notes, and snippets.

@ryanckulp
Last active October 5, 2025 20:30
Show Gist options
  • Select an option

  • Save ryanckulp/fbe5f68c51db1ae214a97da24be4d62b to your computer and use it in GitHub Desktop.

Select an option

Save ryanckulp/fbe5f68c51db1ae214a97da24be4d62b to your computer and use it in GitHub Desktop.
Chef linter for TRMNL Recipes
module PrivatePlugins
class Chef
attr_accessor :recipe, :suggestions
MAX_TITLE_LENGTH = 50
MAX_INLINE_STYLES = 6
def initialize(recipe)
@recipe = recipe
@suggestions = []
end
def critique
prep_tasks.each { suggest_improvement(it) unless tastes_ok?(it) }
end
def pleased?
suggestions.empty?
end
def tastes_ok?(task)
send(task)
end
def prep_tasks
checks.keys
end
private
def suggest_improvement(task)
suggestions << checks[task]
suggestions.flatten!
suggestions.uniq!
end
def checks
{
async_functions_are_not_present: { message: 'Async JavaScript functions are not allowed due to browser timeout settings for generating screenshots.' },
author_bio_is_present: { message: "Field with field_type 'author_bio' is required for end-user support. Feel free to exclude some properties.", learn_more: 'https://help.usetrmnl.com/en/articles/10513740-custom-plugin-form-builder' },
author_bio_is_ordered: { message: "The 'author_bio' field should be at the top (first YAML entry) of your custom fields." },
custom_fields_values_are_used: custom_fields_values_are_used(include_suggestions: true),
highcharts_animations_are_disabled: { message: 'Highcharts should have animations disabled.', learn_more: 'https://usetrmnl.com/framework/chart' },
highcharts_elements_are_unique: { message: 'To avoid variable shadowing across charts in multiple layouts, use the append_random filter for your Highcharts elements.', learn_more: 'https://help.usetrmnl.com/en/articles/10347358-custom-plugin-filters' },
icon_is_present: { message: 'Icon should exist. Add one here in the settings view.' },
image_links_respond_ok: { message: 'One or more <img> tags has a static "src" URL that does not respond to HTTP GET requests with a success code.' },
inline_styles_are_not_present: { message: 'Markup uses too many inline styles, add more native Framework classes.', learn_more: 'https://help.usetrmnl.com/en/articles/11395668-recipe-form-fields-best-practices' },
markup_size_elements_are_excluded: { message: "We already apply the 'full', 'half_horizontal', 'half_vertical', and 'quadrant' classes to each view, please remove them." },
markups_have_content: { message: 'Some markup layouts are empty, please provide basic treatment.' },
not_a_fork: { message: 'This plugin should not be a Fork of another Recipe. Export + import (zip) to create a fresh submission.', learn_more: 'https://help.usetrmnl.com/en/articles/10542599-importing-and-exporting-private-plugins' },
title_casing: { message: 'Title should begin with a capital letter.' },
title_length: { message: "Title should be <= #{MAX_TITLE_LENGTH} characters long." },
waits_for_dom_load: { message: 'JavaScript should listen for the DOMContentLoaded event, not window.onLoad()', learn_more: 'https://help.usetrmnl.com/en/articles/9510536-private-plugins#h_db7030f8b8' }
}.with_indifferent_access
end
def ignored_custom_fields
%w[author_bio]
end
def custom_fields(include_ignored: false)
return recipe.custom_fields if include_ignored
recipe.custom_fields.reject { |f| ignored_custom_fields.include?(f['field_type']) }
end
def dynamic_form_fields
%w[polling_url polling_headers polling_body]
end
def link_friendly_form_fields
%w[description help_text]
end
def markup_sizes
recipe.class::MARKUP_SIZES
end
def markup_contents
@markup_contents ||= markup_sizes.map do |markup_size|
markup_content = recipe.download_markup(markup_size) || ''
{ markup_size => markup_content.strip }
end
end
def markup_contents_to_s
@markup_contents_to_s ||= markup_contents.map(&:values).flatten.join
end
def recipe_settings
@recipe_settings ||= begin
basic_settings = recipe.settings || {}
encrypted_settings = recipe.encrypted_settings || {}
basic_settings.merge(encrypted_settings).with_indifferent_access
end
end
def async_functions_are_not_present
!markup_contents_to_s.downcase.include?('async function')
end
def author_bio_is_present
recipe.custom_fields.select { |f| f['field_type'] == 'author_bio' }.count.positive?
end
# MVP progressive check; only runs if prereq linter (author_bio_is_present) is satisfied
def author_bio_is_ordered
return true unless author_bio_is_present
custom_fields(include_ignored: true).first['field_type'] == 'author_bio'
end
def custom_fields_values_are_used(include_suggestions: false)
return true if custom_fields.empty?
custom_fields_suggestions = []
custom_fields.each do |custom_field|
used_in_form_fields_or_markup = false
dynamic_form_fields.each do |dynamic_field|
next unless recipe_settings[dynamic_field] # skip match?() if this setting is blank
dynamic_var_regex = /#{custom_field['keyname']}/
used_in_form_fields_or_markup = true if recipe_settings[dynamic_field].match?(dynamic_var_regex)
used_in_form_fields_or_markup = true if markup_contents_to_s.match?(dynamic_var_regex)
end
custom_fields_suggestions << { message: "Custom field '#{custom_field['keyname']}' is not used in form fields or markup." } unless used_in_form_fields_or_markup
end
include_suggestions ? custom_fields_suggestions.uniq : custom_fields_suggestions.empty?
end
def highcharts_animations_are_disabled
return true unless markup_contents_to_s.downcase.include?('highcharts')
markup_contents_to_s.match?(/animation:\s{0,6}false/)
end
def highcharts_elements_are_unique
return true unless markup_contents_to_s.downcase.include?('highcharts')
markup_contents_to_s.match?(/append_random/)
end
def icon_is_present
recipe.icon.persisted?
end
def image_links_respond_ok
image_link_response_failures = 0
markup_contents.each do |markup_content|
page = Nokogiri::HTML(markup_content.values.first)
page.css('img').each do |img_node|
src = img_node.attributes['src']&.value
next unless src && !src.include?('{{') # ignore dynamic / interpolated urls
image_link_response_failures += 1 unless HTTParty.get(src).success?
end
end
image_link_response_failures.zero?
end
def inline_styles_are_not_present
inline_styles = ['display', 'justify-content', 'padding', 'margin', 'background-color', 'color', 'border-radius', 'text-align', 'object-fit', 'font-size']
inline_styles_present = 0
inline_styles.each { inline_styles_present += markup_contents_to_s.scan(it).size }
inline_styles_present <= MAX_INLINE_STYLES
end
def markup_size_elements_are_excluded
markup_size_css_pattern = /\b( view(--|__)(full|half_horizontal|half_vertical|quadrant))\b("|')>/
!markup_contents_to_s.match?(markup_size_css_pattern)
end
def markups_have_content
markup_contents.each do |markup_content|
next if markup_content.keys.first == 'shared'
markup = markup_content.values.first
return false unless markup.length >= 10
end
true
end
def not_a_fork
!recipe.fork?
end
def custom_fields_with_links_have_tags
true if custom_fields.empty?
end
def title_casing
recipe.name[0] == recipe.name[0].upcase
end
def title_length
recipe.name.length <= MAX_TITLE_LENGTH
end
def waits_for_dom_load
return false if markup_contents_to_s.downcase.include?('window.onload')
return false if markup_contents_to_s.downcase.include?('window.addEventListener("load")')
return false if markup_contents_to_s.downcase.include?("window.addEventListener('load')")
true
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment