trmnl_preview 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +319 -0
  5. data/bin/rake +27 -0
  6. data/bin/trmnlp +13 -14
  7. data/db/data/form_fields.yml +24 -0
  8. data/db/data/framework_versions.yml +72 -0
  9. data/lib/trmnlp/api_client.rb +99 -0
  10. data/lib/trmnlp/app.rb +132 -0
  11. data/lib/trmnlp/browser_pool.rb +82 -0
  12. data/lib/trmnlp/cli.rb +81 -0
  13. data/lib/trmnlp/commands/base.rb +63 -0
  14. data/lib/trmnlp/commands/build.rb +26 -0
  15. data/lib/trmnlp/commands/clone.rb +35 -0
  16. data/lib/trmnlp/commands/init.rb +59 -0
  17. data/lib/trmnlp/commands/lint.rb +42 -0
  18. data/lib/trmnlp/commands/list.rb +40 -0
  19. data/lib/trmnlp/commands/login.rb +42 -0
  20. data/lib/trmnlp/commands/pull.rb +51 -0
  21. data/lib/trmnlp/commands/push.rb +77 -0
  22. data/lib/trmnlp/commands/serve.rb +54 -0
  23. data/lib/trmnlp/commands.rb +3 -0
  24. data/lib/trmnlp/config/app.rb +50 -0
  25. data/lib/trmnlp/config/plugin.rb +117 -0
  26. data/lib/trmnlp/config/project.rb +106 -0
  27. data/lib/trmnlp/config.rb +17 -0
  28. data/lib/trmnlp/context.rb +35 -0
  29. data/lib/trmnlp/errors.rb +15 -0
  30. data/lib/trmnlp/form_field.rb +42 -0
  31. data/lib/trmnlp/framework_version.rb +69 -0
  32. data/lib/trmnlp/image_quantizer.rb +58 -0
  33. data/lib/trmnlp/lint/check.rb +31 -0
  34. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  35. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  36. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  37. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  38. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  39. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  40. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  41. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  42. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  43. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  44. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  45. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  46. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  47. data/lib/trmnlp/lint/source.rb +42 -0
  48. data/lib/trmnlp/lint.rb +39 -0
  49. data/lib/trmnlp/paths.rb +78 -0
  50. data/lib/trmnlp/poller.rb +105 -0
  51. data/lib/trmnlp/renderer.rb +87 -0
  52. data/lib/trmnlp/reporter.rb +28 -0
  53. data/lib/trmnlp/screen.rb +16 -0
  54. data/lib/trmnlp/screen_generator.rb +34 -0
  55. data/lib/trmnlp/screenshot.rb +96 -0
  56. data/lib/trmnlp/transform_backend/http.rb +107 -0
  57. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  58. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  59. data/lib/trmnlp/transform_client.rb +47 -0
  60. data/lib/trmnlp/transform_pipeline.rb +65 -0
  61. data/lib/trmnlp/user_data_assembler.rb +96 -0
  62. data/lib/trmnlp/version.rb +5 -0
  63. data/lib/trmnlp/watcher.rb +60 -0
  64. data/lib/trmnlp.rb +10 -0
  65. data/templates/init/.trmnlp.yml +14 -0
  66. data/templates/init/bin/trmnlp +44 -0
  67. data/templates/init/src/full.liquid +1 -0
  68. data/templates/init/src/half_horizontal.liquid +1 -0
  69. data/templates/init/src/half_vertical.liquid +1 -0
  70. data/templates/init/src/quadrant.liquid +1 -0
  71. data/templates/init/src/settings.yml +16 -0
  72. data/templates/init/src/shared.liquid +1 -0
  73. data/templates/init/src/transform.py.example +14 -0
  74. data/trmnl_preview.gemspec +68 -0
  75. data/web/public/highlight/highlight.min.js +315 -0
  76. data/web/public/highlight/styles/atom-one-dark.min.css +1 -0
  77. data/web/public/index.css +119 -0
  78. data/web/public/index.js +96 -0
  79. data/web/public/trmnl-picker.js +656 -0
  80. data/web/views/index.erb +73 -0
  81. data/web/views/render_html.erb +25 -0
  82. metadata +324 -15
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TRMNLP
6
+ # Represents a TRMNL design-system framework version, so plugins can
7
+ # pin (or float to "latest") the CSS/JS bundle they render against,
8
+ # both in production and locally.
9
+ class FrameworkVersion
10
+ include Comparable
11
+
12
+ DEFAULT_ASSET_HOST = 'https://trmnl.com'
13
+ DATA_PATH = File.expand_path('../../db/data/framework_versions.yml', __dir__)
14
+
15
+ attr_reader :number
16
+
17
+ def self.config = @config ||= YAML.load_file(DATA_PATH).freeze
18
+
19
+ def self.version_numbers = config.fetch('versions').map { |v| v['number'] }.freeze
20
+
21
+ def self.latest = new(config.fetch('latest'))
22
+
23
+ # Suitable for showing in a `select` form field. Pinnable versions are
24
+ # ordered newest-first by semantic version — never by manifest order.
25
+ def self.options
26
+ newest_first = version_numbers.sort_by { |number| Gem::Version.new(number) }.reverse
27
+ [{ "Always track latest (currently v#{latest.number})" => 'latest' }] +
28
+ newest_first.map { |number| { "v#{number}" => number } }
29
+ end
30
+
31
+ def initialize(number, asset_host: DEFAULT_ASSET_HOST)
32
+ @asset_host = asset_host
33
+
34
+ if number.nil? || number == 'latest'
35
+ @number = self.class.config.fetch('latest')
36
+ @pinned = false
37
+ elsif self.class.version_numbers.include?(number)
38
+ @number = number
39
+ @pinned = true
40
+ else
41
+ raise ArgumentError, "unknown framework version: #{number}"
42
+ end
43
+ end
44
+
45
+ def pinned? = @pinned
46
+
47
+ def css_url = "#{@asset_host}/css/#{path_segment}/plugins.css"
48
+
49
+ def js_url = "#{@asset_host}/js/#{path_segment}/plugins.js"
50
+
51
+ def ==(other) = other.is_a?(self.class) && number == other.number
52
+
53
+ def <=>(other)
54
+ return nil unless other.is_a?(self.class)
55
+
56
+ Gem::Version.new(number) <=> Gem::Version.new(other.number)
57
+ end
58
+
59
+ def as_json(*) = number
60
+
61
+ def to_s = number
62
+
63
+ private
64
+
65
+ # When pinned, requests assets at /css/<version>/plugins.css to lock
66
+ # behavior; otherwise hit /css/latest/ for live updates.
67
+ def path_segment = pinned? ? @number : 'latest'
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_magick'
4
+ require 'tempfile'
5
+ require 'fileutils'
6
+
7
+ module TRMNLP
8
+ class ImageQuantizer
9
+ MIN_DEPTH = 1
10
+ MAX_DEPTH = 8
11
+
12
+ def initialize(depth:)
13
+ @depth = clamp(depth)
14
+ end
15
+
16
+ def call(path)
17
+ tmp = Tempfile.new(['mono', '.png'])
18
+ tmp.close
19
+ quantize(path, tmp.path)
20
+ FileUtils.mv(tmp.path, path, force: true)
21
+ end
22
+
23
+ private
24
+
25
+ def quantize(src, dst)
26
+ MiniMagick.convert do |m|
27
+ m << src
28
+ m.colorspace 'Gray'
29
+ m.dither 'FloydSteinberg'
30
+ apply_depth(m)
31
+ m.depth @depth
32
+ m.define "png:bit-depth=#{@depth}"
33
+ m.strip
34
+ m << dst
35
+ end
36
+ end
37
+
38
+ def apply_depth(m)
39
+ if @depth == 1
40
+ # NOTE: 1-bit only has black/white, so a halftone remap simulates gray
41
+ # via dithering patterns. Skipping this collapses photos to pure
42
+ # silhouettes.
43
+ m.remap 'pattern:gray50'
44
+ m.posterize 2
45
+ m.colors 2
46
+ m.type 'Bilevel'
47
+ else
48
+ levels = 2**@depth
49
+ m.posterize levels
50
+ m.colors levels
51
+ end
52
+ end
53
+
54
+ def clamp(depth)
55
+ [[depth.to_i, MIN_DEPTH].max, MAX_DEPTH].min
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module Lint
5
+ # Base class for a single best-practice check. A subclass declares a
6
+ # MESSAGE constant (and optionally LEARN_MORE) and implements #pass?.
7
+ # Checks that surface a variable number of findings override #issues.
8
+ class Check
9
+ def initialize(source)
10
+ @source = source
11
+ end
12
+
13
+ def issues
14
+ pass? ? [] : [issue]
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :source
20
+
21
+ def pass?
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def issue
26
+ learn_more = self.class.const_defined?(:LEARN_MORE) ? self.class::LEARN_MORE : nil
27
+ { message: self.class::MESSAGE, learn_more: }.compact
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports custom fields declared in .trmnlp.yml that never appear in the
9
+ # polling settings or the markup — one finding per unused field.
10
+ class CustomFieldsUsed < Check
11
+ SETTINGS_KEYS = %w[polling_url polling_headers polling_body].freeze
12
+
13
+ def issues
14
+ source.custom_field_values.keys.reject { |keyname| used?(keyname) }.map do |keyname|
15
+ { message: "Custom field '#{keyname}' is not used in form fields or markup." }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def used?(keyname)
22
+ pattern = /#{Regexp.escape(keyname)}/
23
+ searchable_settings.match?(pattern) || source.all_markup.match?(pattern)
24
+ end
25
+
26
+ def searchable_settings
27
+ @searchable_settings ||= SETTINGS_KEYS.filter_map { |key| source.settings[key] }.join(' ')
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+ require_relative '../../form_field'
5
+
6
+ module TRMNLP
7
+ module Lint
8
+ module Checks
9
+ # Validates settings.yml custom_fields against the FormField schema —
10
+ # the same source serve/build use for their startup warnings.
11
+ class FormFieldsValid < Check
12
+ def issues
13
+ FormField.validate_all(source.custom_field_definitions).map do |warning|
14
+ { message: "settings.yml custom_fields — #{warning}" }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class HighchartsAnimationsDisabled < Check
9
+ MESSAGE = 'Highcharts should have animations disabled.'
10
+ LEARN_MORE = 'https://trmnl.com/framework/chart'
11
+
12
+ private
13
+
14
+ def pass?
15
+ markup = source.all_markup
16
+ return true unless markup.downcase.include?('highcharts')
17
+
18
+ markup.match?(/animation:\s{0,6}false/)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class HighchartsElementsUnique < Check
9
+ MESSAGE = 'To avoid variable shadowing across charts in multiple layouts, ' \
10
+ 'use the append_random filter for your Highcharts elements.'
11
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10347358-custom-plugin-filters'
12
+
13
+ private
14
+
15
+ def pass?
16
+ markup = source.all_markup
17
+ return true unless markup.downcase.include?('highcharts')
18
+
19
+ markup.match?(/append_random/)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ require_relative '../check'
8
+
9
+ module TRMNLP
10
+ module Lint
11
+ module Checks
12
+ # Flags static <img> URLs that answer with a non-success status. A
13
+ # network failure (offline, DNS, timeout) is NOT a plugin defect, so
14
+ # those are skipped rather than reported as one.
15
+ class ImageLinksReachable < Check
16
+ MESSAGE = 'One or more <img> tags has a static "src" URL that does not ' \
17
+ 'respond to HTTP GET requests with a success code.'
18
+ TIMEOUT = 5
19
+ UNREACHABLE = [SocketError, Net::OpenTimeout, Net::ReadTimeout,
20
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, OpenSSL::SSL::SSLError].freeze
21
+
22
+ private
23
+
24
+ def pass? = static_image_urls.all? { |url| reachable?(url) }
25
+
26
+ def static_image_urls
27
+ source.view_markup.values
28
+ .flat_map { |html| html.scan(/<img[^>]+src\s*=\s*["']([^"']+)["']/i).flatten }
29
+ .map(&:strip)
30
+ .reject { |src| src.empty? || src.include?('{{') || src.start_with?('data:') }
31
+ end
32
+
33
+ def reachable?(url)
34
+ return false unless url.match?(%r{\Ahttps?://})
35
+
36
+ response = fetch(URI.parse(url))
37
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
38
+ rescue *UNREACHABLE
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def fetch(uri)
45
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
46
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
47
+ http.get(uri.request_uri)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class LayoutsHaveContent < Check
9
+ MIN_CONTENT_LENGTH = 10
10
+ MESSAGE = 'Some markup layouts are empty, please provide basic treatment.'
11
+
12
+ private
13
+
14
+ # A view passes when either its own markup or the shared markup
15
+ # carries real content — mirrors the hosted behaviour.
16
+ def pass?
17
+ source.view_markup.values.all? do |markup|
18
+ [markup.length, source.shared_markup.length].max >= MIN_CONTENT_LENGTH
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class LimitedInlineStyles < Check
9
+ MAX_INLINE_STYLES = 6
10
+ PROPERTIES = %w[
11
+ justify-content padding margin background-color
12
+ border-radius text-align object-fit font-size
13
+ ].freeze
14
+ MESSAGE = 'Markup uses too many inline styles, add more native Framework classes.'
15
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/11395668-recipe-best-practices#h_3a3eab0712'
16
+
17
+ private
18
+
19
+ def pass?
20
+ count = PROPERTIES.sum { |property| source.all_markup.scan(property).size }
21
+ count <= MAX_INLINE_STYLES
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoAsyncFunctions < Check
9
+ MESSAGE = 'Async JavaScript functions are not allowed due to browser ' \
10
+ 'timeout settings for generating screenshots.'
11
+
12
+ private
13
+
14
+ def pass? = !source.all_markup.downcase.include?('async function')
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoOpacity < Check
9
+ MESSAGE = 'Opacity should not be used, use the "--gray--##" Framework classes instead.'
10
+ LEARN_MORE = 'https://trmnl.com/framework/docs/text_color'
11
+ PATTERN = /opacity:\s*[\d.]+/
12
+
13
+ private
14
+
15
+ def pass? = !source.all_markup.match?(PATTERN)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoSizeClasses < Check
9
+ PATTERN = /\b(view(--|__)(full|half_horizontal|half_vertical|quadrant))\b("|')>/
10
+ MESSAGE = "We already apply the 'full', 'half_horizontal', 'half_vertical', and " \
11
+ "'quadrant' classes to each view, please remove them."
12
+
13
+ private
14
+
15
+ def pass? = !source.all_markup.match?(PATTERN)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class TitleCasing < Check
9
+ MESSAGE = 'Title should begin with a capital letter.'
10
+
11
+ private
12
+
13
+ def pass?
14
+ name = source.plugin_name
15
+ name.empty? || name[0] == name[0].upcase
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class TitleLength < Check
9
+ MAX_LENGTH = 50
10
+ MESSAGE = "Title should be <= #{MAX_LENGTH} characters long.".freeze
11
+
12
+ private
13
+
14
+ def pass? = source.plugin_name.length <= MAX_LENGTH
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class WaitsForDomLoad < Check
9
+ MESSAGE = 'JavaScript should listen for the DOMContentLoaded event, not window.onLoad()'
10
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/9510536-private-plugins#h_db7030f8b8'
11
+ FORBIDDEN = ['window.onload', 'window.addeventlistener("load")',
12
+ "window.addeventlistener('load')"].freeze
13
+
14
+ private
15
+
16
+ def pass?
17
+ markup = source.all_markup.downcase
18
+ FORBIDDEN.none? { |token| markup.include?(token) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module Lint
5
+ # The plugin data every lint check examines: markup per view, shared
6
+ # markup, the combined markup string, and the raw settings.yml. Built
7
+ # once and shared across all checks. Settings come straight from
8
+ # Config::Plugin (a single parse); checks read the raw `{{ }}` templates,
9
+ # which Config::Plugin's semantic readers render away.
10
+ class Source
11
+ VIEWS = %w[full half_horizontal half_vertical quadrant].freeze
12
+
13
+ def initialize(config:, paths:)
14
+ @config = config
15
+ @paths = paths
16
+ end
17
+
18
+ def plugin_name = settings['name'].to_s
19
+ def settings = config.plugin.settings
20
+ def custom_field_values = config.project.custom_fields
21
+ def custom_field_definitions = config.plugin.custom_field_definitions
22
+
23
+ def view_markup
24
+ @view_markup ||= VIEWS.to_h { |view| [view, read(paths.template(view))] }
25
+ end
26
+
27
+ def shared_markup
28
+ @shared_markup ||= read(paths.shared_template)
29
+ end
30
+
31
+ def all_markup
32
+ @all_markup ||= view_markup.values.join + shared_markup
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :config, :paths
38
+
39
+ def read(path) = path.exist? ? path.read.strip : ''
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lint/check'
4
+ require_relative 'lint/source'
5
+ require_relative 'lint/checks/title_casing'
6
+ require_relative 'lint/checks/title_length'
7
+ require_relative 'lint/checks/layouts_have_content'
8
+ require_relative 'lint/checks/no_async_functions'
9
+ require_relative 'lint/checks/waits_for_dom_load'
10
+ require_relative 'lint/checks/limited_inline_styles'
11
+ require_relative 'lint/checks/no_size_classes'
12
+ require_relative 'lint/checks/no_opacity'
13
+ require_relative 'lint/checks/highcharts_animations_disabled'
14
+ require_relative 'lint/checks/highcharts_elements_unique'
15
+ require_relative 'lint/checks/image_links_reachable'
16
+ require_relative 'lint/checks/custom_fields_used'
17
+ require_relative 'lint/checks/form_fields_valid'
18
+
19
+ module TRMNLP
20
+ # Markup best-practice checks behind `trmnlp lint`.
21
+ module Lint
22
+ # Every check the lint command runs, in report order.
23
+ CHECKS = [
24
+ Checks::TitleCasing,
25
+ Checks::TitleLength,
26
+ Checks::LayoutsHaveContent,
27
+ Checks::NoAsyncFunctions,
28
+ Checks::WaitsForDomLoad,
29
+ Checks::LimitedInlineStyles,
30
+ Checks::NoSizeClasses,
31
+ Checks::NoOpacity,
32
+ Checks::HighchartsAnimationsDisabled,
33
+ Checks::HighchartsElementsUnique,
34
+ Checks::ImageLinksReachable,
35
+ Checks::CustomFieldsUsed,
36
+ Checks::FormFieldsValid
37
+ ].freeze
38
+ end
39
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xdg'
4
+
5
+ module TRMNLP
6
+ class Paths
7
+ attr_reader :root_dir
8
+
9
+ def initialize(root_dir)
10
+ @root_dir = Pathname.new(root_dir)
11
+ @xdg = XDG.new
12
+ end
13
+
14
+ # --- trmnlp library ---
15
+
16
+ def gem_dir = Pathname.new(__dir__).join('..', '..').expand_path
17
+
18
+ def templates_dir = gem_dir.join('templates')
19
+
20
+ # --- directories ---
21
+
22
+ def src_dir = root_dir.join('src')
23
+
24
+ def build_dir = root_dir.join('_build')
25
+ def create_build_dir = build_dir.mkpath
26
+
27
+ def app_config_dir = xdg.config_home.join('trmnlp')
28
+
29
+ def cache_dir = xdg.cache_home.join('trmnl')
30
+ def create_cache_dir = cache_dir.mkpath
31
+
32
+ def valid? = trmnlp_config.exist?
33
+
34
+ # --- files ---
35
+
36
+ def trmnlp_config = root_dir.join('.trmnlp.yml')
37
+
38
+ def plugin_config = src_dir.join('settings.yml')
39
+
40
+ def template(view) = src_dir.join("#{view}.liquid")
41
+
42
+ def shared_template = template('shared')
43
+
44
+ def app_config = app_config_dir.join('config.yml')
45
+
46
+ def user_data = cache_dir.join('data.json')
47
+
48
+ def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
49
+
50
+ def src_files = src_dir.glob('*').select(&:file?)
51
+
52
+ # File extension → transform language identifier.
53
+ TRANSFORM_EXTENSIONS = {
54
+ '.py' => 'python',
55
+ '.rb' => 'ruby',
56
+ '.php' => 'php',
57
+ '.js' => 'node'
58
+ }.freeze
59
+
60
+ # Locate src/transform.{py,rb,php,js}. Returns [Pathname, language]
61
+ # or [nil, nil] if no transform file exists.
62
+ def transform_file
63
+ TRANSFORM_EXTENSIONS.each do |ext, language|
64
+ candidate = src_dir.join("transform#{ext}")
65
+ return [candidate, language] if candidate.exist?
66
+ end
67
+ [nil, nil]
68
+ end
69
+
70
+ # --- utilities ---
71
+
72
+ def expand(path) = Pathname.new(path).expand_path(root_dir)
73
+
74
+ private
75
+
76
+ attr_reader :xdg
77
+ end
78
+ end