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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef021f6caeb5f09abf1a92b03780f66dd8a3575a75b37e20e233ae28c303bb15
4
- data.tar.gz: ae2d16ea0c3bb7e706e9b10170e1bec40b00d45a07e7dc71f58a0eb8a3f1f966
3
+ metadata.gz: 77caec2dd9193b287504a5ebb36eb91746f4a4f5f13e37403e0af2ac89f8dcf2
4
+ data.tar.gz: 1de9dc376d3f1c151bd47ff897305f55fc3c13d6409d3202676e394fd7a41e98
5
5
  SHA512:
6
- metadata.gz: 77f6bcf3bb9dc1ddf951c0efa9a2a34daac9d37a792e11e33086632133fbd9bcf55df366d6e59ec2b74fae87d9c57bb2427722c890c2f98d892cf826e79c1a73
7
- data.tar.gz: a67b31ff57cb06cfe123133581138fd34f6c1671ebea4c4cae38597f759b477c154c7ac0eabcd2bcba61b1430f8babf64e852cd439bc64d661545faabb031ec2
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
- # parser.on("-f", "--force") { flags[:force] = true }
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, 'dev', 'fluid_cli/commands/theme/dev'
7
- subcommand :Init, 'init', 'fluid_cli/commands/theme/init'
8
- subcommand :Pull, 'pull', 'fluid_cli/commands/theme/pull'
9
- subcommand :Push, 'push', 'fluid_cli/commands/theme/push'
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.open_url!(address)
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 preview_message
283
- preview_suffix = editor_sync ? "" : "Please download changes"
299
+ def browser_open_message
300
+ "\n\n{{bold: To view your theme in the browser, visit {{green: #{address} }} }}"
301
+ end
284
302
 
285
- "To customize or preview your theme, visit #{theme.editor_url} or #{theme.preview_url}. #{preview_suffix}"
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
@@ -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?("templates/")
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
- end
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
- ["#{parsed_body['error_message']}\n#{errors}"]
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,
@@ -32,7 +32,7 @@ module FluidCLI
32
32
  end
33
33
 
34
34
  def editor_url
35
- "https://admin.fluid.app/admin/themes/#{id}/editor"
35
+ "https://admin.fluid.app/themes/#{id}/editor"
36
36
  end
37
37
 
38
38
  def name
@@ -1,3 +1,3 @@
1
1
  module FluidCLI
2
- VERSION = "0.1.7"
2
+ VERSION = "0.1.9"
3
3
  end
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.7
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-01-09 00:00:00.000000000 Z
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