fluid_cli 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +185 -1
- 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/file_system_listener.rb +5 -2
- data/lib/fluid_cli/theme/dev_server/watcher.rb +7 -1
- data/lib/fluid_cli/theme/dev_server.rb +19 -4
- data/lib/fluid_cli/theme/file.rb +26 -1
- data/lib/fluid_cli/theme/fluid_ignore.rb +93 -0
- 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/root.rb +6 -0
- data/lib/fluid_cli/theme/schema_validator.rb +274 -0
- data/lib/fluid_cli/theme/syncer.rb +14 -6
- data/lib/fluid_cli/version.rb +1 -1
- data/lib/fluid_cli.rb +5 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84ff5fea48f57756d085af8e58148aec8b8b98161b152f5c7e95252f51e2348b
|
|
4
|
+
data.tar.gz: 0b65b1eccfcf8e095961e4518eb9c5e73cb4b6884b029f12074bfe477b1dc40a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a951d7bfafd5ce072e8b2b8e91f308878021a13697848f0eb6fa294f09e5e0d2118a02339530914fa3dcd0647b2c7e7f29bf36bc4d2f89adcfebaf3e3b225937
|
|
7
|
+
data.tar.gz: 1031eb13ffbfb6025e7adbea17dca9bc76d7ab220840e3a8e5782422ba2edba49ee8dc8ccbc7a631e244e04aca5d62718db9f1d85043ec4ad5770008c00e2533
|
data/README.md
CHANGED
|
@@ -1 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Fluid CLI
|
|
2
|
+
|
|
3
|
+
The official command-line interface for the [Fluid](https://fluid.app) e-commerce platform. Build, preview, and deploy themes for your Fluid storefront directly from the terminal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Prerequisites
|
|
8
|
+
|
|
9
|
+
- Ruby >= 3.1.0
|
|
10
|
+
- Bundler
|
|
11
|
+
|
|
12
|
+
### From Source
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
git clone https://github.com/fluid-commerce/fluid-cli.git
|
|
16
|
+
cd fluid-cli
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### As a Gem
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
gem install fluid_cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
# Authenticate with your Fluid account
|
|
30
|
+
fluid login
|
|
31
|
+
|
|
32
|
+
# Initialize a new theme from the base template
|
|
33
|
+
fluid theme init
|
|
34
|
+
|
|
35
|
+
# Start the local development server
|
|
36
|
+
fluid theme dev
|
|
37
|
+
|
|
38
|
+
# Push your theme to Fluid
|
|
39
|
+
fluid theme push
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
Fluid CLI uses OAuth2 for authentication. Running `fluid login` will open your browser and redirect you to the Fluid authentication page. Once authenticated, your session is stored locally.
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
# Log in to your Fluid account
|
|
48
|
+
fluid login
|
|
49
|
+
|
|
50
|
+
# Log in to a specific company
|
|
51
|
+
fluid login --company=my-store
|
|
52
|
+
|
|
53
|
+
# Check who you're logged in as
|
|
54
|
+
fluid whoami
|
|
55
|
+
|
|
56
|
+
# Switch between companies
|
|
57
|
+
fluid switch --company=another-store
|
|
58
|
+
|
|
59
|
+
# Log out
|
|
60
|
+
fluid logout
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You can also authenticate via environment variables:
|
|
64
|
+
|
|
65
|
+
| Variable | Description |
|
|
66
|
+
|----------|-------------|
|
|
67
|
+
| `FLUID_AUTH_TOKEN` | Bearer token for API authentication |
|
|
68
|
+
| `FLUID_COMPANY` | Default company subdomain |
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
### `fluid login`
|
|
73
|
+
|
|
74
|
+
Authenticate with your Fluid account via the browser.
|
|
75
|
+
|
|
76
|
+
| Flag | Description |
|
|
77
|
+
|------|-------------|
|
|
78
|
+
| `-c, --company=COMPANY` | Specify the company to log in to |
|
|
79
|
+
|
|
80
|
+
### `fluid logout`
|
|
81
|
+
|
|
82
|
+
Clear stored authentication tokens.
|
|
83
|
+
|
|
84
|
+
### `fluid whoami`
|
|
85
|
+
|
|
86
|
+
Display the currently authenticated company.
|
|
87
|
+
|
|
88
|
+
### `fluid switch`
|
|
89
|
+
|
|
90
|
+
Switch between companies on your account.
|
|
91
|
+
|
|
92
|
+
| Flag | Description |
|
|
93
|
+
|------|-------------|
|
|
94
|
+
| `-c, --company=COMPANY` | Specify the company to switch to |
|
|
95
|
+
|
|
96
|
+
### `fluid theme init`
|
|
97
|
+
|
|
98
|
+
Initialize a new theme by cloning the base template into your current directory.
|
|
99
|
+
|
|
100
|
+
| Flag | Description |
|
|
101
|
+
|------|-------------|
|
|
102
|
+
| `-u, --clone-url=URL` | Custom Git URL to clone from |
|
|
103
|
+
|
|
104
|
+
### `fluid theme dev`
|
|
105
|
+
|
|
106
|
+
Start a local development server with hot reload. The dev server proxies requests to your Fluid storefront and injects local theme files, enabling a fast development workflow.
|
|
107
|
+
|
|
108
|
+
| Flag | Description |
|
|
109
|
+
|------|-------------|
|
|
110
|
+
| `--host=HOST` | Host to bind the dev server to |
|
|
111
|
+
| `--port=PORT` | Port to bind the dev server to |
|
|
112
|
+
| `--poll` | Use polling instead of filesystem events for file watching |
|
|
113
|
+
| `--live-reload=MODE` | Reload mode: `full-page` or `hot-reload` |
|
|
114
|
+
| `-t, --theme=THEME_ID` | Use a specific theme |
|
|
115
|
+
| `-f, --force` | Force create a new development theme |
|
|
116
|
+
| `--overwrite-json` | Overwrite remote JSON files with local versions |
|
|
117
|
+
| `--navigate` | Open the interactive route/resource navigator |
|
|
118
|
+
|
|
119
|
+
### `fluid theme push`
|
|
120
|
+
|
|
121
|
+
Upload your local theme files to Fluid.
|
|
122
|
+
|
|
123
|
+
| Flag | Description |
|
|
124
|
+
|------|-------------|
|
|
125
|
+
| `-n, --nodelete` | Don't delete remote files that are missing locally |
|
|
126
|
+
| `-t, --theme=THEME_ID` | Push to a specific theme |
|
|
127
|
+
| `-u, --unpublished` | Push to a new unpublished theme |
|
|
128
|
+
| `-j, --json` | Include JSON settings files |
|
|
129
|
+
| `-a, --allow-live` | Allow pushing to the live theme |
|
|
130
|
+
| `-p, --publish` | Publish the theme after pushing |
|
|
131
|
+
| `-f, --force` | Skip confirmation prompts |
|
|
132
|
+
|
|
133
|
+
### `fluid theme pull`
|
|
134
|
+
|
|
135
|
+
Download theme files from Fluid to your local directory.
|
|
136
|
+
|
|
137
|
+
| Flag | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `-t, --theme=THEME_ID` | Pull from a specific theme |
|
|
140
|
+
|
|
141
|
+
### `fluid theme navigate`
|
|
142
|
+
|
|
143
|
+
Launch an interactive navigator to browse routes and resources for your storefront.
|
|
144
|
+
|
|
145
|
+
| Flag | Description |
|
|
146
|
+
|------|-------------|
|
|
147
|
+
| `--host=HOST` | Host for the navigator server |
|
|
148
|
+
| `--port=PORT` | Port for the navigator server |
|
|
149
|
+
| `-t, --theme=THEME_ID` | Theme to navigate |
|
|
150
|
+
|
|
151
|
+
## `.fluidignore`
|
|
152
|
+
|
|
153
|
+
You can create a `.fluidignore` file in your theme's root directory to exclude files and directories from all theme operations (dev, push, pull, and file watching). The syntax is identical to `.gitignore`:
|
|
154
|
+
|
|
155
|
+
```gitignore
|
|
156
|
+
# Ignore node_modules
|
|
157
|
+
node_modules/
|
|
158
|
+
|
|
159
|
+
# Ignore all log files
|
|
160
|
+
*.log
|
|
161
|
+
|
|
162
|
+
# Ignore a specific directory
|
|
163
|
+
tmp/
|
|
164
|
+
|
|
165
|
+
# Negate a pattern (re-include a previously ignored file)
|
|
166
|
+
!important.log
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
After cloning the repository:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
bundle install
|
|
175
|
+
|
|
176
|
+
# Run the CLI locally
|
|
177
|
+
bundle exec fluid
|
|
178
|
+
|
|
179
|
+
# Run tests
|
|
180
|
+
bundle exec rake test
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
This project is available under the [MIT License](LICENSE.txt).
|
|
@@ -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)
|
|
@@ -6,11 +6,14 @@ module FluidCLI
|
|
|
6
6
|
class FileSystemListener
|
|
7
7
|
include Observable
|
|
8
8
|
|
|
9
|
-
def initialize(root:, force_poll:)
|
|
9
|
+
def initialize(root:, force_poll:, ignore_pattern: nil)
|
|
10
10
|
@root = root
|
|
11
11
|
@force_poll = force_poll
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
listen_opts = { force_polling: @force_poll }
|
|
14
|
+
listen_opts[:ignore] = ignore_pattern if ignore_pattern
|
|
15
|
+
|
|
16
|
+
@listener = Listen.to(@root, **listen_opts) do |updated, added, removed|
|
|
14
17
|
changed
|
|
15
18
|
notify_observers(updated, added, removed)
|
|
16
19
|
end
|
|
@@ -15,7 +15,13 @@ module FluidCLI
|
|
|
15
15
|
@ctx = ctx
|
|
16
16
|
@theme = theme
|
|
17
17
|
@syncer = syncer
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
ignore_pattern = @theme.fluid_ignore.to_listen_regexp
|
|
20
|
+
@listener = FileSystemListener.new(
|
|
21
|
+
root: @theme.root,
|
|
22
|
+
force_poll: poll,
|
|
23
|
+
ignore_pattern: ignore_pattern
|
|
24
|
+
)
|
|
19
25
|
|
|
20
26
|
add_observer(self, :upload_files_when_changed)
|
|
21
27
|
end
|
|
@@ -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,10 +183,12 @@ module FluidCLI
|
|
|
175
183
|
return if stopped
|
|
176
184
|
|
|
177
185
|
ctx.puts(serving_theme_message)
|
|
178
|
-
|
|
186
|
+
|
|
179
187
|
ctx.puts(browser_open_message)
|
|
180
188
|
|
|
181
189
|
ctx.puts(preview_message)
|
|
190
|
+
|
|
191
|
+
navigate_to_resource if @navigate
|
|
182
192
|
end
|
|
183
193
|
end
|
|
184
194
|
|
|
@@ -195,7 +205,8 @@ module FluidCLI
|
|
|
195
205
|
@syncer ||= Syncer.new(
|
|
196
206
|
ctx,
|
|
197
207
|
theme: theme,
|
|
198
|
-
overwrite_json: !editor_sync || @overwrite_json
|
|
208
|
+
overwrite_json: !editor_sync || @overwrite_json,
|
|
209
|
+
force: @force
|
|
199
210
|
)
|
|
200
211
|
end
|
|
201
212
|
|
|
@@ -238,6 +249,10 @@ module FluidCLI
|
|
|
238
249
|
@address ||= "http://#{host}:#{port}"
|
|
239
250
|
end
|
|
240
251
|
|
|
252
|
+
def navigate_to_resource
|
|
253
|
+
FluidCLI::Theme::Navigation::RouteNavigator.new(ctx, address, theme.id).navigate
|
|
254
|
+
end
|
|
255
|
+
|
|
241
256
|
# Messages
|
|
242
257
|
|
|
243
258
|
def ensure_user_message
|
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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluidCLI
|
|
4
|
+
module Theme
|
|
5
|
+
class FluidIgnore
|
|
6
|
+
IGNORE_FILE = ".fluidignore"
|
|
7
|
+
|
|
8
|
+
attr_reader :patterns
|
|
9
|
+
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@root = root
|
|
12
|
+
@patterns = parse(ignore_file_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns true if the given relative path should be ignored.
|
|
16
|
+
# Last matching pattern wins (supports negation with !).
|
|
17
|
+
def ignore?(relative_path)
|
|
18
|
+
relative_path = relative_path.to_s
|
|
19
|
+
result = false
|
|
20
|
+
@patterns.each do |negated, pattern|
|
|
21
|
+
if match?(pattern, relative_path)
|
|
22
|
+
result = !negated
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns a Regexp suitable for Listen.to(ignore: ...).
|
|
29
|
+
# Returns nil if there are no positive patterns.
|
|
30
|
+
def to_listen_regexp
|
|
31
|
+
positive_patterns = @patterns.reject(&:first).map(&:last)
|
|
32
|
+
return nil if positive_patterns.empty?
|
|
33
|
+
|
|
34
|
+
parts = positive_patterns.map { |pat| fnmatch_to_regex(pat) }
|
|
35
|
+
Regexp.new(parts.join("|"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def any?
|
|
39
|
+
@patterns.any?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def ignore_file_path
|
|
45
|
+
::File.join(@root.to_s, IGNORE_FILE)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(path)
|
|
49
|
+
return [] unless ::File.exist?(path)
|
|
50
|
+
|
|
51
|
+
::File.readlines(path, chomp: true).filter_map do |line|
|
|
52
|
+
line = line.strip
|
|
53
|
+
next if line.empty? || line.start_with?("#")
|
|
54
|
+
|
|
55
|
+
negated = line.start_with?("!")
|
|
56
|
+
line = line[1..] if negated
|
|
57
|
+
|
|
58
|
+
# Leading slash means relative to root — strip it for fnmatch
|
|
59
|
+
line = line[1..] if line.start_with?("/")
|
|
60
|
+
|
|
61
|
+
[negated, line]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def match?(pattern, path)
|
|
66
|
+
if pattern.end_with?("/")
|
|
67
|
+
# Directory pattern — match anything under this directory
|
|
68
|
+
path.start_with?(pattern) || path == pattern.chomp("/")
|
|
69
|
+
elsif pattern.include?("/")
|
|
70
|
+
# Path pattern — match against the full relative path
|
|
71
|
+
::File.fnmatch(pattern, path, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH)
|
|
72
|
+
else
|
|
73
|
+
# Basename pattern — match against both full path and basename
|
|
74
|
+
::File.fnmatch(pattern, path, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH) ||
|
|
75
|
+
::File.fnmatch(pattern, ::File.basename(path), ::File::FNM_DOTMATCH)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fnmatch_to_regex(pattern)
|
|
80
|
+
if pattern.end_with?("/")
|
|
81
|
+
# Directory pattern — match the directory and anything under it
|
|
82
|
+
Regexp.escape(pattern) + ".*"
|
|
83
|
+
else
|
|
84
|
+
re = Regexp.escape(pattern)
|
|
85
|
+
re.gsub("\\*\\*", "DOUBLE_STAR")
|
|
86
|
+
.gsub("\\*", "[^/]*")
|
|
87
|
+
.gsub("DOUBLE_STAR", ".*")
|
|
88
|
+
.gsub("\\?", "[^/]")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
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
|
data/lib/fluid_cli/theme/root.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative "file"
|
|
3
|
+
require_relative "fluid_ignore"
|
|
3
4
|
require "pathname"
|
|
4
5
|
|
|
5
6
|
module FluidCLI
|
|
@@ -24,11 +25,16 @@ module FluidCLI
|
|
|
24
25
|
glob("**/*.json")
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
def fluid_ignore
|
|
29
|
+
@fluid_ignore ||= FluidIgnore.new(root)
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
def glob(pattern, raise_on_dir: false)
|
|
28
33
|
root
|
|
29
34
|
.glob(pattern)
|
|
30
35
|
.select { |path| file?(path, raise_on_dir) }
|
|
31
36
|
.map { |path| File.new(path, root) }
|
|
37
|
+
.reject { |file| fluid_ignore.ignore?(file.relative_path) }
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def static_asset_file?(file)
|
|
@@ -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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Fluid
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-02 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
|
|
@@ -141,11 +142,15 @@ files:
|
|
|
141
142
|
- lib/fluid_cli/theme/dev_server/web_server.rb
|
|
142
143
|
- lib/fluid_cli/theme/development_theme.rb
|
|
143
144
|
- lib/fluid_cli/theme/file.rb
|
|
145
|
+
- lib/fluid_cli/theme/fluid_ignore.rb
|
|
144
146
|
- lib/fluid_cli/theme/forms/select.rb
|
|
145
147
|
- lib/fluid_cli/theme/mime_type.rb
|
|
148
|
+
- lib/fluid_cli/theme/navigation/resource_fetcher.rb
|
|
149
|
+
- lib/fluid_cli/theme/navigation/route_navigator.rb
|
|
146
150
|
- lib/fluid_cli/theme/presenters/theme_presenter.rb
|
|
147
151
|
- lib/fluid_cli/theme/presenters/themes_presenter.rb
|
|
148
152
|
- lib/fluid_cli/theme/root.rb
|
|
153
|
+
- lib/fluid_cli/theme/schema_validator.rb
|
|
149
154
|
- lib/fluid_cli/theme/syncer.rb
|
|
150
155
|
- lib/fluid_cli/theme/syncer/checksums.rb
|
|
151
156
|
- lib/fluid_cli/theme/syncer/downloader.rb
|