Last active
October 5, 2025 20:30
-
-
Save ryanckulp/fbe5f68c51db1ae214a97da24be4d62b to your computer and use it in GitHub Desktop.
Chef linter for TRMNL Recipes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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