Skip to content

Instantly share code, notes, and snippets.

@ryanckulp
Last active January 13, 2026 21:04
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
# rubocop:disable Naming/PredicateMethod
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' },
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-best-practices#h_3a3eab0712' },
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' },
webhook_strategy_has_copyable_url: { message: "Field with field_type 'copyable_webhook_url' is required for Webhook strategy plugins.", learn_more: 'https://help.usetrmnl.com/en/articles/10513740-custom-plugin-form-builder#h_431c22552a' }
}.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
# ignore *_canvas sizes, which are JSON documents, not lintable HTML
recipe.class::MARKUP_SIZES.reject { it.ends_with?('_canvas') }
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 shared_markup
markup_contents.find { |m| m.keys.first == 'markup_shared' }.values.first
end
def markup_contents_to_s
@markup_contents_to_s ||= markup_contents.map(&:values).join
end
def transform_contents
@transform_contents ||= recipe.download_markup('transform_js') || ''
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.find { it['field_type'] == 'author_bio' }.present?
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|
field_used_in_plugin = 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']}/
field_used_in_plugin = true if recipe_settings[dynamic_field].match?(dynamic_var_regex)
field_used_in_plugin = true if markup_contents_to_s.match?(dynamic_var_regex)
field_used_in_plugin = true if transform_contents.match?(dynamic_var_regex)
end
custom_fields_suggestions << { message: "Custom field '#{custom_field['keyname']}' is not used in form fields or markup." } unless field_used_in_plugin
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
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&.strip
next unless src && !src.include?('{{') # ignore dynamic / interpolated urls
next if src.start_with?('data:') # ignore data URIs (inline images)
return false if src.empty? || !src.match?(%r{\Ahttps?://}) # fail on invalid URLs (empty, relative, or non-HTTP)
return false unless HTTParty.get(src).success?
rescue StandardError
return false
end
end
true
end
def inline_styles_are_not_present
inline_styles = ['justify-content', 'padding', 'margin', 'background-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.include?('shared')
markup = markup_content.values.first
return false unless [markup.length, shared_markup.length].max >= 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
def webhook_strategy_has_copyable_url
return true unless recipe.settings['strategy'] == 'webhook'
custom_fields.find { it['field_type'] == 'copyable_webhook_url' }.present?
end
# rubocop:enable Naming/PredicateMethod
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment