diff --git a/NEWS.md b/NEWS.md index 2da0cf764..5e5a827a1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ Unreleased * Introduce `suspenders:factories` generator * Introduce `suspenders:advisories` generator * Introduce `suspenders:styles` generator +* Introduce `suspenders:lint` generator 20230113.0 (January, 13, 2023) diff --git a/README.md b/README.md index c1105ad55..e5641fcbe 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,30 @@ improvement for the viewer. [inline_svg]: https://github.com/jamesmartin/inline_svg +### Lint + +Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB. + +Introduces `eslint` and `stylelint` NPM commands that leverage +[@thoughtbot/eslint-config][] and [@thoughtbot/stylelint-config][]. + +``` +yarn run eslint +yarn run stylelint +``` + +Also introduces `.prettierrc` based off of our [Guides][]. + +Introduces `rake standard` which also runs `erblint` to lint ERB files +via [better_html][], [erb_lint][] and [erblint-github][]. + +[@thoughtbot/eslint-config]: https://github.com/thoughtbot/eslint-config +[@thoughtbot/stylelint-config]: https://github.com/thoughtbot/stylelint-config +[Guides]: https://github.com/thoughtbot/guides/blob/main/javascript/README.md#formatting +[better_html]: https://github.com/Shopify/better-html +[erb_lint]: https://github.com/Shopify/erb-lint +[erblint-github]: https://github.com/github/erblint-github + ### Styles Configures applications to use [PostCSS][] or [Tailwind][] via diff --git a/lib/generators/suspenders/lint_generator.rb b/lib/generators/suspenders/lint_generator.rb new file mode 100644 index 000000000..a5feb3759 --- /dev/null +++ b/lib/generators/suspenders/lint_generator.rb @@ -0,0 +1,67 @@ +module Suspenders + module Generators + class LintGenerator < Rails::Generators::Base + include Suspenders::Generators::Helpers + + source_root File.expand_path("../../templates/lint", __FILE__) + desc "Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB." + + def install_dependencies + run "yarn add stylelint eslint @thoughtbot/stylelint-config @thoughtbot/eslint-config --dev" + end + + def install_gems + gem_group :development, :test do + gem "better_html", require: false + gem "erb_lint", require: false + gem "erblint-github", require: false + gem "standard" + end + Bundler.with_unbundled_env { run "bundle install" } + end + + def configure_stylelint + copy_file "stylelintrc.json", ".stylelintrc.json" + end + + def configure_eslint + copy_file "eslintrc.json", ".eslintrc.json" + end + + def configure_prettier + copy_file "prettierrc", ".prettierrc" + end + + def configure_erb_lint + copy_file "erb-lint.yml", ".erb-lint.yml" + copy_file "config_better_html.yml", "config/better_html.yml" + copy_file "config_initializers_better_html.rb", "config/initializers/better_html.rb" + copy_file "erblint.rake", "lib/tasks/erblint.rake" + + if default_test_suite? + copy_file "better_html_test.rb", "test/views/better_html_test.rb" + elsif rspec_test_suite? + copy_file "better_html_spec.rb", "spec/views/better_html_spec.rb" + end + end + + # TODO: Consider extracting this into Rails + def update_package_json + content = File.read package_json + json = JSON.parse content + json["scripts"] ||= {} + + json["scripts"]["eslint"] = "npx eslint 'app/javascript/**/*.js'" + json["scripts"]["stylelint"] = "npx stylelint 'app/assets/stylesheets/**/*.css'" + + File.write package_json, JSON.pretty_generate(json) + end + + private + + def package_json + Rails.root.join("package.json") + end + end + end +end diff --git a/lib/generators/templates/lint/better_html_spec.rb b/lib/generators/templates/lint/better_html_spec.rb new file mode 100644 index 000000000..e62bfcb25 --- /dev/null +++ b/lib/generators/templates/lint/better_html_spec.rb @@ -0,0 +1,17 @@ +require "spec_helper" + +describe "ERB Implementation" do + def self.erb_lint + configuration = ActiveSupport::ConfigurationFile.parse(".erb-lint.yml") + + ActiveSupport::OrderedOptions.new.merge!(configuration.deep_symbolize_keys!) + end + + Rails.root.glob(erb_lint.glob).each do |template| + it "raises html errors in #{template.relative_path_from(Rails.root)}" do + validator = BetterHtml::BetterErb::ErubiImplementation.new(template.read) + + validator.validate! + end + end +end diff --git a/lib/generators/templates/lint/better_html_test.rb b/lib/generators/templates/lint/better_html_test.rb new file mode 100644 index 000000000..9138c0ee5 --- /dev/null +++ b/lib/generators/templates/lint/better_html_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class ErbImplementationTest < ActiveSupport::TestCase + def self.erb_lint + configuration = ActiveSupport::ConfigurationFile.parse(".erb-lint.yml") + + ActiveSupport::OrderedOptions.new.merge!(configuration.deep_symbolize_keys!) + end + + Rails.root.glob(erb_lint.glob).each do |template| + test "html errors in #{template.relative_path_from(Rails.root)}" do + validator = BetterHtml::BetterErb::ErubiImplementation.new(template.read) + + validator.validate! + end + end +end diff --git a/lib/generators/templates/lint/config_better_html.yml b/lib/generators/templates/lint/config_better_html.yml new file mode 100644 index 000000000..eb6826cff --- /dev/null +++ b/lib/generators/templates/lint/config_better_html.yml @@ -0,0 +1,2 @@ +--- +allow_single_quoted_attributes: false diff --git a/lib/generators/templates/lint/config_initializers_better_html.rb b/lib/generators/templates/lint/config_initializers_better_html.rb new file mode 100644 index 000000000..887b953e6 --- /dev/null +++ b/lib/generators/templates/lint/config_initializers_better_html.rb @@ -0,0 +1,5 @@ +Rails.configuration.to_prepare do + config = ActiveSupport::ConfigurationFile.parse("config/better_html.yml") + + BetterHtml.config = BetterHtml::Config.new(config) +end diff --git a/lib/generators/templates/lint/erb-lint.yml b/lib/generators/templates/lint/erb-lint.yml new file mode 100644 index 000000000..aac222c15 --- /dev/null +++ b/lib/generators/templates/lint/erb-lint.yml @@ -0,0 +1,63 @@ +--- +glob: "app/views/**/*.{html,turbo_stream}{+*,}.erb" + +linters: + AllowedScriptType: + enabled: true + allowed_types: + - "module" + - "text/javascript" + ErbSafety: + enabled: true + better_html_config: "config/better_html.yml" + GitHub::Accessibility::AvoidBothDisabledAndAriaDisabledCounter: + enabled: true + GitHub::Accessibility::AvoidGenericLinkTextCounter: + enabled: true + GitHub::Accessibility::DisabledAttributeCounter: + enabled: true + GitHub::Accessibility::IframeHasTitleCounter: + enabled: true + GitHub::Accessibility::ImageHasAltCounter: + enabled: true + GitHub::Accessibility::LandmarkHasLabelCounter: + enabled: true + GitHub::Accessibility::LinkHasHrefCounter: + enabled: true + GitHub::Accessibility::NestedInteractiveElementsCounter: + enabled: true + GitHub::Accessibility::NoAriaLabelMisuseCounter: + enabled: true + GitHub::Accessibility::NoPositiveTabIndexCounter: + enabled: true + GitHub::Accessibility::NoRedundantImageAltCounter: + enabled: true + GitHub::Accessibility::NoTitleAttributeCounter: + enabled: true + GitHub::Accessibility::SvgHasAccessibleTextCounter: + enabled: true + Rubocop: + enabled: true + rubocop_config: + inherit_from: + - .rubocop.yml + + Lint/EmptyBlock: + Enabled: false + Layout/InitialIndentation: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Layout/LeadingEmptyLines: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Style/MultilineTernaryOperator: + Enabled: false + Lint/UselessAssignment: + Exclude: + - "app/views/**/*" + +EnableDefaultLinters: true diff --git a/lib/generators/templates/lint/erblint.rake b/lib/generators/templates/lint/erblint.rake new file mode 100644 index 000000000..804f572fc --- /dev/null +++ b/lib/generators/templates/lint/erblint.rake @@ -0,0 +1,47 @@ +module ERBLint + module RakeSupport + # Allow command line flags set in STANDARDOPTS (like MiniTest's TESTOPTS) + def self.argvify + if ENV["ERBLINTOPTS"] + ENV["ERBLINTOPTS"].split(/\s+/) + else + [] + end + end + + # DELETE THIS FILE AFTER MERGE: + # + # * https://github.com/Shopify/better-html/pull/95 + # + def self.backport! + BetterHtml::TestHelper::SafeErb::AllowedScriptType::VALID_JAVASCRIPT_TAG_TYPES.push("module") + end + end +end + +desc "Lint templates with erb_lint" +task "erblint" do + require "erb_lint/cli" + require "erblint-github/linters" + + ERBLint::RakeSupport.backport! + + cli = ERBLint::CLI.new + success = cli.run(ERBLint::RakeSupport.argvify + ["--lint-all", "--format=compact"]) + fail unless success +end + +desc "Lint and automatically fix templates with erb_lint" +task "erblint:autocorrect" do + require "erb_lint/cli" + require "erblint-github/linters" + + ERBLint::RakeSupport.backport! + + cli = ERBLint::CLI.new + success = cli.run(ERBLint::RakeSupport.argvify + ["--lint-all", "--autocorrect"]) + fail unless success +end + +task "standard" => "erblint" +task "standard:fix" => "erblint:autocorrect" diff --git a/lib/generators/templates/lint/eslintrc.json b/lib/generators/templates/lint/eslintrc.json new file mode 100644 index 000000000..118e6598b --- /dev/null +++ b/lib/generators/templates/lint/eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "@thoughtbot/eslint-config/base", + "@thoughtbot/eslint-config/prettier" + ] +} diff --git a/lib/generators/templates/lint/prettierrc b/lib/generators/templates/lint/prettierrc new file mode 100644 index 000000000..544138be4 --- /dev/null +++ b/lib/generators/templates/lint/prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/lib/generators/templates/lint/stylelintrc.json b/lib/generators/templates/lint/stylelintrc.json new file mode 100644 index 000000000..3171f405a --- /dev/null +++ b/lib/generators/templates/lint/stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@thoughtbot/stylelint-config" +} diff --git a/test/generators/suspenders/lint_generator_test.rb b/test/generators/suspenders/lint_generator_test.rb new file mode 100644 index 000000000..1ec875cd0 --- /dev/null +++ b/test/generators/suspenders/lint_generator_test.rb @@ -0,0 +1,190 @@ +require "test_helper" +require "generators/suspenders/lint_generator" + +module Suspenders + module Generators + class LintGeneratorTest < Rails::Generators::TestCase + include Suspenders::TestHelpers + + tests Suspenders::Generators::LintGenerator + destination Rails.root + setup :prepare_destination + teardown :restore_destination + + test "installs dependencies" do + capture(:stderr) do + output = run_generator + + assert_match(/yarn add stylelint eslint @thoughtbot\/stylelint-config @thoughtbot\/eslint-config --dev/, output) + end + end + + test "installs gems" do + capture(:stderr) do + expected_gemfile = <<~TEXT + group :development, :test do + gem "better_html", require: false + gem "erb_lint", require: false + gem "erblint-github", require: false + gem "standard" + end + TEXT + + output = run_generator + + assert_match(/bundle install/, output) + assert_file app_root "Gemfile" do |file| + assert_match expected_gemfile, file + end + end + end + + test "configures stylelint" do + expected_content = <<~TEXT + { + "extends": "@thoughtbot/stylelint-config" + } + TEXT + + capture(:stderr) { run_generator } + + assert_file app_root(".stylelintrc.json") do |file| + assert_equal expected_content, file + end + end + + test "configures eslint" do + expected_content = <<~TEXT + { + "extends": [ + "@thoughtbot/eslint-config/base", + "@thoughtbot/eslint-config/prettier" + ] + } + TEXT + + capture(:stderr) { run_generator } + + assert_file app_root(".eslintrc.json") do |file| + assert_equal expected_content, file + end + end + + test "configures prettier" do + expected_content = <<~TEXT + { + "singleQuote": true + } + TEXT + + capture(:stderr) { run_generator } + + assert_file app_root(".prettierrc") do |file| + assert_equal expected_content, file + end + end + + test "configures erb-lint" do + capture(:stderr) { run_generator } + + assert_file app_root(".erb-lint.yml") + assert_file app_root("config/better_html.yml") + assert_file app_root("config/initializers/better_html.rb") + assert_file app_root("lib/tasks/erblint.rake") + end + + test "generates erb-lint tests" do + with_test_suite do + capture(:stderr) { run_generator } + + assert_file app_root("test/views/better_html_test.rb") + assert_no_file app_root("spec/views/better_html_spec.rb") + end + end + + test "generates erb-lint specs" do + with_test_suite :rspec do + capture(:stderr) { run_generator } + + assert_file app_root("spec/views/better_html_spec.rb") + assert_no_file app_root("test/views/better_html_test.rb") + end + end + + test "updates package.json" do + touch "package.json", content: package_json + + capture(:stderr) { run_generator } + + assert_file "package.json" do |file| + assert_equal expected_package_json, file + end + end + + test "updates package.json if script key does not exist" do + touch "package.json", content: package_json(empty: true) + + capture(:stderr) { run_generator } + + assert_file "package.json" do |file| + assert_equal expected_package_json, file + end + end + + test "description" do + desc = "Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB." + + assert_equal desc, generator_class.desc + end + + private + + def prepare_destination + touch "Gemfile" + touch "package.json", content: package_json(empty: true) + end + + def restore_destination + remove_file_if_exists "Gemfile" + remove_file_if_exists ".stylelintrc.json" + remove_file_if_exists ".eslintrc.json" + remove_file_if_exists ".prettierrc" + remove_file_if_exists "package.json" + remove_file_if_exists ".erb-lint.yml" + remove_file_if_exists "config/better_html.yml" + remove_file_if_exists "config/initializers/better_html.rb" + remove_file_if_exists "package.json", root: true + remove_file_if_exists "yarn.lock", root: true + remove_dir_if_exists "lib/tasks" + remove_dir_if_exists "test" + remove_dir_if_exists "spec" + end + + def package_json(empty: false) + if empty + <<~JSON.chomp + { + } + JSON + else + <<~JSON.chomp + { + "scripts": {} + } + JSON + end + end + + def expected_package_json + <<~JSON.chomp + { + "scripts": { + "eslint": "npx eslint 'app/javascript/**/*.js'", + "stylelint": "npx stylelint 'app/assets/stylesheets/**/*.css'" + } + } + JSON + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4fcaa7ebc..9900d7552 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,10 +38,16 @@ def mkdir(dir) FileUtils.mkdir path end - def touch(file) + # TODO: Update existing tests to use the content: option + def touch(file, **options) + content = options[:content] path = app_root file FileUtils.touch path + + if content + File.write app_root(path), content + end end def within_api_only_app(**options, &block) @@ -76,6 +82,21 @@ class Application < Rails::Application restore_file "config/application.rb" end + # TODO: Refactor existing tests to use this + def with_test_suite(test_suite = :minitest, &block) + if test_suite == :minitest + mkdir "test" + elsif test_suite == :rspec + mkdir "spec" + touch "spec/spec_helper.rb" + end + + yield + ensure + remove_dir_if_exists "test" + remove_dir_if_exists "spec" + end + def backup_file(file) FileUtils.mv app_root(file), app_root("#{file}.bak") touch file