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.
@@ -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>