liquidbook 0.1.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 +7 -0
- data/.rspec +2 -0
- data/LICENSE +21 -0
- data/README.md +83 -0
- data/Rakefile +8 -0
- data/docs/README.ja.md +81 -0
- data/exe/liquidbook +7 -0
- data/fixtures/default_mocks.yml +88 -0
- data/lib/liquidbook/cli.rb +86 -0
- data/lib/liquidbook/config.rb +104 -0
- data/lib/liquidbook/filters/shopify_filters.rb +146 -0
- data/lib/liquidbook/mock_data.rb +57 -0
- data/lib/liquidbook/param_parser.rb +70 -0
- data/lib/liquidbook/pid_manager.rb +81 -0
- data/lib/liquidbook/schema_parser.rb +58 -0
- data/lib/liquidbook/server/app.rb +158 -0
- data/lib/liquidbook/server/views/index.erb +35 -0
- data/lib/liquidbook/server/views/layout.erb +59 -0
- data/lib/liquidbook/server/views/preview.erb +183 -0
- data/lib/liquidbook/tags/render_tag.rb +85 -0
- data/lib/liquidbook/tags/section_tag.rb +33 -0
- data/lib/liquidbook/theme_renderer.rb +82 -0
- data/lib/liquidbook/version.rb +5 -0
- data/lib/liquidbook.rb +50 -0
- data/sig/liquidbook.rbs +4 -0
- metadata +153 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Liquidbook
|
|
4
|
+
# Extracts @param comments from snippet files to build editable parameter forms
|
|
5
|
+
#
|
|
6
|
+
# Supported format:
|
|
7
|
+
# @param {Type} name - description
|
|
8
|
+
# @param {Type} name [default_value] - description
|
|
9
|
+
class ParamParser
|
|
10
|
+
PARAM_REGEX = /@param\s+\{(\w+)\}\s+(\w+)(?:\s+\[([^\]]*)\])?\s*(?:-\s*(.*))?/
|
|
11
|
+
|
|
12
|
+
def initialize(source)
|
|
13
|
+
@source = source
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse
|
|
17
|
+
@source.scan(PARAM_REGEX).map do |type, name, default, description|
|
|
18
|
+
{
|
|
19
|
+
"name" => name,
|
|
20
|
+
"type" => normalize_type(type),
|
|
21
|
+
"default" => coerce_default(type, default),
|
|
22
|
+
"description" => description&.strip
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build assigns hash from params with their defaults
|
|
28
|
+
def default_assigns
|
|
29
|
+
parse.each_with_object({}) do |param, hash|
|
|
30
|
+
hash[param["name"]] = param["default"]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_type(type)
|
|
37
|
+
case type.downcase
|
|
38
|
+
when "string" then "text"
|
|
39
|
+
when "number", "integer", "int", "float" then "number"
|
|
40
|
+
when "boolean", "bool" then "checkbox"
|
|
41
|
+
when "object", "hash" then "json"
|
|
42
|
+
when "array" then "json"
|
|
43
|
+
else "text"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def coerce_default(type, value)
|
|
48
|
+
return sample_value(type) if value.nil? || value.strip.empty?
|
|
49
|
+
|
|
50
|
+
case type.downcase
|
|
51
|
+
when "string" then value.strip
|
|
52
|
+
when "number", "integer", "int" then value.strip.to_i
|
|
53
|
+
when "float" then value.strip.to_f
|
|
54
|
+
when "boolean", "bool" then %w[true 1 yes].include?(value.strip.downcase)
|
|
55
|
+
else value.strip
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Provide sensible sample values when no default is given
|
|
60
|
+
def sample_value(type)
|
|
61
|
+
case type.downcase
|
|
62
|
+
when "string" then "Sample Text"
|
|
63
|
+
when "number", "integer", "int" then 1
|
|
64
|
+
when "float" then 1.0
|
|
65
|
+
when "boolean", "bool" then false
|
|
66
|
+
else "sample"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Liquidbook
|
|
4
|
+
class PidManager
|
|
5
|
+
PID_FILE_NAME = "server.pid"
|
|
6
|
+
|
|
7
|
+
def initialize(theme_root:)
|
|
8
|
+
@theme_root = theme_root
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pid_file_path
|
|
12
|
+
File.join(@theme_root, ".liquid-preview", PID_FILE_NAME)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Write the current process PID to the PID file
|
|
16
|
+
def write_pid
|
|
17
|
+
dir = File.dirname(pid_file_path)
|
|
18
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
19
|
+
File.write(pid_file_path, Process.pid.to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Read the PID from the PID file, returns nil if not found
|
|
23
|
+
def read_pid
|
|
24
|
+
return nil unless File.exist?(pid_file_path)
|
|
25
|
+
|
|
26
|
+
pid = File.read(pid_file_path).strip.to_i
|
|
27
|
+
pid.positive? ? pid : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Remove the PID file
|
|
31
|
+
def remove_pid
|
|
32
|
+
File.delete(pid_file_path) if File.exist?(pid_file_path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if a process with the given PID is alive
|
|
36
|
+
def process_alive?(pid)
|
|
37
|
+
Process.kill(0, pid)
|
|
38
|
+
true
|
|
39
|
+
rescue Errno::ESRCH
|
|
40
|
+
false
|
|
41
|
+
rescue Errno::EPERM
|
|
42
|
+
# Process exists but we don't have permission to signal it
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check the current state and ensure it's safe to start
|
|
47
|
+
# Returns :ok, :stale_pid_cleaned, or raises an error
|
|
48
|
+
def ensure_can_start!
|
|
49
|
+
pid = read_pid
|
|
50
|
+
return :ok if pid.nil?
|
|
51
|
+
|
|
52
|
+
if process_alive?(pid)
|
|
53
|
+
raise Error, "Server is already running (PID: #{pid}). Run `liquidbook stop` to stop it."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
remove_pid
|
|
57
|
+
:stale_pid_cleaned
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stop the running server process
|
|
61
|
+
# Returns :stopped, :not_running, or :stale_pid_cleaned
|
|
62
|
+
def stop!
|
|
63
|
+
pid = read_pid
|
|
64
|
+
|
|
65
|
+
if pid.nil?
|
|
66
|
+
return :not_running
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unless process_alive?(pid)
|
|
70
|
+
remove_pid
|
|
71
|
+
return :stale_pid_cleaned
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Process.kill("TERM", pid)
|
|
75
|
+
remove_pid
|
|
76
|
+
:stopped
|
|
77
|
+
rescue Errno::EPERM
|
|
78
|
+
raise Error, "Permission denied: cannot stop process (PID: #{pid})."
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Liquidbook
|
|
6
|
+
# Extracts and parses {% schema %} blocks from section files
|
|
7
|
+
class SchemaParser
|
|
8
|
+
SCHEMA_REGEX = /\{%-?\s*schema\s*-?%\}(.*?)\{%-?\s*endschema\s*-?%\}/m
|
|
9
|
+
|
|
10
|
+
def initialize(template_source)
|
|
11
|
+
@source = template_source
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse
|
|
15
|
+
match = @source.match(SCHEMA_REGEX)
|
|
16
|
+
return {} unless match
|
|
17
|
+
|
|
18
|
+
JSON.parse(match[1].strip)
|
|
19
|
+
rescue JSON::ParserError => e
|
|
20
|
+
warn "Warning: Failed to parse schema JSON: #{e.message}"
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the template source with the schema block removed
|
|
25
|
+
def template_without_schema
|
|
26
|
+
@source.gsub(SCHEMA_REGEX, "").strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds default settings values from schema
|
|
30
|
+
def default_settings
|
|
31
|
+
schema = parse
|
|
32
|
+
settings = schema.fetch("settings", [])
|
|
33
|
+
|
|
34
|
+
settings.each_with_object({}) do |setting, hash|
|
|
35
|
+
hash[setting["id"]] = setting["default"] if setting["id"]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds default block data from schema
|
|
40
|
+
def default_blocks
|
|
41
|
+
schema = parse
|
|
42
|
+
blocks = schema.fetch("blocks", [])
|
|
43
|
+
|
|
44
|
+
blocks.filter_map do |block|
|
|
45
|
+
next unless block["type"]
|
|
46
|
+
|
|
47
|
+
settings = (block["settings"] || []).each_with_object({}) do |s, h|
|
|
48
|
+
h[s["id"]] = s["default"] if s["id"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
"type" => block["type"],
|
|
53
|
+
"settings" => settings
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Liquidbook
|
|
7
|
+
module Server
|
|
8
|
+
class App < Sinatra::Base
|
|
9
|
+
set :views, File.expand_path("views", __dir__)
|
|
10
|
+
set :public_folder, -> { File.join(Liquidbook.root, "assets") }
|
|
11
|
+
|
|
12
|
+
helpers do
|
|
13
|
+
def renderer
|
|
14
|
+
@renderer ||= ThemeRenderer.new(theme_root: Liquidbook.root)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def config
|
|
18
|
+
Liquidbook.config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def h(text)
|
|
22
|
+
Rack::Utils.escape_html(text.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def head_tags
|
|
26
|
+
config.head_tags_html.join("\n")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Index page - list all sections and snippets
|
|
31
|
+
get "/" do
|
|
32
|
+
@sections = renderer.sections
|
|
33
|
+
@snippets = renderer.snippets
|
|
34
|
+
erb :index
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Preview a section
|
|
38
|
+
get "/sections/:name" do
|
|
39
|
+
name = params[:name]
|
|
40
|
+
begin
|
|
41
|
+
overrides = parse_overrides(params)
|
|
42
|
+
@rendered = renderer.render_section(name, overrides: overrides)
|
|
43
|
+
@name = name
|
|
44
|
+
@type = "section"
|
|
45
|
+
@schema = load_schema("sections", name)
|
|
46
|
+
@params = []
|
|
47
|
+
erb :preview
|
|
48
|
+
rescue Liquidbook::Error => e
|
|
49
|
+
status 404
|
|
50
|
+
"Section not found: #{h(name)}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Preview a snippet
|
|
55
|
+
get "/snippets/:name" do
|
|
56
|
+
name = params[:name]
|
|
57
|
+
begin
|
|
58
|
+
overrides = parse_overrides(params)
|
|
59
|
+
@rendered = renderer.render_snippet(name, overrides: overrides)
|
|
60
|
+
@name = name
|
|
61
|
+
@type = "snippet"
|
|
62
|
+
@schema = {}
|
|
63
|
+
@params = renderer.snippet_params(name)
|
|
64
|
+
erb :preview
|
|
65
|
+
rescue Liquidbook::Error => e
|
|
66
|
+
status 404
|
|
67
|
+
"Snippet not found: #{h(name)}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Serve imported files from config (CSS/JS from arbitrary paths)
|
|
72
|
+
get "/__imports__/*" do
|
|
73
|
+
serve_path = "/__imports__/#{params["splat"].first}"
|
|
74
|
+
file_path = config.import_files[serve_path]
|
|
75
|
+
|
|
76
|
+
if file_path && File.exist?(file_path)
|
|
77
|
+
content_type mime_type(File.extname(file_path)) || "application/octet-stream"
|
|
78
|
+
File.read(file_path)
|
|
79
|
+
else
|
|
80
|
+
status 404
|
|
81
|
+
"Import not found: #{h(serve_path)}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Serve theme assets
|
|
86
|
+
get "/assets/*" do
|
|
87
|
+
file_path = File.join(Liquidbook.root, "assets", params["splat"].first)
|
|
88
|
+
if File.exist?(file_path)
|
|
89
|
+
content_type mime_type(File.extname(file_path)) || "application/octet-stream"
|
|
90
|
+
File.read(file_path)
|
|
91
|
+
else
|
|
92
|
+
status 404
|
|
93
|
+
"Asset not found"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# API: re-render with params (for live reload + param editing)
|
|
98
|
+
post "/api/render/:type/:name" do
|
|
99
|
+
content_type :json
|
|
100
|
+
type = params[:type]
|
|
101
|
+
name = params[:name]
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
body = JSON.parse(request.body.read) rescue {}
|
|
105
|
+
overrides = body["overrides"] || {}
|
|
106
|
+
|
|
107
|
+
html = case type
|
|
108
|
+
when "sections" then renderer.render_section(name, overrides: overrides)
|
|
109
|
+
when "snippets" then renderer.render_snippet(name, overrides: overrides)
|
|
110
|
+
else raise Error, "Unknown type: #{type}"
|
|
111
|
+
end
|
|
112
|
+
{ html: html }.to_json
|
|
113
|
+
rescue Liquidbook::Error => e
|
|
114
|
+
status 404
|
|
115
|
+
{ error: e.message }.to_json
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# API: re-render (GET for live reload)
|
|
120
|
+
get "/api/render/:type/:name" do
|
|
121
|
+
content_type :json
|
|
122
|
+
type = params[:type]
|
|
123
|
+
name = params[:name]
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
html = case type
|
|
127
|
+
when "sections" then renderer.render_section(name)
|
|
128
|
+
when "snippets" then renderer.render_snippet(name)
|
|
129
|
+
else raise Error, "Unknown type: #{type}"
|
|
130
|
+
end
|
|
131
|
+
{ html: html }.to_json
|
|
132
|
+
rescue Liquidbook::Error => e
|
|
133
|
+
status 404
|
|
134
|
+
{ error: e.message }.to_json
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def load_schema(dir, name)
|
|
141
|
+
path = File.join(Liquidbook.root, dir, "#{name}.liquid")
|
|
142
|
+
return {} unless File.exist?(path)
|
|
143
|
+
|
|
144
|
+
SchemaParser.new(File.read(path)).parse
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_overrides(params)
|
|
148
|
+
overrides = {}
|
|
149
|
+
params.each do |key, value|
|
|
150
|
+
next if %w[name splat captures].include?(key)
|
|
151
|
+
|
|
152
|
+
overrides[key] = value
|
|
153
|
+
end
|
|
154
|
+
overrides
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<h2 style="margin-bottom: 16px; font-size: 20px;">Sections</h2>
|
|
2
|
+
<% if @sections.empty? %>
|
|
3
|
+
<p style="color: var(--text-sub);">No sections found in <code>sections/</code></p>
|
|
4
|
+
<% else %>
|
|
5
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 32px;">
|
|
6
|
+
<% @sections.each do |name| %>
|
|
7
|
+
<a href="/sections/<%= h(name) %>" style="
|
|
8
|
+
display: block; padding: 16px; background: var(--surface);
|
|
9
|
+
border: 1px solid var(--border); border-radius: var(--radius);
|
|
10
|
+
text-decoration: none; color: var(--text); transition: border-color 0.2s;
|
|
11
|
+
" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
|
12
|
+
<div style="font-weight: 500;"><%= h(name) %></div>
|
|
13
|
+
<div style="font-size: 12px; color: var(--text-sub); margin-top: 4px;">section</div>
|
|
14
|
+
</a>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<h2 style="margin-bottom: 16px; font-size: 20px;">Snippets</h2>
|
|
20
|
+
<% if @snippets.empty? %>
|
|
21
|
+
<p style="color: var(--text-sub);">No snippets found in <code>snippets/</code></p>
|
|
22
|
+
<% else %>
|
|
23
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px;">
|
|
24
|
+
<% @snippets.each do |name| %>
|
|
25
|
+
<a href="/snippets/<%= h(name) %>" style="
|
|
26
|
+
display: block; padding: 16px; background: var(--surface);
|
|
27
|
+
border: 1px solid var(--border); border-radius: var(--radius);
|
|
28
|
+
text-decoration: none; color: var(--text); transition: border-color 0.2s;
|
|
29
|
+
" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
|
30
|
+
<div style="font-weight: 500;"><%= h(name) %></div>
|
|
31
|
+
<div style="font-size: 12px; color: var(--text-sub); margin-top: 4px;">snippet</div>
|
|
32
|
+
</a>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Liquidbook</title>
|
|
7
|
+
<%= head_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--lp-bg: #f5f5f5;
|
|
11
|
+
--lp-surface: #fff;
|
|
12
|
+
--lp-text: #1a1a1a;
|
|
13
|
+
--lp-text-sub: #666;
|
|
14
|
+
--lp-accent: #5c6ac4;
|
|
15
|
+
--lp-border: #e0e0e0;
|
|
16
|
+
--lp-radius: 8px;
|
|
17
|
+
}
|
|
18
|
+
.lp-topbar {
|
|
19
|
+
background: var(--lp-surface);
|
|
20
|
+
border-bottom: 1px solid var(--lp-border);
|
|
21
|
+
padding: 12px 24px;
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 16px;
|
|
25
|
+
position: sticky;
|
|
26
|
+
top: 0;
|
|
27
|
+
z-index: 10000;
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
.lp-topbar h1 {
|
|
31
|
+
font-size: 16px;
|
|
32
|
+
font-weight: 600;
|
|
33
|
+
color: var(--lp-accent);
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
.lp-topbar nav a {
|
|
37
|
+
color: var(--lp-text-sub);
|
|
38
|
+
text-decoration: none;
|
|
39
|
+
font-size: 14px;
|
|
40
|
+
}
|
|
41
|
+
.lp-topbar nav a:hover { color: var(--lp-accent); }
|
|
42
|
+
.lp-container {
|
|
43
|
+
max-width: 1200px;
|
|
44
|
+
margin: 0 auto;
|
|
45
|
+
padding: 24px;
|
|
46
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div class="lp-topbar">
|
|
52
|
+
<h1>Liquidbook</h1>
|
|
53
|
+
<nav><a href="/">Home</a></nav>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="lp-container">
|
|
56
|
+
<%= yield %>
|
|
57
|
+
</div>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<div style="display: flex; gap: 24px; align-items: flex-start; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
2
|
+
<!-- Preview pane -->
|
|
3
|
+
<div style="flex: 1; min-width: 0;">
|
|
4
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
|
|
5
|
+
<h2 style="font-size: 20px; margin: 0;"><%= h(@name) %></h2>
|
|
6
|
+
<span style="font-size: 12px; padding: 2px 8px; background: var(--lp-accent); color: #fff; border-radius: 12px;"><%= h(@type) %></span>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<!-- Viewport controls -->
|
|
10
|
+
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
|
|
11
|
+
<button onclick="setWidth('100%')" class="lp-btn">Full</button>
|
|
12
|
+
<button onclick="setWidth('768px')" class="lp-btn">Tablet</button>
|
|
13
|
+
<button onclick="setWidth('375px')" class="lp-btn">Mobile</button>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div id="preview-frame" style="
|
|
17
|
+
background: var(--lp-surface);
|
|
18
|
+
border: 1px solid var(--lp-border);
|
|
19
|
+
border-radius: var(--lp-radius);
|
|
20
|
+
padding: 24px;
|
|
21
|
+
overflow: auto;
|
|
22
|
+
transition: max-width 0.3s;
|
|
23
|
+
">
|
|
24
|
+
<%= @rendered %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Source toggle -->
|
|
28
|
+
<details style="margin-top: 16px;">
|
|
29
|
+
<summary style="cursor: pointer; color: var(--lp-text-sub); font-size: 14px;">Rendered HTML</summary>
|
|
30
|
+
<pre style="
|
|
31
|
+
margin-top: 8px; padding: 16px; background: #2d2d2d; color: #f8f8f2;
|
|
32
|
+
border-radius: var(--lp-radius); overflow-x: auto; font-size: 13px; line-height: 1.5;
|
|
33
|
+
"><%= h(@rendered) %></pre>
|
|
34
|
+
</details>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Sidebar: params + schema -->
|
|
38
|
+
<div style="width: 320px; flex-shrink: 0; position: sticky; top: 64px;">
|
|
39
|
+
|
|
40
|
+
<!-- Snippet @param editor -->
|
|
41
|
+
<% if @params && !@params.empty? %>
|
|
42
|
+
<div style="background: var(--lp-surface); border: 1px solid var(--lp-border); border-radius: var(--lp-radius); padding: 16px; margin-bottom: 16px;">
|
|
43
|
+
<h3 style="font-size: 14px; margin: 0 0 12px; color: var(--lp-text-sub);">Parameters</h3>
|
|
44
|
+
<form id="param-form">
|
|
45
|
+
<% @params.each do |param| %>
|
|
46
|
+
<div style="margin-bottom: 12px;">
|
|
47
|
+
<label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px;">
|
|
48
|
+
<%= h(param["name"]) %>
|
|
49
|
+
<span style="font-weight: normal; color: var(--lp-text-sub);"><%= h(param["description"]) %></span>
|
|
50
|
+
</label>
|
|
51
|
+
<% if param["type"] == "checkbox" %>
|
|
52
|
+
<input type="checkbox" name="<%= h(param["name"]) %>" <%= "checked" if param["default"] %> data-param>
|
|
53
|
+
<% elsif param["type"] == "number" %>
|
|
54
|
+
<input type="number" name="<%= h(param["name"]) %>" value="<%= h(param["default"]) %>" style="width: 100%; padding: 6px 8px; border: 1px solid var(--lp-border); border-radius: 4px; font-size: 13px;" data-param>
|
|
55
|
+
<% else %>
|
|
56
|
+
<input type="text" name="<%= h(param["name"]) %>" value="<%= h(param["default"]) %>" style="width: 100%; padding: 6px 8px; border: 1px solid var(--lp-border); border-radius: 4px; font-size: 13px;" data-param>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
<button type="submit" class="lp-btn" style="width: 100%; background: var(--lp-accent); color: #fff; border-color: var(--lp-accent);">Update Preview</button>
|
|
61
|
+
</form>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
|
|
65
|
+
<!-- Schema sidebar -->
|
|
66
|
+
<% unless @schema.nil? || @schema.empty? %>
|
|
67
|
+
<div style="background: var(--lp-surface); border: 1px solid var(--lp-border); border-radius: var(--lp-radius); padding: 16px;">
|
|
68
|
+
<h3 style="font-size: 14px; margin: 0 0 12px; color: var(--lp-text-sub);">Schema Settings</h3>
|
|
69
|
+
<% (@schema["settings"] || []).each do |setting| %>
|
|
70
|
+
<div style="margin-bottom: 12px;">
|
|
71
|
+
<label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px;">
|
|
72
|
+
<%= h(setting["label"] || setting["id"]) %>
|
|
73
|
+
</label>
|
|
74
|
+
<% st = setting["type"] %>
|
|
75
|
+
<% if %w[text url richtext].include?(st) %>
|
|
76
|
+
<input type="text" value="<%= h(setting["default"]) %>" style="width: 100%; padding: 6px 8px; border: 1px solid var(--lp-border); border-radius: 4px; font-size: 13px;" disabled>
|
|
77
|
+
<% elsif st == "textarea" %>
|
|
78
|
+
<textarea style="width: 100%; padding: 6px 8px; border: 1px solid var(--lp-border); border-radius: 4px; font-size: 13px; min-height: 60px;" disabled><%= h(setting["default"]) %></textarea>
|
|
79
|
+
<% elsif st == "checkbox" %>
|
|
80
|
+
<input type="checkbox" <%= "checked" if setting["default"] %> disabled>
|
|
81
|
+
<% elsif st == "range" %>
|
|
82
|
+
<input type="range" min="<%= setting["min"] %>" max="<%= setting["max"] %>" value="<%= setting["default"] %>" step="<%= setting["step"] || 1 %>" style="width: 100%;" disabled>
|
|
83
|
+
<span style="font-size: 12px; color: var(--lp-text-sub);"><%= setting["default"] %></span>
|
|
84
|
+
<% elsif st == "select" %>
|
|
85
|
+
<select style="width: 100%; padding: 6px 8px; border: 1px solid var(--lp-border); border-radius: 4px; font-size: 13px;" disabled>
|
|
86
|
+
<% (setting["options"] || []).each do |opt| %>
|
|
87
|
+
<option value="<%= h(opt["value"]) %>" <%= "selected" if opt["value"] == setting["default"] %>>
|
|
88
|
+
<%= h(opt["label"] || opt["value"]) %>
|
|
89
|
+
</option>
|
|
90
|
+
<% end %>
|
|
91
|
+
</select>
|
|
92
|
+
<% elsif %w[color color_background].include?(st) %>
|
|
93
|
+
<input type="color" value="<%= h(setting["default"] || "#000000") %>" style="width: 48px; height: 32px; border: 1px solid var(--lp-border); border-radius: 4px;" disabled>
|
|
94
|
+
<% else %>
|
|
95
|
+
<div style="font-size: 12px; color: var(--lp-text-sub);"><%= h(st) %>: <%= h(setting["default"]) %></div>
|
|
96
|
+
<% end %>
|
|
97
|
+
</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
|
|
100
|
+
<% if @schema["blocks"] && !@schema["blocks"].empty? %>
|
|
101
|
+
<h3 style="font-size: 14px; margin: 16px 0 8px; color: var(--lp-text-sub);">Blocks</h3>
|
|
102
|
+
<% @schema["blocks"].each do |block| %>
|
|
103
|
+
<div style="padding: 8px; border: 1px solid var(--lp-border); border-radius: 4px; margin-bottom: 8px;">
|
|
104
|
+
<div style="font-size: 13px; font-weight: 500;"><%= h(block["name"] || block["type"]) %></div>
|
|
105
|
+
<div style="font-size: 11px; color: var(--lp-text-sub);">type: <%= h(block["type"]) %></div>
|
|
106
|
+
</div>
|
|
107
|
+
<% end %>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.lp-btn {
|
|
116
|
+
padding: 4px 12px;
|
|
117
|
+
border: 1px solid var(--lp-border);
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
background: var(--lp-surface);
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
font-size: 13px;
|
|
122
|
+
font-family: inherit;
|
|
123
|
+
}
|
|
124
|
+
.lp-btn:hover { border-color: var(--lp-accent); }
|
|
125
|
+
</style>
|
|
126
|
+
|
|
127
|
+
<script>
|
|
128
|
+
function setWidth(w) {
|
|
129
|
+
document.getElementById('preview-frame').style.maxWidth = w;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Param form → re-render via POST
|
|
133
|
+
const form = document.getElementById('param-form');
|
|
134
|
+
if (form) {
|
|
135
|
+
form.addEventListener('submit', async (e) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
const overrides = {};
|
|
138
|
+
form.querySelectorAll('[data-param]').forEach(el => {
|
|
139
|
+
if (el.type === 'checkbox') {
|
|
140
|
+
overrides[el.name] = el.checked;
|
|
141
|
+
} else if (el.type === 'number') {
|
|
142
|
+
overrides[el.name] = Number(el.value);
|
|
143
|
+
} else {
|
|
144
|
+
overrides[el.name] = el.value;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const type = '<%= @type == "section" ? "sections" : "snippets" %>';
|
|
149
|
+
const name = '<%= @name %>';
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`/api/render/${type}/${name}`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ overrides })
|
|
155
|
+
});
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
if (data.html) {
|
|
158
|
+
document.getElementById('preview-frame').innerHTML = data.html;
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('Render error:', err);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Live reload via polling (file changes)
|
|
167
|
+
(function() {
|
|
168
|
+
const type = '<%= @type == "section" ? "sections" : "snippets" %>';
|
|
169
|
+
const name = '<%= @name %>';
|
|
170
|
+
let lastHtml = null;
|
|
171
|
+
|
|
172
|
+
setInterval(async () => {
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(`/api/render/${type}/${name}`);
|
|
175
|
+
const data = await res.json();
|
|
176
|
+
if (data.html && lastHtml !== null && data.html !== lastHtml) {
|
|
177
|
+
document.getElementById('preview-frame').innerHTML = data.html;
|
|
178
|
+
}
|
|
179
|
+
lastHtml = data.html;
|
|
180
|
+
} catch (e) {}
|
|
181
|
+
}, 1500);
|
|
182
|
+
})();
|
|
183
|
+
</script>
|