nitro_kit 0.9.0 → 2.0.0.alpha.2
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.
- checksums.yaml +4 -4
- data/.agents/plugins/marketplace.json +20 -0
- data/CHANGELOG.md +172 -0
- data/LICENSE +28 -0
- data/README.md +22 -15
- data/STYLE_GUIDE.md +425 -0
- data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
- data/app/assets/stylesheets/nitro_kit.css +7239 -0
- data/app/components/nitro_kit/accordion.rb +125 -65
- data/app/components/nitro_kit/alert.rb +80 -43
- data/app/components/nitro_kit/app_navigation.rb +307 -0
- data/app/components/nitro_kit/app_shell.rb +227 -0
- data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
- data/app/components/nitro_kit/appearance_picker.rb +156 -0
- data/app/components/nitro_kit/auth_shell.rb +34 -0
- data/app/components/nitro_kit/avatar.rb +85 -29
- data/app/components/nitro_kit/avatar_stack.rb +155 -9
- data/app/components/nitro_kit/badge.rb +43 -81
- data/app/components/nitro_kit/button.rb +180 -103
- data/app/components/nitro_kit/button_group.rb +63 -11
- data/app/components/nitro_kit/button_to.rb +69 -0
- data/app/components/nitro_kit/card.rb +42 -45
- data/app/components/nitro_kit/checkbox.rb +115 -62
- data/app/components/nitro_kit/checkbox_group.rb +131 -22
- data/app/components/nitro_kit/choice.rb +55 -0
- data/app/components/nitro_kit/combobox.rb +292 -105
- data/app/components/nitro_kit/command_palette.rb +412 -0
- data/app/components/nitro_kit/component.rb +322 -46
- data/app/components/nitro_kit/container.rb +32 -0
- data/app/components/nitro_kit/control_group.rb +45 -0
- data/app/components/nitro_kit/danger_zone.rb +78 -0
- data/app/components/nitro_kit/data_section.rb +106 -0
- data/app/components/nitro_kit/details_table.rb +229 -0
- data/app/components/nitro_kit/dialog.rb +257 -83
- data/app/components/nitro_kit/dropdown.rb +335 -122
- data/app/components/nitro_kit/dropzone.rb +326 -0
- data/app/components/nitro_kit/empty_state.rb +99 -0
- data/app/components/nitro_kit/field.rb +461 -236
- data/app/components/nitro_kit/field_group.rb +15 -8
- data/app/components/nitro_kit/fieldset.rb +37 -39
- data/app/components/nitro_kit/flex.rb +49 -0
- data/app/components/nitro_kit/form_builder.rb +313 -72
- data/app/components/nitro_kit/form_section.rb +79 -0
- data/app/components/nitro_kit/grid.rb +34 -0
- data/app/components/nitro_kit/icon.rb +54 -25
- data/app/components/nitro_kit/input.rb +97 -10
- data/app/components/nitro_kit/label.rb +22 -6
- data/app/components/nitro_kit/layout_options.rb +7 -0
- data/app/components/nitro_kit/page_header.rb +76 -0
- data/app/components/nitro_kit/pagination.rb +355 -69
- data/app/components/nitro_kit/pagination_bar.rb +90 -0
- data/app/components/nitro_kit/progressive_image.rb +181 -0
- data/app/components/nitro_kit/radio_button.rb +77 -55
- data/app/components/nitro_kit/radio_button_group.rb +107 -36
- data/app/components/nitro_kit/responsive_value.rb +94 -0
- data/app/components/nitro_kit/rich_text_area.rb +31 -0
- data/app/components/nitro_kit/select.rb +129 -64
- data/app/components/nitro_kit/settings_layout.rb +152 -0
- data/app/components/nitro_kit/sheet.rb +202 -0
- data/app/components/nitro_kit/stat_grid.rb +75 -0
- data/app/components/nitro_kit/switch.rb +108 -51
- data/app/components/nitro_kit/table.rb +237 -45
- data/app/components/nitro_kit/tabs.rb +168 -63
- data/app/components/nitro_kit/textarea.rb +81 -10
- data/app/components/nitro_kit/toast.rb +211 -80
- data/app/components/nitro_kit/toolbar.rb +75 -0
- data/app/components/nitro_kit/tooltip.rb +235 -34
- data/app/components/nitro_kit/typeset.rb +28 -0
- data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
- data/app/javascript/controllers/nk/appearance_controller.js +73 -0
- data/app/javascript/controllers/nk/avatar_controller.js +15 -0
- data/app/javascript/controllers/nk/button_controller.js +56 -0
- data/app/javascript/controllers/nk/checkable_controller.js +41 -0
- data/app/javascript/controllers/nk/combobox_controller.js +314 -82
- data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
- data/app/javascript/controllers/nk/dialog_controller.js +14 -10
- data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
- data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
- data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
- data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
- data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
- data/app/javascript/controllers/nk/overlay_position.js +37 -0
- data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
- data/app/javascript/controllers/nk/tabs_controller.js +135 -23
- data/app/javascript/controllers/nk/toast_controller.js +112 -45
- data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
- data/config/importmap.rb +2 -0
- data/config/locales/en.yml +98 -0
- data/docs/agent_guide.md +139 -0
- data/docs/agent_native_spec.md +422 -0
- data/docs/component_contracts.md +263 -0
- data/docs/customization.md +595 -0
- data/docs/hotwire.md +94 -0
- data/docs/initialization_prompt.md +36 -0
- data/docs/migration_1_to_2.md +318 -0
- data/docs/new_app_strategy.md +22 -0
- data/docs/patterns/application_foundation.md +136 -0
- data/docs/patterns/crud_resource.md +144 -0
- data/docs/patterns/destructive_action.md +105 -0
- data/docs/patterns/flash_and_toast.md +57 -0
- data/docs/patterns/inline_edit.md +59 -0
- data/docs/patterns/queryable_collection.md +187 -0
- data/docs/patterns/resource_form.md +126 -0
- data/docs/rails_conventions.md +95 -0
- data/docs/rails_integration.md +514 -0
- data/lib/generators/nitro_kit/install_generator.rb +48 -0
- data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
- data/lib/nitro_kit/engine.rb +20 -0
- data/lib/nitro_kit/installation.rb +671 -0
- data/lib/nitro_kit/migration_inventory.rb +345 -0
- data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
- data/lib/nitro_kit/version.rb +1 -1
- data/lib/nitro_kit.rb +5 -42
- data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
- data/lib/tasks/nitro_kit_tasks.rake +79 -4
- data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
- data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
- data/src/stylesheets/nitro_kit/components/alert.css +82 -0
- data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
- data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
- data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
- data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
- data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
- data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
- data/src/stylesheets/nitro_kit/components/badge.css +76 -0
- data/src/stylesheets/nitro_kit/components/button.css +283 -0
- data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
- data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
- data/src/stylesheets/nitro_kit/components/card.css +70 -0
- data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
- data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
- data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
- data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
- data/src/stylesheets/nitro_kit/components/container.css +25 -0
- data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
- data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
- data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
- data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
- data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
- data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
- data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
- data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
- data/src/stylesheets/nitro_kit/components/field.css +79 -0
- data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
- data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
- data/src/stylesheets/nitro_kit/components/flex.css +455 -0
- data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
- data/src/stylesheets/nitro_kit/components/grid.css +308 -0
- data/src/stylesheets/nitro_kit/components/icon.css +26 -0
- data/src/stylesheets/nitro_kit/components/input.css +80 -0
- data/src/stylesheets/nitro_kit/components/label.css +9 -0
- data/src/stylesheets/nitro_kit/components/layout.css +440 -0
- data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
- data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
- data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
- data/src/stylesheets/nitro_kit/components/palette.css +113 -0
- data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
- data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
- data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
- data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
- data/src/stylesheets/nitro_kit/components/select.css +70 -0
- data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
- data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
- data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
- data/src/stylesheets/nitro_kit/components/switch.css +152 -0
- data/src/stylesheets/nitro_kit/components/table.css +116 -0
- data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
- data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
- data/src/stylesheets/nitro_kit/components/toast.css +131 -0
- data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
- data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
- data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
- data/src/stylesheets/nitro_kit/layers.css +7 -0
- data/src/stylesheets/nitro_kit/reset.css +192 -0
- data/src/stylesheets/nitro_kit/tokens.css +198 -0
- metadata +182 -51
- data/Rakefile +0 -8
- data/app/assets/tailwind/application.css +0 -49
- data/app/components/nitro_kit/datepicker.rb +0 -9
- data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
- data/app/helpers/nitro_kit/alert_helper.rb +0 -11
- data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
- data/app/helpers/nitro_kit/badge_helper.rb +0 -13
- data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/button_helper.rb +0 -49
- data/app/helpers/nitro_kit/card_helper.rb +0 -9
- data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
- data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
- data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
- data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
- data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_helper.rb +0 -9
- data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
- data/app/helpers/nitro_kit/form_helper.rb +0 -13
- data/app/helpers/nitro_kit/icon_helper.rb +0 -9
- data/app/helpers/nitro_kit/input_helper.rb +0 -37
- data/app/helpers/nitro_kit/label_helper.rb +0 -19
- data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
- data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
- data/app/helpers/nitro_kit/select_helper.rb +0 -24
- data/app/helpers/nitro_kit/switch_helper.rb +0 -9
- data/app/helpers/nitro_kit/table_helper.rb +0 -9
- data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
- data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
- data/app/helpers/nitro_kit/toast_helper.rb +0 -25
- data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
- data/app/javascript/controllers/nk/accordion_controller.js +0 -18
- data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
- data/app/javascript/controllers/nk/switch_controller.js +0 -32
- data/lib/generators/nitro_kit/component_generator.rb +0 -84
- data/lib/nitro_kit/schema_builder.rb +0 -129
- data/lib/nitro_kit/variants.rb +0 -21
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "ripper"
|
|
5
|
+
require "nitro_kit/migration_inventory"
|
|
6
|
+
|
|
7
|
+
module NitroKit
|
|
8
|
+
class Installation
|
|
9
|
+
ROOT = Pathname.new(File.expand_path("../..", __dir__))
|
|
10
|
+
SKILLS = %w[nitro-kit-hotwire nitro-kit-rails nitro-kit-ui].freeze
|
|
11
|
+
SKILL_ROOTS = [ ".agents/skills", ".claude/skills" ].freeze
|
|
12
|
+
MANAGED_STYLESHEETS = %w[
|
|
13
|
+
lexxy nitro_kit-tailwind-v4 nitro_kit tailwind application
|
|
14
|
+
].freeze
|
|
15
|
+
AGENTS_START = "<!-- nitro-kit:start -->"
|
|
16
|
+
AGENTS_END = "<!-- nitro-kit:end -->"
|
|
17
|
+
AGENTS_BLOCK = <<~MARKDOWN.freeze
|
|
18
|
+
#{AGENTS_START}
|
|
19
|
+
## Nitro Kit 2
|
|
20
|
+
|
|
21
|
+
This application uses Nitro Kit 2.x. Before changing Rails structure,
|
|
22
|
+
Hotwire interactions, or UI, use the matching project-local Nitro Kit skill.
|
|
23
|
+
Each skill resolves the installed gem with `bundle show nitro_kit` and reads
|
|
24
|
+
its version-matched documentation.
|
|
25
|
+
|
|
26
|
+
Do not use Nitro Kit 1.x APIs, `nk_*` helpers, copied Nitro components, or
|
|
27
|
+
application-owned `controllers/nk`. Compose the installed Phlex Kit and keep
|
|
28
|
+
routes, records, authorization, queries, DOM IDs, and server responses in the
|
|
29
|
+
application.
|
|
30
|
+
|
|
31
|
+
During migration, replace an existing form control only when Nitro Kit 2 has
|
|
32
|
+
a genuine semantic and behavioral equivalent. Otherwise preserve the control
|
|
33
|
+
as application-owned Rails and semantic HTML. Never downgrade specialized
|
|
34
|
+
behavior or retain copied Nitro Kit 1.x source as the fallback.
|
|
35
|
+
#{AGENTS_END}
|
|
36
|
+
MARKDOWN
|
|
37
|
+
|
|
38
|
+
Check = Struct.new(:status, :label, :detail, keyword_init: true)
|
|
39
|
+
LayoutExpression = Struct.new(:range, :type, :assets, :dynamic, keyword_init: true)
|
|
40
|
+
LayoutAnalysis = Struct.new(:head_range, :stylesheets, :bootstraps, :errors, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
attr_reader :application_root
|
|
43
|
+
|
|
44
|
+
def initialize(application_root)
|
|
45
|
+
@application_root = Pathname.new(application_root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def install
|
|
49
|
+
changes = { "AGENTS.md" => write_agents }.merge(write_skills)
|
|
50
|
+
changes[relative(layout_path)] = write_layout if layout_path
|
|
51
|
+
changes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_agents
|
|
55
|
+
path = application_root.join("AGENTS.md")
|
|
56
|
+
current = path.exist? ? path.read : ""
|
|
57
|
+
updated = if current.include?(AGENTS_START)
|
|
58
|
+
current.sub(/#{Regexp.escape(AGENTS_START)}.*?#{Regexp.escape(AGENTS_END)}/m, AGENTS_BLOCK.rstrip)
|
|
59
|
+
else
|
|
60
|
+
[ current.rstrip, AGENTS_BLOCK.rstrip ].reject(&:empty?).join("\n\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
write(path, "#{updated.rstrip}\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write_skills
|
|
67
|
+
SKILL_ROOTS.product(SKILLS).to_h do |root, skill|
|
|
68
|
+
destination = application_root.join(root, skill, "SKILL.md")
|
|
69
|
+
source = skill_source(skill)
|
|
70
|
+
[ destination.relative_path_from(application_root).to_s, write(destination, source.read) ]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def prompt
|
|
75
|
+
ROOT.join("docs/initialization_prompt.md").read
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def copy_prompt
|
|
79
|
+
command = clipboard_command
|
|
80
|
+
raise "No supported clipboard command found" unless command
|
|
81
|
+
|
|
82
|
+
IO.popen(command, "w") { _1.write(prompt) }
|
|
83
|
+
raise "Clipboard command failed" unless $?.success?
|
|
84
|
+
|
|
85
|
+
command.first
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def checks
|
|
89
|
+
[
|
|
90
|
+
version_check,
|
|
91
|
+
agents_check,
|
|
92
|
+
skills_check,
|
|
93
|
+
hotwire_check,
|
|
94
|
+
stimulus_loader_check,
|
|
95
|
+
stylesheet_setup_check,
|
|
96
|
+
phlex_kit_check,
|
|
97
|
+
*migration_checks
|
|
98
|
+
]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def healthy?
|
|
102
|
+
checks.none? { _1.status == :fail }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
def write(path, contents)
|
|
107
|
+
return :unchanged if path.exist? && path.read == contents
|
|
108
|
+
|
|
109
|
+
FileUtils.mkdir_p(path.dirname)
|
|
110
|
+
path.write(contents)
|
|
111
|
+
:written
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def skill_source(skill)
|
|
115
|
+
ROOT.join("plugins/nitro-kit/skills", skill, "SKILL.md")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def version_check
|
|
119
|
+
status = NitroKit::VERSION.start_with?("2.") ? :pass : :fail
|
|
120
|
+
Check.new(status:, label: "Nitro Kit version", detail: NitroKit::VERSION)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def agents_check
|
|
124
|
+
path = application_root.join("AGENTS.md")
|
|
125
|
+
installed = path.exist? && path.read.include?(AGENTS_BLOCK.rstrip)
|
|
126
|
+
Check.new(
|
|
127
|
+
status: installed ? :pass : :fail,
|
|
128
|
+
label: "AGENTS.md guidance",
|
|
129
|
+
detail: installed ? "managed Nitro Kit 2 block is current" : "run the Nitro Kit install generator"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def skills_check
|
|
134
|
+
missing = SKILL_ROOTS.product(SKILLS).filter_map do |root, skill|
|
|
135
|
+
path = application_root.join(root, skill, "SKILL.md")
|
|
136
|
+
path.relative_path_from(application_root).to_s unless path.exist? && path.read == skill_source(skill).read
|
|
137
|
+
end
|
|
138
|
+
Check.new(
|
|
139
|
+
status: missing.empty? ? :pass : :fail,
|
|
140
|
+
label: "Project-local skills",
|
|
141
|
+
detail: missing.empty? ? "Codex and Claude skill routers are current" : "missing or stale: #{missing.join(', ')}"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def hotwire_check
|
|
146
|
+
missing = %w[turbo-rails stimulus-rails].reject { Gem.loaded_specs.key?(_1) }
|
|
147
|
+
Check.new(
|
|
148
|
+
status: missing.empty? ? :pass : :fail,
|
|
149
|
+
label: "Hotwire dependencies",
|
|
150
|
+
detail: missing.empty? ? "Turbo and Stimulus are available" : "missing: #{missing.join(', ')}"
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stimulus_loader_check
|
|
155
|
+
path = application_root.join("app/javascript/controllers/index.js")
|
|
156
|
+
ready = path.exist? && path.read.include?("eagerLoadControllersFrom")
|
|
157
|
+
Check.new(
|
|
158
|
+
status: ready ? :pass : :warn,
|
|
159
|
+
label: "Stimulus loader",
|
|
160
|
+
detail: ready ? "eager controller loading is configured" : "verify the application's controller registration"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stylesheet_setup_check
|
|
165
|
+
return Check.new(
|
|
166
|
+
status: :fail,
|
|
167
|
+
label: "Stylesheet and appearance setup",
|
|
168
|
+
detail: "missing application layout; add AppearanceBootstrap before the ordered stylesheet entries"
|
|
169
|
+
) unless layout_path
|
|
170
|
+
|
|
171
|
+
contents = layout_path.read
|
|
172
|
+
analysis = analyze_layout(contents)
|
|
173
|
+
expected = expected_stylesheets(analysis)
|
|
174
|
+
errors = []
|
|
175
|
+
bootstrap_count = analysis.bootstraps.size
|
|
176
|
+
|
|
177
|
+
if analysis.errors.any?
|
|
178
|
+
return Check.new(
|
|
179
|
+
status: :fail,
|
|
180
|
+
label: "Stylesheet and appearance setup",
|
|
181
|
+
detail: "#{relative(layout_path)}: installer left the layout unchanged — #{analysis.errors.join('; ')}; manually ensure AppearanceBootstrap → #{expected.join(' → ')} without discarding existing options"
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
errors << "add one NitroKit::AppearanceBootstrap before every stylesheet" if bootstrap_count.zero?
|
|
186
|
+
errors << "remove duplicate NitroKit::AppearanceBootstrap entries" if bootstrap_count > 1
|
|
187
|
+
|
|
188
|
+
assets = managed_stylesheet_assets(analysis)
|
|
189
|
+
expected.each do |stylesheet|
|
|
190
|
+
count = assets.count(stylesheet)
|
|
191
|
+
errors << "add stylesheet #{stylesheet.inspect}" if count.zero?
|
|
192
|
+
errors << "remove duplicate stylesheet #{stylesheet.inspect}" if count > 1
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if (edit_error = layout_edit_error(analysis, expected))
|
|
196
|
+
errors << "installer left the layout unchanged: #{edit_error}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if assets.include?("nitro_kit-tailwind-v4") && !expected.include?("tailwind")
|
|
200
|
+
errors << "remove nitro_kit-tailwind-v4 or load compiled Tailwind after nitro_kit"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
bootstrap_position = analysis.bootstraps.first&.range&.begin
|
|
204
|
+
first_stylesheet_position = analysis.stylesheets.first&.range&.begin
|
|
205
|
+
if errors.empty? && (bootstrap_position >= first_stylesheet_position || assets != expected)
|
|
206
|
+
errors << "reorder to AppearanceBootstrap → #{expected.join(' → ')}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
Check.new(
|
|
210
|
+
status: errors.empty? ? :pass : :fail,
|
|
211
|
+
label: "Stylesheet and appearance setup",
|
|
212
|
+
detail: errors.empty? ? "#{relative(layout_path)}: AppearanceBootstrap → #{expected.join(' → ')}" : "#{relative(layout_path)}: #{errors.join('; ')}"
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def phlex_kit_check
|
|
217
|
+
ready = application_root.glob("app/components/**/*.rb").any? { _1.read.match?(/include\s+(?:\(?\s*)?NitroKit\b/) }
|
|
218
|
+
Check.new(
|
|
219
|
+
status: ready ? :pass : :warn,
|
|
220
|
+
label: "Phlex Kit base",
|
|
221
|
+
detail: ready ? "an application component includes NitroKit" : "include NitroKit once in the application base component"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def migration_checks
|
|
226
|
+
MigrationInventory.new(application_root).categories.map do |label, findings|
|
|
227
|
+
Check.new(
|
|
228
|
+
status: findings.empty? ? :pass : :warn,
|
|
229
|
+
label: "Migration: #{label}",
|
|
230
|
+
detail: findings.empty? ? "migrated: none found" : findings.map do |finding|
|
|
231
|
+
"#{finding.status.to_s.tr('_', '-')}: #{finding.path} — #{finding.guidance}"
|
|
232
|
+
end.join("\n")
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def layout_path
|
|
238
|
+
layout_paths.first
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def layout_paths
|
|
242
|
+
[
|
|
243
|
+
application_root.join("app/components/ui/application_layout.rb"),
|
|
244
|
+
application_root.join("app/components/application_layout.rb"),
|
|
245
|
+
application_root.join("app/views/layouts/application.html.erb"),
|
|
246
|
+
*application_root.glob("app/components/**/application_layout.rb")
|
|
247
|
+
].uniq.select(&:file?)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def write_layout
|
|
251
|
+
write(layout_path, configured_layout(layout_path.read))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def configured_layout(contents)
|
|
255
|
+
analysis = analyze_layout(contents)
|
|
256
|
+
expected = expected_stylesheets(analysis)
|
|
257
|
+
edit_error = layout_edit_error(analysis, expected)
|
|
258
|
+
return contents if edit_error
|
|
259
|
+
|
|
260
|
+
missing = expected - managed_stylesheet_assets(analysis)
|
|
261
|
+
return contents if missing.empty? && analysis.bootstraps.one?
|
|
262
|
+
|
|
263
|
+
lines = contents.lines
|
|
264
|
+
insertions = Hash.new { |hash, index| hash[index] = [] }
|
|
265
|
+
indent = layout_indent(lines, analysis)
|
|
266
|
+
|
|
267
|
+
if analysis.bootstraps.empty?
|
|
268
|
+
bootstrap_index = analysis.stylesheets.first&.range&.begin || analysis.head_range.begin + 1
|
|
269
|
+
insertions[bootstrap_index] << bootstrap_markup(indent)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
missing.group_by { insertion_slot(analysis, expected, _1) }.each do |slot, assets|
|
|
273
|
+
index = stylesheet_insertion_index(analysis, slot)
|
|
274
|
+
assets.sort_by { expected.index(_1) }.each do |asset|
|
|
275
|
+
insertions[index] << stylesheet_markup(indent, asset)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
(0..lines.length).each_with_object(String.new) do |index, updated|
|
|
280
|
+
insertions[index].each { updated << _1 }
|
|
281
|
+
updated << lines[index] if index < lines.length
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def analyze_layout(contents)
|
|
286
|
+
errors = []
|
|
287
|
+
errors << "multiple application layouts found: #{layout_paths.map { relative(_1) }.join(', ')}" if layout_paths.many?
|
|
288
|
+
lines = contents.lines
|
|
289
|
+
head_range, boundary_errors = if layout_path.extname == ".rb"
|
|
290
|
+
phlex_head_range(contents)
|
|
291
|
+
else
|
|
292
|
+
erb_head_range(contents)
|
|
293
|
+
end
|
|
294
|
+
errors.concat(boundary_errors)
|
|
295
|
+
|
|
296
|
+
all_expressions = layout_path.extname == ".rb" ? phlex_layout_expressions(contents) : erb_layout_expressions(contents)
|
|
297
|
+
expressions = if head_range
|
|
298
|
+
all_expressions.select do |expression|
|
|
299
|
+
inside = head_range.cover?(expression.range.begin) && head_range.cover?(expression.range.end)
|
|
300
|
+
overlaps = head_range.cover?(expression.range.begin) || head_range.cover?(expression.range.end)
|
|
301
|
+
errors << "#{expression.type} call on line #{expression.range.begin + 1} crosses the document head boundary" if overlaps && !inside
|
|
302
|
+
inside
|
|
303
|
+
end
|
|
304
|
+
else
|
|
305
|
+
[]
|
|
306
|
+
end
|
|
307
|
+
outside_expressions = all_expressions - expressions
|
|
308
|
+
outside_expressions.each do |expression|
|
|
309
|
+
next if head_range && (head_range.cover?(expression.range.begin) || head_range.cover?(expression.range.end))
|
|
310
|
+
|
|
311
|
+
errors << "#{expression.type} call on line #{expression.range.begin + 1} is outside the document head"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
stylesheets = expressions.select { _1.type == :stylesheet }
|
|
315
|
+
bootstraps = expressions.select { _1.type == :bootstrap }
|
|
316
|
+
target_indent = head_range ? lines[head_range.begin][/^\s*/].to_s.length + 2 : nil
|
|
317
|
+
|
|
318
|
+
expressions.each do |expression|
|
|
319
|
+
actual_indent = lines[expression.range.begin][/^\s*/].to_s.length
|
|
320
|
+
errors << "#{expression.type} call on line #{expression.range.begin + 1} is conditional or nested" unless actual_indent == target_indent
|
|
321
|
+
end
|
|
322
|
+
stylesheets.each do |expression|
|
|
323
|
+
errors << "stylesheet call on line #{expression.range.begin + 1} has dynamic assets" if expression.dynamic
|
|
324
|
+
if (expression.assets & MANAGED_STYLESHEETS).any? && (expression.assets - MANAGED_STYLESHEETS).any?
|
|
325
|
+
errors << "stylesheet call on line #{expression.range.begin + 1} combines custom and canonical assets"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
head_contents = head_range ? lines[head_range].join : ""
|
|
330
|
+
if layout_path.extname == ".erb" && head_contents.match?(/<%(?![=%#])/)
|
|
331
|
+
errors << "head contains conditional or executable ERB"
|
|
332
|
+
end
|
|
333
|
+
if head_contents.match?(/<link\b[^>]*\brel=["']stylesheet["']/i)
|
|
334
|
+
errors << "head contains a custom stylesheet link"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
LayoutAnalysis.new(head_range: head_range || (0..0), stylesheets:, bootstraps:, errors: errors.uniq)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def erb_head_range(contents)
|
|
341
|
+
lines = contents.lines
|
|
342
|
+
starts = lines.each_index.select { lines[_1].match?(/^\s*<head(?:\s[^>]*)?>\s*$/i) }
|
|
343
|
+
finishes = lines.each_index.select { lines[_1].match?(/^\s*<\/head>\s*$/i) }
|
|
344
|
+
errors = []
|
|
345
|
+
errors << "expected one conventional <head> opening tag on its own line" unless starts.one?
|
|
346
|
+
errors << "expected one conventional </head> closing tag on its own line" unless finishes.one?
|
|
347
|
+
return [ nil, errors ] unless starts.one? && finishes.one?
|
|
348
|
+
|
|
349
|
+
errors << "the </head> closing tag must follow the <head> opening tag" unless starts.first < finishes.first
|
|
350
|
+
[ starts.first..finishes.first, errors ]
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def phlex_head_range(contents)
|
|
354
|
+
syntax = Ripper.sexp(contents)
|
|
355
|
+
return [ nil, [ "could not parse the Phlex application layout" ] ] unless syntax
|
|
356
|
+
|
|
357
|
+
html_blocks = ruby_block_calls(syntax, "html")
|
|
358
|
+
head_blocks = ruby_block_calls(syntax, "head")
|
|
359
|
+
errors = []
|
|
360
|
+
errors << "expected one conventional html block in the Phlex application layout" unless html_blocks.one?
|
|
361
|
+
errors << "expected one conventional head block in the Phlex application layout" unless head_blocks.one?
|
|
362
|
+
return [ nil, errors ] unless html_blocks.one? && head_blocks.one?
|
|
363
|
+
|
|
364
|
+
html_statements = ruby_block_statements(html_blocks.first)
|
|
365
|
+
direct_heads = html_statements.select { ruby_block_call_name(_1) == "head" }
|
|
366
|
+
unless direct_heads.one? && direct_heads.first.equal?(head_blocks.first)
|
|
367
|
+
return [ nil, [ "the Phlex head block must be owned directly by the conventional html block" ] ]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
position = ruby_node_position(head_blocks.first, "head")
|
|
371
|
+
return [ nil, [ "could not determine the Phlex head block boundary" ] ] unless position
|
|
372
|
+
|
|
373
|
+
start = position.first - 1
|
|
374
|
+
indent = lines_indent(contents.lines[start])
|
|
375
|
+
unless contents.lines[start].match?(/^#{Regexp.escape(indent)}head(?:\(\s*\))?\s+do\s*$/)
|
|
376
|
+
return [ nil, [ "the Phlex head block must use a conventional `head do` line" ] ]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
head_statement_index = html_statements.index { _1.equal?(head_blocks.first) }
|
|
380
|
+
next_statement_position = html_statements[(head_statement_index + 1)..].filter_map { ruby_first_position(_1) }.min
|
|
381
|
+
upper_bound = if next_statement_position
|
|
382
|
+
next_statement_position.first - 2
|
|
383
|
+
else
|
|
384
|
+
html_position = ruby_node_position(html_blocks.first, "html")
|
|
385
|
+
html_indent = lines_indent(contents.lines[html_position.first - 1])
|
|
386
|
+
contents.lines.each_index.find do |index|
|
|
387
|
+
index > start && contents.lines[index].match?(/^#{Regexp.escape(html_indent)}end\s*$/)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
return [ nil, [ "could not determine the closing `end` for the Phlex html block" ] ] unless upper_bound
|
|
391
|
+
|
|
392
|
+
finishes = contents.lines.each_index.select do |index|
|
|
393
|
+
index > start && index <= upper_bound && contents.lines[index].match?(/^#{Regexp.escape(indent)}end\s*$/)
|
|
394
|
+
end
|
|
395
|
+
unless finishes.one?
|
|
396
|
+
return [ nil, [ "could not uniquely determine the closing `end` for the Phlex head block" ] ]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
[ start..finishes.first, errors ]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def phlex_layout_expressions(contents)
|
|
403
|
+
lines = contents.lines
|
|
404
|
+
Ripper.lex(contents).filter_map do |(position, event, token, _state)|
|
|
405
|
+
next unless event == :on_ident && token == "stylesheet_link_tag"
|
|
406
|
+
|
|
407
|
+
start = position.first - 1
|
|
408
|
+
range = ruby_expression_range(lines, start)
|
|
409
|
+
source = lines[range].join
|
|
410
|
+
assets = static_stylesheet_assets(source)
|
|
411
|
+
LayoutExpression.new(range:, type: :stylesheet, assets: assets || [], dynamic: assets.nil?)
|
|
412
|
+
end + lines.each_index.filter_map do |index|
|
|
413
|
+
range = ruby_expression_range(lines, index)
|
|
414
|
+
source = lines[range].join
|
|
415
|
+
next unless source.lstrip.start_with?("render") && bootstrap_expression?(source)
|
|
416
|
+
|
|
417
|
+
LayoutExpression.new(range:, type: :bootstrap, assets: [], dynamic: false)
|
|
418
|
+
end.uniq { [ _1.type, _1.range ] }.sort_by { _1.range.begin }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def erb_layout_expressions(contents)
|
|
422
|
+
contents.to_enum(:scan, /<%=(.*?)%>/m).filter_map do
|
|
423
|
+
match = Regexp.last_match
|
|
424
|
+
start = contents[0...match.begin(0)].count("\n")
|
|
425
|
+
finish = contents[0...match.end(0)].count("\n")
|
|
426
|
+
source = match[1]
|
|
427
|
+
if ruby_identifier?(source, "stylesheet_link_tag")
|
|
428
|
+
assets = static_stylesheet_assets(source)
|
|
429
|
+
LayoutExpression.new(range: start..finish, type: :stylesheet, assets: assets || [], dynamic: assets.nil?)
|
|
430
|
+
elsif bootstrap_expression?(source)
|
|
431
|
+
LayoutExpression.new(range: start..finish, type: :bootstrap, assets: [], dynamic: false)
|
|
432
|
+
end
|
|
433
|
+
end.sort_by { _1.range.begin }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def ruby_expression_range(lines, start)
|
|
437
|
+
(start...lines.length).each do |finish|
|
|
438
|
+
return start..finish if Ripper.sexp(lines[start..finish].join)
|
|
439
|
+
end
|
|
440
|
+
start..start
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def static_stylesheet_assets(source)
|
|
444
|
+
arguments = invocation_arguments(Ripper.sexp(source), "stylesheet_link_tag")
|
|
445
|
+
return unless arguments
|
|
446
|
+
|
|
447
|
+
positional = arguments.take_while { !%i[bare_assoc_hash hash].include?(_1&.first) }
|
|
448
|
+
return unless arguments.drop(positional.length).all? { %i[bare_assoc_hash hash].include?(_1&.first) }
|
|
449
|
+
|
|
450
|
+
positional.map { string_literal(_1) }.tap { return if _1.any?(&:nil?) }
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def invocation_arguments(node, name)
|
|
454
|
+
return unless node.is_a?(Array)
|
|
455
|
+
|
|
456
|
+
arguments = direct_invocation_arguments(node, name)
|
|
457
|
+
return arguments if arguments
|
|
458
|
+
|
|
459
|
+
node.filter_map { invocation_arguments(_1, name) if _1.is_a?(Array) }.first
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def invocation_argument_lists(node, name)
|
|
463
|
+
return [] unless node.is_a?(Array)
|
|
464
|
+
|
|
465
|
+
arguments = direct_invocation_arguments(node, name)
|
|
466
|
+
nested = node.flat_map { invocation_argument_lists(_1, name) if _1.is_a?(Array) }.compact
|
|
467
|
+
arguments ? [ arguments, *nested ] : nested
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def direct_invocation_arguments(node, name)
|
|
471
|
+
case node.first
|
|
472
|
+
when :method_add_arg
|
|
473
|
+
argument_list(node[2]) if callable_name(node[1]) == name
|
|
474
|
+
when :command
|
|
475
|
+
argument_list(node[2]) if node.dig(1, 1) == name
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def callable_name(node)
|
|
480
|
+
node.dig(1, 1) if node&.first == :fcall
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def ruby_block_calls(node, name)
|
|
484
|
+
return [] unless node.is_a?(Array)
|
|
485
|
+
|
|
486
|
+
calls = ruby_block_call_name(node) == name ? [ node ] : []
|
|
487
|
+
calls + node.flat_map { ruby_block_calls(_1, name) if _1.is_a?(Array) }.compact
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def ruby_block_call_name(node)
|
|
491
|
+
return unless node&.first == :method_add_block
|
|
492
|
+
|
|
493
|
+
call = node[1]
|
|
494
|
+
case call&.first
|
|
495
|
+
when :method_add_arg
|
|
496
|
+
callable_name(call[1])
|
|
497
|
+
when :command
|
|
498
|
+
call.dig(1, 1)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def ruby_block_statements(node)
|
|
503
|
+
block = node[2]
|
|
504
|
+
block&.first == :do_block && block.dig(2, 1) || []
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def ruby_node_position(node, name)
|
|
508
|
+
return unless node.is_a?(Array)
|
|
509
|
+
return node[2] if node.first == :@ident && node[1] == name
|
|
510
|
+
|
|
511
|
+
node.filter_map { ruby_node_position(_1, name) if _1.is_a?(Array) }.first
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def ruby_first_position(node)
|
|
515
|
+
return unless node.is_a?(Array)
|
|
516
|
+
return node[2] if node.first.to_s.start_with?("@") && node[2].is_a?(Array)
|
|
517
|
+
|
|
518
|
+
node.filter_map { ruby_first_position(_1) if _1.is_a?(Array) }.min
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def lines_indent(line)
|
|
522
|
+
line[/^\s*/].to_s
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def argument_list(node)
|
|
526
|
+
node = node[1] if node&.first == :arg_paren
|
|
527
|
+
node&.first == :args_add_block ? node[1] : nil
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def string_literal(node)
|
|
531
|
+
return unless node&.first == :string_literal
|
|
532
|
+
return "" if node[1]&.first == :string_content && node[1].length == 1
|
|
533
|
+
return unless node[1]&.first == :string_content && node[1][1..].all? { _1.first == :@tstring_content }
|
|
534
|
+
|
|
535
|
+
node[1][1..].map { _1[1] }.join
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def bootstrap_expression?(source)
|
|
539
|
+
tokens = Ripper.lex(source).reject { %i[on_sp on_nl on_ignored_nl].include?(_1[1]) }.map { _1[2] }
|
|
540
|
+
tokens.each_cons(3).any? { _1 == [ "NitroKit", "::", "AppearanceBootstrap" ] }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def ruby_identifier?(source, identifier)
|
|
544
|
+
Ripper.lex(source).any? { _1[1] == :on_ident && _1[2] == identifier }
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def expected_stylesheets(analysis)
|
|
548
|
+
assets = managed_stylesheet_assets(analysis) - [ "nitro_kit-tailwind-v4" ]
|
|
549
|
+
assets << "lexxy" if dependency?("lexxy")
|
|
550
|
+
assets << "tailwind" if tailwind?(assets)
|
|
551
|
+
assets << "nitro_kit"
|
|
552
|
+
assets << "application" if assets.include?("application") || application_stylesheet_asset?
|
|
553
|
+
assets << "nitro_kit-tailwind-v4" if assets.include?("tailwind")
|
|
554
|
+
ordered_stylesheets(assets.uniq)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def ordered_stylesheets(assets)
|
|
558
|
+
vendor = assets - %w[nitro_kit-tailwind-v4 nitro_kit tailwind application]
|
|
559
|
+
[
|
|
560
|
+
*vendor,
|
|
561
|
+
*(%w[nitro_kit-tailwind-v4] & assets),
|
|
562
|
+
*(%w[nitro_kit] & assets),
|
|
563
|
+
*(%w[tailwind] & assets),
|
|
564
|
+
*(%w[application] & assets)
|
|
565
|
+
]
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def layout_edit_error(analysis, expected)
|
|
569
|
+
return analysis.errors.join("; ") if analysis.errors.any?
|
|
570
|
+
return "duplicate AppearanceBootstrap calls require manual review" if analysis.bootstraps.many?
|
|
571
|
+
|
|
572
|
+
first_stylesheet = analysis.stylesheets.first
|
|
573
|
+
if analysis.bootstraps.one? && first_stylesheet && analysis.bootstraps.first.range.begin > first_stylesheet.range.begin
|
|
574
|
+
return "AppearanceBootstrap is after a stylesheet; move the existing call without changing its options"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
existing = managed_stylesheet_assets(analysis)
|
|
578
|
+
return "duplicate canonical stylesheet assets require manual review" if existing.uniq != existing
|
|
579
|
+
unless existing == expected.select { existing.include?(_1) }
|
|
580
|
+
return "existing canonical stylesheets are misordered; reorder their intact calls manually"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
missing = expected - existing
|
|
584
|
+
if missing.any? { insertion_slot(analysis, expected, _1).nil? }
|
|
585
|
+
"a combined stylesheet call spans a required insertion point; split it without changing its options, then rerun the installer"
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def insertion_slot(analysis, expected, asset)
|
|
590
|
+
rank = expected.index(asset)
|
|
591
|
+
managed = analysis.stylesheets.select { (_1.assets & MANAGED_STYLESHEETS).any? }
|
|
592
|
+
lower = managed.each_index.filter_map do |index|
|
|
593
|
+
index + 1 if managed[index].assets.any? { expected.index(_1).to_i < rank }
|
|
594
|
+
end.max || 0
|
|
595
|
+
upper = managed.each_index.filter_map do |index|
|
|
596
|
+
index if managed[index].assets.any? { expected.index(_1).to_i > rank }
|
|
597
|
+
end.min || managed.length
|
|
598
|
+
lower if lower <= upper
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def stylesheet_insertion_index(analysis, slot)
|
|
602
|
+
managed = analysis.stylesheets.select { (_1.assets & MANAGED_STYLESHEETS).any? }
|
|
603
|
+
if managed.empty?
|
|
604
|
+
anchor = analysis.stylesheets.last || analysis.bootstraps.first
|
|
605
|
+
anchor ? anchor.range.end + 1 : analysis.head_range.begin + 1
|
|
606
|
+
elsif slot < managed.length
|
|
607
|
+
managed[slot].range.begin
|
|
608
|
+
else
|
|
609
|
+
managed.last.range.end + 1
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def layout_indent(lines, analysis)
|
|
614
|
+
expression = (analysis.stylesheets + analysis.bootstraps).min_by { _1.range.begin }
|
|
615
|
+
expression ? lines[expression.range.begin][/^\s*/] : "#{lines[analysis.head_range.begin][/^\s*/]} "
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def bootstrap_markup(indent)
|
|
619
|
+
if layout_path.extname == ".rb"
|
|
620
|
+
"#{indent}render NitroKit::AppearanceBootstrap.new\n"
|
|
621
|
+
else
|
|
622
|
+
"#{indent}<%= render NitroKit::AppearanceBootstrap.new %>\n"
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def stylesheet_markup(indent, asset)
|
|
627
|
+
if layout_path.extname == ".rb"
|
|
628
|
+
"#{indent}stylesheet_link_tag(#{asset.inspect}, data: { turbo_track: \"reload\" })\n"
|
|
629
|
+
else
|
|
630
|
+
"#{indent}<%= stylesheet_link_tag #{asset.inspect}, \"data-turbo-track\": \"reload\" %>\n"
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def managed_stylesheet_assets(analysis)
|
|
635
|
+
analysis.stylesheets.flat_map(&:assets).select { MANAGED_STYLESHEETS.include?(_1) }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def tailwind?(assets)
|
|
639
|
+
assets.include?("tailwind") || dependency?("tailwindcss-rails") || application_root.join("app/assets/tailwind/application.css").file?
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def application_stylesheet_asset?
|
|
643
|
+
application_root.glob("app/assets/stylesheets/application.*").any?(&:file?) ||
|
|
644
|
+
application_root.join("app/assets/builds/application.css").file?
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def dependency?(name)
|
|
648
|
+
gemfile = application_root.join("Gemfile")
|
|
649
|
+
return false unless gemfile.file?
|
|
650
|
+
|
|
651
|
+
syntax = Ripper.sexp(gemfile.read)
|
|
652
|
+
syntax && invocation_argument_lists(syntax, "gem").any? { string_literal(_1.first) == name }
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def relative(path)
|
|
656
|
+
path.relative_path_from(application_root).to_s
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def clipboard_command
|
|
660
|
+
[ [ "pbcopy" ], [ "wl-copy" ], [ "xclip", "-selection", "clipboard" ], [ "clip" ] ].find do |command|
|
|
661
|
+
executable?(command.first)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def executable?(name)
|
|
666
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
667
|
+
File.executable?(File.join(directory, name))
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
end
|