fluid_cli 0.1.7 → 0.1.9
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/lib/fluid_cli/commands/theme/dev.rb +1 -0
- data/lib/fluid_cli/commands/theme/navigate.rb +57 -0
- data/lib/fluid_cli/commands/theme/push.rb +2 -2
- data/lib/fluid_cli/commands/theme.rb +5 -4
- data/lib/fluid_cli/theme/dev_server.rb +28 -9
- data/lib/fluid_cli/theme/file.rb +26 -1
- data/lib/fluid_cli/theme/navigation/resource_fetcher.rb +25 -0
- data/lib/fluid_cli/theme/navigation/route_navigator.rb +87 -0
- data/lib/fluid_cli/theme/schema_validator.rb +274 -0
- data/lib/fluid_cli/theme/syncer.rb +14 -6
- data/lib/fluid_cli/theme/theme.rb +1 -1
- data/lib/fluid_cli/version.rb +1 -1
- data/lib/fluid_cli.rb +5 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77caec2dd9193b287504a5ebb36eb91746f4a4f5f13e37403e0af2ac89f8dcf2
|
|
4
|
+
data.tar.gz: 1de9dc376d3f1c151bd47ff897305f55fc3c13d6409d3202676e394fd7a41e98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0da7f1602d15d1ba32f8995039a2d4d40cb2292f2546fbf941f8dcedbded6b5b73df918df068045d815f10bbb2a2377d6b2600beeed87b3cf952d7236a7970a6
|
|
7
|
+
data.tar.gz: 46ac939cb3df539f6b447e127487d80aa59c39ac995f17e1f1b858bd3c68e255ec2c28d922c117720c8da520efa77787d3c9b292a5157907f41b821217762282
|
|
@@ -24,6 +24,7 @@ module FluidCLI
|
|
|
24
24
|
parser.on("-t", "--theme=NAME_OR_ID") { |theme| flags[:theme] = theme }
|
|
25
25
|
parser.on("-f", "--force") { flags[:force] = true }
|
|
26
26
|
parser.on("--overwrite-json") { flags[:overwrite_json] = true }
|
|
27
|
+
parser.on("--navigate") { flags[:navigate] = true }
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def call(_args, name)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fluid_cli"
|
|
4
|
+
|
|
5
|
+
module FluidCLI
|
|
6
|
+
module Commands
|
|
7
|
+
class Theme
|
|
8
|
+
class Navigate < FluidCLI::Command
|
|
9
|
+
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
10
|
+
DEFAULT_PORT = 9292
|
|
11
|
+
|
|
12
|
+
options do |parser, flags|
|
|
13
|
+
parser.on("--host=HOST") { |host| flags[:host] = host.to_s }
|
|
14
|
+
parser.on("--port=PORT") { |port| flags[:port] = port.to_i }
|
|
15
|
+
parser.on("-t", "--theme=NAME_OR_ID") { |theme| flags[:theme] = theme }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(_args, _name)
|
|
19
|
+
valid_authentication_method!
|
|
20
|
+
|
|
21
|
+
flags = options.flags.dup
|
|
22
|
+
host = flags[:host] || DEFAULT_HTTP_HOST
|
|
23
|
+
port = flags[:port] || DEFAULT_PORT
|
|
24
|
+
address = "http://#{host}:#{port}"
|
|
25
|
+
|
|
26
|
+
theme_id = resolve_theme_id(flags[:theme])
|
|
27
|
+
|
|
28
|
+
FluidCLI::Theme::Navigation::RouteNavigator.new(@ctx, address, theme_id).navigate
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.help
|
|
32
|
+
"Interactively select a route and resource to navigate to in the theme dev server."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def valid_authentication_method!
|
|
38
|
+
if FluidCLI::Environment.auth_token.nil? && FluidCLI::DB.get(:jwt).nil?
|
|
39
|
+
FluidCLI::Context.abort("Auth token is missing", "Please login using 'fluid login' command.")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_theme_id(theme_identifier)
|
|
44
|
+
if theme_identifier
|
|
45
|
+
theme = FluidCLI::Theme::Theme.find_by_identifier(@ctx, root: ".", identifier: theme_identifier)
|
|
46
|
+
@ctx.abort("Theme not found: #{theme_identifier}") unless theme
|
|
47
|
+
theme.id
|
|
48
|
+
else
|
|
49
|
+
id = FluidCLI::DB.get(:development_theme_id)
|
|
50
|
+
@ctx.abort("No active development theme found. Run 'fluid theme dev' first, or specify a theme with --theme.") unless id
|
|
51
|
+
id
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -22,7 +22,7 @@ module FluidCLI
|
|
|
22
22
|
parser.on("-j", "--json") { flags[:json] = true }
|
|
23
23
|
parser.on("-a", "--allow-live") { flags[:allow_live] = true }
|
|
24
24
|
parser.on("-p", "--publish") { flags[:publish] = true }
|
|
25
|
-
|
|
25
|
+
parser.on("-f", "--force") { flags[:force] = true }
|
|
26
26
|
# parser.on("--development-theme-id=DEVELOPMENT_THEME_ID") do |development_theme_id|
|
|
27
27
|
# flags[:development_theme_id] = development_theme_id.to_i
|
|
28
28
|
# end
|
|
@@ -45,7 +45,7 @@ module FluidCLI
|
|
|
45
45
|
# return unless CLI::UI::Prompt.confirm(question)
|
|
46
46
|
# end
|
|
47
47
|
|
|
48
|
-
syncer = FluidCLI::Theme::Syncer.new(@ctx, theme: theme)
|
|
48
|
+
syncer = FluidCLI::Theme::Syncer.new(@ctx, theme: theme, force: options.flags[:force])
|
|
49
49
|
begin
|
|
50
50
|
syncer.start_threads
|
|
51
51
|
if options.flags[:json]
|
|
@@ -3,10 +3,11 @@ require "fluid_cli"
|
|
|
3
3
|
module FluidCLI
|
|
4
4
|
module Commands
|
|
5
5
|
class Theme < FluidCLI::Command
|
|
6
|
-
subcommand :Dev,
|
|
7
|
-
subcommand :Init,
|
|
8
|
-
subcommand :
|
|
9
|
-
subcommand :
|
|
6
|
+
subcommand :Dev, 'dev', 'fluid_cli/commands/theme/dev'
|
|
7
|
+
subcommand :Init, 'init', 'fluid_cli/commands/theme/init'
|
|
8
|
+
subcommand :Navigate, 'navigate', 'fluid_cli/commands/theme/navigate'
|
|
9
|
+
subcommand :Pull, 'pull', 'fluid_cli/commands/theme/pull'
|
|
10
|
+
subcommand :Push, 'push', 'fluid_cli/commands/theme/push'
|
|
10
11
|
|
|
11
12
|
def call(*args)
|
|
12
13
|
self.class.help(*args)
|
|
@@ -28,8 +28,8 @@ module FluidCLI
|
|
|
28
28
|
class DevServer
|
|
29
29
|
include Singleton
|
|
30
30
|
|
|
31
|
-
attr_reader :app, :stopped, :ctx, :root, :host, :theme_identifier, :port, :poll, :editor_sync, :mode,
|
|
32
|
-
:block, :includes, :ignores
|
|
31
|
+
attr_reader :app, :stopped, :ctx, :root, :host, :theme_identifier, :port, :poll, :editor_sync, :force, :mode,
|
|
32
|
+
:block, :includes, :ignores, :navigate
|
|
33
33
|
|
|
34
34
|
class << self
|
|
35
35
|
def start(
|
|
@@ -40,7 +40,9 @@ module FluidCLI
|
|
|
40
40
|
port: 9292,
|
|
41
41
|
poll: false,
|
|
42
42
|
editor_sync: false,
|
|
43
|
+
force: false,
|
|
43
44
|
overwrite_json: false,
|
|
45
|
+
navigate: false,
|
|
44
46
|
mode: ReloadMode.default,
|
|
45
47
|
includes: nil,
|
|
46
48
|
ignores: nil,
|
|
@@ -54,7 +56,9 @@ module FluidCLI
|
|
|
54
56
|
port,
|
|
55
57
|
poll,
|
|
56
58
|
editor_sync,
|
|
59
|
+
force,
|
|
57
60
|
overwrite_json,
|
|
61
|
+
navigate,
|
|
58
62
|
mode,
|
|
59
63
|
includes,
|
|
60
64
|
ignores,
|
|
@@ -77,7 +81,9 @@ module FluidCLI
|
|
|
77
81
|
port,
|
|
78
82
|
poll,
|
|
79
83
|
editor_sync,
|
|
84
|
+
force,
|
|
80
85
|
overwrite_json,
|
|
86
|
+
navigate,
|
|
81
87
|
mode,
|
|
82
88
|
includes,
|
|
83
89
|
ignores,
|
|
@@ -90,7 +96,9 @@ module FluidCLI
|
|
|
90
96
|
@port = port
|
|
91
97
|
@poll = poll
|
|
92
98
|
@editor_sync = editor_sync
|
|
99
|
+
@force = force
|
|
93
100
|
@overwrite_json = overwrite_json
|
|
101
|
+
@navigate = navigate
|
|
94
102
|
@mode = mode
|
|
95
103
|
@includes = includes
|
|
96
104
|
@ignores = ignores
|
|
@@ -175,8 +183,12 @@ module FluidCLI
|
|
|
175
183
|
return if stopped
|
|
176
184
|
|
|
177
185
|
ctx.puts(serving_theme_message)
|
|
178
|
-
|
|
179
|
-
ctx.
|
|
186
|
+
|
|
187
|
+
ctx.puts(browser_open_message)
|
|
188
|
+
|
|
189
|
+
ctx.puts(preview_message)
|
|
190
|
+
|
|
191
|
+
navigate_to_resource if @navigate
|
|
180
192
|
end
|
|
181
193
|
end
|
|
182
194
|
|
|
@@ -193,7 +205,8 @@ module FluidCLI
|
|
|
193
205
|
@syncer ||= Syncer.new(
|
|
194
206
|
ctx,
|
|
195
207
|
theme: theme,
|
|
196
|
-
overwrite_json: !editor_sync || @overwrite_json
|
|
208
|
+
overwrite_json: !editor_sync || @overwrite_json,
|
|
209
|
+
force: @force
|
|
197
210
|
)
|
|
198
211
|
end
|
|
199
212
|
|
|
@@ -236,6 +249,10 @@ module FluidCLI
|
|
|
236
249
|
@address ||= "http://#{host}:#{port}"
|
|
237
250
|
end
|
|
238
251
|
|
|
252
|
+
def navigate_to_resource
|
|
253
|
+
FluidCLI::Theme::Navigation::RouteNavigator.new(ctx, address, theme.id).navigate
|
|
254
|
+
end
|
|
255
|
+
|
|
239
256
|
# Messages
|
|
240
257
|
|
|
241
258
|
def ensure_user_message
|
|
@@ -264,7 +281,7 @@ module FluidCLI
|
|
|
264
281
|
end
|
|
265
282
|
|
|
266
283
|
def serving_theme_message
|
|
267
|
-
"Serving theme #{theme.id} (#{theme.company})"
|
|
284
|
+
"\n\n{{bold: Serving theme {{blue: #{theme.id} (#{theme.company})}} }}"
|
|
268
285
|
end
|
|
269
286
|
|
|
270
287
|
def stopping_message
|
|
@@ -279,10 +296,12 @@ module FluidCLI
|
|
|
279
296
|
"Theme not found: #{theme_identifier}"
|
|
280
297
|
end
|
|
281
298
|
|
|
282
|
-
def
|
|
283
|
-
|
|
299
|
+
def browser_open_message
|
|
300
|
+
"\n\n{{bold: To view your theme in the browser, visit {{green: #{address} }} }}"
|
|
301
|
+
end
|
|
284
302
|
|
|
285
|
-
|
|
303
|
+
def preview_message
|
|
304
|
+
"\n\n{{bold: To customize your theme on web editor, visit {{green: #{theme.editor_url} }} }}"
|
|
286
305
|
end
|
|
287
306
|
end
|
|
288
307
|
end
|
data/lib/fluid_cli/theme/file.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative "mime_type"
|
|
3
|
+
require_relative "schema_validator"
|
|
3
4
|
|
|
4
5
|
module FluidCLI
|
|
5
6
|
module Theme
|
|
@@ -7,6 +8,11 @@ module FluidCLI
|
|
|
7
8
|
attr_accessor :remote_checksum
|
|
8
9
|
attr_writer :warnings
|
|
9
10
|
|
|
11
|
+
TEMPLATE_TYPES = %w[
|
|
12
|
+
product medium enrollment_pack shop_page library category_page collection_page cart_page
|
|
13
|
+
home_page join_page page collection post category post_page navbar footer
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
10
16
|
def initialize(path, root)
|
|
11
17
|
super(Pathname.new(path))
|
|
12
18
|
|
|
@@ -71,7 +77,7 @@ module FluidCLI
|
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
def template?
|
|
74
|
-
relative_path.start_with?(
|
|
80
|
+
TEMPLATE_TYPES.any? { |type| relative_path.start_with?(type) }
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
def checksum
|
|
@@ -100,6 +106,25 @@ module FluidCLI
|
|
|
100
106
|
def warnings
|
|
101
107
|
@warnings || []
|
|
102
108
|
end
|
|
109
|
+
|
|
110
|
+
def validate_schema
|
|
111
|
+
return [] if !liquid?
|
|
112
|
+
|
|
113
|
+
FluidCLI::Theme::SchemaValidator
|
|
114
|
+
.new(self, blocks_schema_type: template? ? "object" : "array")
|
|
115
|
+
.validate
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_schema_and_raise
|
|
119
|
+
diags = validate_schema
|
|
120
|
+
return if diags.empty?
|
|
121
|
+
|
|
122
|
+
message = "Schema validation errors in #{relative_path}:\n"
|
|
123
|
+
diags.each do |diag|
|
|
124
|
+
message += "- [#{diag[:severity]}] #{diag[:message]}\n"
|
|
125
|
+
end
|
|
126
|
+
raise FluidCLI::Theme::SchemaValidationError, message
|
|
127
|
+
end
|
|
103
128
|
end
|
|
104
129
|
end
|
|
105
130
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluidCLI
|
|
4
|
+
module Theme
|
|
5
|
+
module Navigation
|
|
6
|
+
class ResourceFetcher
|
|
7
|
+
def initialize(ctx, theme_id)
|
|
8
|
+
@ctx = ctx
|
|
9
|
+
@theme_id = theme_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch(themeable_type)
|
|
13
|
+
_status, body = FluidCLI::API.new(@ctx).get(
|
|
14
|
+
path: "application_themes/#{@theme_id}/available_themeables",
|
|
15
|
+
query: URI.encode_www_form(themeable: themeable_type, per_page: 50),
|
|
16
|
+
)
|
|
17
|
+
body["available_themeables"] || []
|
|
18
|
+
rescue FluidCLI::API::APIRequestError => e
|
|
19
|
+
@ctx.debug("Failed to fetch #{themeable_type} themeables: #{e.message}")
|
|
20
|
+
[]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluidCLI
|
|
4
|
+
module Theme
|
|
5
|
+
module Navigation
|
|
6
|
+
class RouteNavigator
|
|
7
|
+
STATIC_ROUTES = [
|
|
8
|
+
{ label: "Home", path: "/home" },
|
|
9
|
+
{ label: "Shop", path: "/home/shop" },
|
|
10
|
+
{ label: "Join / Sign Up", path: "/home/join" },
|
|
11
|
+
{ label: "Cart", path: "/cart" },
|
|
12
|
+
{ label: "Blog", path: "/home/blog" },
|
|
13
|
+
{ label: "Categories (all)", path: "/home/categories" },
|
|
14
|
+
{ label: "Collections (all)", path: "/home/collections" },
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
RESOURCE_ROUTES = [
|
|
18
|
+
{ label: "Category", themeable_type: "category", path_template: "/home/categories/%<id>s", fallback_path: "/home/categories" },
|
|
19
|
+
{ label: "Collection", themeable_type: "collection", path_template: "/home/collections/%<id>s", fallback_path: "/home/collections" },
|
|
20
|
+
{ label: "Product", themeable_type: "product", path_template: "/home/products/%<id>s", fallback_path: "/home/shop" },
|
|
21
|
+
{ label: "Library", themeable_type: "library", path_template: "/home/libraries/%<id>s", fallback_path: "/home/libraries" },
|
|
22
|
+
{ label: "Post", themeable_type: "post", path_template: "/home/posts/%<id>s", fallback_path: "/home/blog" },
|
|
23
|
+
{ label: "Media", themeable_type: "medium", path_template: "/home/media/%<id>s", fallback_path: "/home/media" },
|
|
24
|
+
{ label: "Enrollment Pack", themeable_type: "enrollment_pack", path_template: "/home/enrollments/%<id>s", fallback_path: "/home/join" },
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(ctx, address, theme_id)
|
|
28
|
+
@ctx = ctx
|
|
29
|
+
@address = address
|
|
30
|
+
@theme_id = theme_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def navigate
|
|
34
|
+
path = select_destination
|
|
35
|
+
return unless path
|
|
36
|
+
|
|
37
|
+
url = "#{@address}#{path}"
|
|
38
|
+
@ctx.puts("\n{{bold:Navigate to: {{green:#{url}}}}}\n")
|
|
39
|
+
open_browser(url)
|
|
40
|
+
rescue => e
|
|
41
|
+
@ctx.debug("Navigation error: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def select_destination
|
|
47
|
+
all_options = STATIC_ROUTES.map { |r| [:static, r] } + RESOURCE_ROUTES.map { |r| [:resource, r] }
|
|
48
|
+
type, data = CLI::UI::Prompt.ask("Select a route to navigate to") do |handler|
|
|
49
|
+
all_options.each { |t, r| handler.option(option_label(t, r)) { [t, r] } }
|
|
50
|
+
end
|
|
51
|
+
type == :static ? data[:path] : resolve_resource_path(data)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def option_label(type, data)
|
|
55
|
+
type == :static ? data[:label] : "#{data[:label]} (select specific)"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_resource_path(route)
|
|
59
|
+
resources = ResourceFetcher.new(@ctx, @theme_id).fetch(route[:themeable_type])
|
|
60
|
+
|
|
61
|
+
if resources.empty?
|
|
62
|
+
@ctx.puts("{{yellow:No #{route[:label].downcase} resources found, navigating to listing page.}}")
|
|
63
|
+
return route[:fallback_path]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
resource = CLI::UI::Prompt.ask("Select a #{route[:label].downcase}") do |handler|
|
|
67
|
+
resources.each { |r| handler.option(display_name(r)) { r } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
format(route[:path_template], id: resource["slug"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def display_name(resource)
|
|
74
|
+
resource["title"] || resource["id"].to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def open_browser(url)
|
|
78
|
+
case RbConfig::CONFIG["host_os"]
|
|
79
|
+
when /darwin/ then system("open", url)
|
|
80
|
+
when /linux/ then system("xdg-open", url)
|
|
81
|
+
when /mswin|mingw|cygwin/ then system("start", "", url)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FluidCLI
|
|
6
|
+
module Theme
|
|
7
|
+
|
|
8
|
+
class SchemaValidator
|
|
9
|
+
Diagnostic = Struct.new(:severity, :message, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
VALID_SETTING_TYPES = %w[
|
|
12
|
+
text rich_text richtext textarea range select checkbox color text_alignment
|
|
13
|
+
color_background radio url image image_picker video_picker font_picker html
|
|
14
|
+
html_textarea link_list header blog posts posts_list products products_list
|
|
15
|
+
product product_list forms collections collections_list collection
|
|
16
|
+
collection_list categories categories_list category category_list enrollments
|
|
17
|
+
enrollments_list enrollment_pack enrollment enrollment_list
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(file, blocks_schema_type: "unknown")
|
|
21
|
+
@file = file
|
|
22
|
+
@blocks_schema_type = blocks_schema_type
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate
|
|
26
|
+
text = @file.read.to_s
|
|
27
|
+
diagnostics = []
|
|
28
|
+
|
|
29
|
+
match = text.match(/{% schema %}([\s\S]*?){% endschema %}/)
|
|
30
|
+
|
|
31
|
+
return diagnostics.map(&:to_h) unless match
|
|
32
|
+
|
|
33
|
+
json_text = match[1] || ""
|
|
34
|
+
|
|
35
|
+
schema = nil
|
|
36
|
+
begin
|
|
37
|
+
schema = JSON.parse(json_text)
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
diagnostics << Diagnostic.new(severity: "error", message: "Invalid JSON:\n #{e.message}")
|
|
40
|
+
return diagnostics.map(&:to_h)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
scan = JsonTokenScan.scan(json_text)
|
|
44
|
+
|
|
45
|
+
scan.duplicate_blocks_key_tokens.each do
|
|
46
|
+
diagnostics << Diagnostic.new(
|
|
47
|
+
severity: "error",
|
|
48
|
+
message: "Error in blocks: duplicate 'blocks' key in the same object"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if schema.is_a?(Hash) && schema["settings"].is_a?(Array)
|
|
53
|
+
diagnostics.concat(validate_settings(schema["settings"]))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if @blocks_schema_type == "object" && !schema["blocks"].is_a?(Hash)
|
|
57
|
+
scan.blocks_array_value_tokens.each do
|
|
58
|
+
diagnostics << Diagnostic.new(
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: "Error in blocks: expected an object ({})"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if schema.is_a?(Hash) && schema.key?("blocks")
|
|
66
|
+
blocks = schema["blocks"]
|
|
67
|
+
|
|
68
|
+
case @blocks_schema_type
|
|
69
|
+
when "array"
|
|
70
|
+
if !blocks.is_a?(Array)
|
|
71
|
+
diagnostics << Diagnostic.new(
|
|
72
|
+
severity: "error",
|
|
73
|
+
message: "Error in blocks: expected an array ([])"
|
|
74
|
+
)
|
|
75
|
+
else
|
|
76
|
+
diagnostics.concat(validate_blocks(blocks))
|
|
77
|
+
end
|
|
78
|
+
when "object"
|
|
79
|
+
unless blocks.is_a?(Hash)
|
|
80
|
+
diagnostics << Diagnostic.new(
|
|
81
|
+
severity: "error",
|
|
82
|
+
message: "Error in blocks: expected an object ({})"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
else # "unknown"
|
|
86
|
+
diagnostics.concat(validate_blocks(blocks)) if blocks.is_a?(Array)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
diagnostics.map(&:to_h)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def validate_settings(settings)
|
|
96
|
+
diagnostics = []
|
|
97
|
+
ids = {}
|
|
98
|
+
|
|
99
|
+
settings.each_with_index do |setting, index|
|
|
100
|
+
setting ||= {}
|
|
101
|
+
setting = {} unless setting.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
id = setting["id"]
|
|
104
|
+
type = setting["type"]
|
|
105
|
+
|
|
106
|
+
if id.is_a?(String) && id.strip.empty?
|
|
107
|
+
diagnostics << Diagnostic.new(
|
|
108
|
+
severity: "error",
|
|
109
|
+
message: "Error in settings: id cannot be empty"
|
|
110
|
+
)
|
|
111
|
+
elsif id && ids.key?(id)
|
|
112
|
+
diagnostics << Diagnostic.new(
|
|
113
|
+
severity: "error",
|
|
114
|
+
message: "Error in settings: duplicate id '#{id}' found"
|
|
115
|
+
)
|
|
116
|
+
elsif id
|
|
117
|
+
ids[id] = true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if !type
|
|
121
|
+
diagnostics << Diagnostic.new(
|
|
122
|
+
severity: "error",
|
|
123
|
+
message: "Error in setting '#{id || index}': missing required field 'type'"
|
|
124
|
+
)
|
|
125
|
+
elsif !VALID_SETTING_TYPES.include?(type)
|
|
126
|
+
diagnostics << Diagnostic.new(
|
|
127
|
+
severity: "error",
|
|
128
|
+
message: "Invalid settings type: '#{type}'"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
diagnostics
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_blocks(blocks)
|
|
137
|
+
diagnostics = []
|
|
138
|
+
types = {}
|
|
139
|
+
|
|
140
|
+
blocks.each_with_index do |block, index|
|
|
141
|
+
block ||= {}
|
|
142
|
+
block = {} unless block.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
type = block["type"]
|
|
145
|
+
name = block["name"]
|
|
146
|
+
|
|
147
|
+
if !type
|
|
148
|
+
diagnostics << Diagnostic.new(
|
|
149
|
+
severity: "error",
|
|
150
|
+
message: "Error in blocks at index #{index}: missing required field 'type'"
|
|
151
|
+
)
|
|
152
|
+
elsif types.key?(type)
|
|
153
|
+
diagnostics << Diagnostic.new(
|
|
154
|
+
severity: "warning",
|
|
155
|
+
message: "Warning in blocks: duplicate type '#{type}' found"
|
|
156
|
+
)
|
|
157
|
+
else
|
|
158
|
+
types[type] = true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if !name
|
|
162
|
+
diagnostics << Diagnostic.new(
|
|
163
|
+
severity: "error",
|
|
164
|
+
message: "Error in block '#{type || index}': missing required field 'name'"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if block["settings"].is_a?(Array)
|
|
169
|
+
diagnostics.concat(validate_settings(block["settings"]))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
diagnostics
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Minimal token scanner:
|
|
177
|
+
# - detects duplicate "blocks" keys inside the same object
|
|
178
|
+
# - detects when "blocks" value starts with an array ([)
|
|
179
|
+
class JsonTokenScan
|
|
180
|
+
Token = Struct.new(:value)
|
|
181
|
+
|
|
182
|
+
attr_reader :duplicate_blocks_key_tokens, :blocks_array_value_tokens
|
|
183
|
+
|
|
184
|
+
def initialize
|
|
185
|
+
@duplicate_blocks_key_tokens = []
|
|
186
|
+
@blocks_array_value_tokens = []
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.scan(json_text)
|
|
190
|
+
new.tap { |s| s.scan!(json_text) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def scan!(s)
|
|
194
|
+
stack = []
|
|
195
|
+
pending_key = nil
|
|
196
|
+
i = 0
|
|
197
|
+
|
|
198
|
+
while i < s.length
|
|
199
|
+
ch = s.getbyte(i)
|
|
200
|
+
|
|
201
|
+
if whitespace?(ch)
|
|
202
|
+
i += 1
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
case ch
|
|
207
|
+
when 0x7B # {
|
|
208
|
+
stack << { type: :object, keys: {}, expecting_key: true }
|
|
209
|
+
pending_key = nil
|
|
210
|
+
i += 1
|
|
211
|
+
when 0x7D # }
|
|
212
|
+
stack.pop
|
|
213
|
+
pending_key = nil
|
|
214
|
+
i += 1
|
|
215
|
+
when 0x5B # [
|
|
216
|
+
if pending_key == "blocks"
|
|
217
|
+
@blocks_array_value_tokens << Token.new("blocks-array")
|
|
218
|
+
end
|
|
219
|
+
pending_key = nil
|
|
220
|
+
stack << { type: :array }
|
|
221
|
+
i += 1
|
|
222
|
+
when 0x5D # ]
|
|
223
|
+
stack.pop
|
|
224
|
+
pending_key = nil
|
|
225
|
+
i += 1
|
|
226
|
+
when 0x3A # :
|
|
227
|
+
# after colon, we are reading a value next
|
|
228
|
+
i += 1
|
|
229
|
+
when 0x2C # ,
|
|
230
|
+
if stack.last&.dig(:type) == :object
|
|
231
|
+
stack.last[:expecting_key] = true
|
|
232
|
+
end
|
|
233
|
+
pending_key = nil
|
|
234
|
+
i += 1
|
|
235
|
+
when 0x22 # "
|
|
236
|
+
str, i = read_string(s, i)
|
|
237
|
+
|
|
238
|
+
if stack.last&.dig(:type) == :object && stack.last[:expecting_key]
|
|
239
|
+
# this string is a key
|
|
240
|
+
if str == "blocks" && stack.last[:keys][str]
|
|
241
|
+
@duplicate_blocks_key_tokens << Token.new(str)
|
|
242
|
+
end
|
|
243
|
+
stack.last[:keys][str] = true
|
|
244
|
+
stack.last[:expecting_key] = false
|
|
245
|
+
pending_key = str
|
|
246
|
+
else
|
|
247
|
+
pending_key = nil
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
# literals / numbers / etc.
|
|
251
|
+
pending_key = nil
|
|
252
|
+
i += 1
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def read_string(s, start)
|
|
258
|
+
i = start + 1
|
|
259
|
+
while i < s.length
|
|
260
|
+
break if s.getbyte(i) == 0x22 && s.getbyte(i - 1) != 0x5C
|
|
261
|
+
i += 1
|
|
262
|
+
end
|
|
263
|
+
[s[start + 1...i], i + 1]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def whitespace?(ch)
|
|
267
|
+
ch == 0x20 || ch == 0x0A || ch == 0x0D || ch == 0x09
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
class SchemaValidationError < StandardError; end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -31,10 +31,11 @@ module FluidCLI
|
|
|
31
31
|
|
|
32
32
|
def_delegators :@error_reporter, :has_any_error?
|
|
33
33
|
|
|
34
|
-
def initialize(ctx, theme:, overwrite_json: true)
|
|
34
|
+
def initialize(ctx, theme:, overwrite_json: true, force: false)
|
|
35
35
|
@ctx = ctx
|
|
36
36
|
@theme = theme
|
|
37
37
|
@overwrite_json = overwrite_json
|
|
38
|
+
@force = force
|
|
38
39
|
@error_reporter = ErrorReporter.new(ctx)
|
|
39
40
|
@standard_reporter = StandardReporter.new(ctx)
|
|
40
41
|
@reporters = [@error_reporter, @standard_reporter]
|
|
@@ -230,12 +231,15 @@ module FluidCLI
|
|
|
230
231
|
else
|
|
231
232
|
JSON.parse(response&.body)
|
|
232
233
|
end
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
errors = parsed_body.dig("errors") # either nil or another type
|
|
236
|
-
errors = errors.dig("asset") if errors&.is_a?(Hash)
|
|
234
|
+
errors = parsed_body.dig("errors") # either nil or another type
|
|
235
|
+
errors = errors.dig("asset") if errors&.is_a?(Hash)
|
|
237
236
|
|
|
238
|
-
|
|
237
|
+
["#{parsed_body['error_message']}\n#{errors}"]
|
|
238
|
+
elsif exception.respond_to?(:message)
|
|
239
|
+
[exception.message]
|
|
240
|
+
else
|
|
241
|
+
["An unknown error occurred while syncing the asset #{file}"]
|
|
242
|
+
end
|
|
239
243
|
rescue JSON::ParserError
|
|
240
244
|
[exception.message]
|
|
241
245
|
rescue StandardError => e
|
|
@@ -310,6 +314,10 @@ module FluidCLI
|
|
|
310
314
|
path = "application_themes/#{@theme.id}/resources"
|
|
311
315
|
|
|
312
316
|
if file.text?
|
|
317
|
+
if file.liquid? && !@force
|
|
318
|
+
file.validate_schema_and_raise
|
|
319
|
+
end
|
|
320
|
+
|
|
313
321
|
application_theme_resource[:content] = file.read
|
|
314
322
|
_status, body, response = api_client.put(
|
|
315
323
|
path: path,
|
data/lib/fluid_cli/version.rb
CHANGED
data/lib/fluid_cli.rb
CHANGED
|
@@ -32,6 +32,11 @@ module FluidCLI
|
|
|
32
32
|
module UI
|
|
33
33
|
autoload(:SyncProgressBar, 'fluid_cli/theme/ui/sync_progress_bar')
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
module Navigation
|
|
37
|
+
autoload(:RouteNavigator, 'fluid_cli/theme/navigation/route_navigator')
|
|
38
|
+
autoload(:ResourceFetcher, 'fluid_cli/theme/navigation/resource_fetcher')
|
|
39
|
+
end
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def self.cache_dir
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fluid_cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Fluid
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-02-24 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: bugsnag
|
|
@@ -105,6 +105,7 @@ files:
|
|
|
105
105
|
- lib/fluid_cli/commands/theme/dev.rb
|
|
106
106
|
- lib/fluid_cli/commands/theme/help.rb
|
|
107
107
|
- lib/fluid_cli/commands/theme/init.rb
|
|
108
|
+
- lib/fluid_cli/commands/theme/navigate.rb
|
|
108
109
|
- lib/fluid_cli/commands/theme/pull.rb
|
|
109
110
|
- lib/fluid_cli/commands/theme/push.rb
|
|
110
111
|
- lib/fluid_cli/commands/whoami.rb
|
|
@@ -143,9 +144,12 @@ files:
|
|
|
143
144
|
- lib/fluid_cli/theme/file.rb
|
|
144
145
|
- lib/fluid_cli/theme/forms/select.rb
|
|
145
146
|
- lib/fluid_cli/theme/mime_type.rb
|
|
147
|
+
- lib/fluid_cli/theme/navigation/resource_fetcher.rb
|
|
148
|
+
- lib/fluid_cli/theme/navigation/route_navigator.rb
|
|
146
149
|
- lib/fluid_cli/theme/presenters/theme_presenter.rb
|
|
147
150
|
- lib/fluid_cli/theme/presenters/themes_presenter.rb
|
|
148
151
|
- lib/fluid_cli/theme/root.rb
|
|
152
|
+
- lib/fluid_cli/theme/schema_validator.rb
|
|
149
153
|
- lib/fluid_cli/theme/syncer.rb
|
|
150
154
|
- lib/fluid_cli/theme/syncer/checksums.rb
|
|
151
155
|
- lib/fluid_cli/theme/syncer/downloader.rb
|