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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6c2c697f9bb5b094c752a0798ef24b5bf0265f60c5ed490abe4b9ad2abbb2d60
4
+ data.tar.gz: dda835eaec1bef74610452dfe1596a1b517122ca9e66a852592aa5ca79a43700
5
+ SHA512:
6
+ metadata.gz: a5ff5a395bdf717724ebec570cb4fb8ff039d4cd9940dbb2df6320e402e0ae18511d5ec2d1ebeca9f598b73f0b81cc96977bae7bcb996117fc0e07e8380d10ff
7
+ data.tar.gz: 731b75e5e253632d37d58033f49d12c4ef74820397212750174bc2e66b9d01c8ad980be48108197effefab90ec4009c6d57becd4b494793f17f0471659665efa
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sena Murakami
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Liquidbook
2
+
3
+ [![CI](https://github.com/sena-m09/liquidbook/actions/workflows/test.yml/badge.svg)](https://github.com/sena-m09/liquidbook/actions/workflows/test.yml)
4
+
5
+ A Storybook-like local preview server for Shopify Liquid templates. Browse and preview your sections and snippets in the browser instantly.
6
+
7
+ ## Features
8
+
9
+ - Auto-discovers `.liquid` files in `sections/` and `snippets/`
10
+ - Generates default setting values from `{% schema %}` blocks
11
+ - Supports Shopify-compatible filters and tags (`section`, `render`)
12
+ - Load external CSS/JS via `.liquid-preview/config.yml`
13
+ - File watching with live reload
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "liquidbook"
21
+ ```
22
+
23
+ Or install directly:
24
+
25
+ ```bash
26
+ gem install liquidbook
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Start the preview server
32
+
33
+ Run from your Shopify theme root:
34
+
35
+ ```bash
36
+ liquidbook server
37
+ ```
38
+
39
+ Open `http://127.0.0.1:4567` in your browser to see a list of sections and snippets with live previews.
40
+
41
+ #### Options
42
+
43
+ | Option | Short | Default | Description |
44
+ |---|---|---|---|
45
+ | `--port` | `-p` | `4567` | Port number |
46
+ | `--host` | `-H` | `127.0.0.1` | Host to bind |
47
+ | `--root` | `-r` | `.` | Theme root directory |
48
+
49
+ ### Render a single template
50
+
51
+ ```bash
52
+ liquidbook render sections/header.liquid
53
+ ```
54
+
55
+ Outputs the rendered HTML to stdout.
56
+
57
+ ## Configuration
58
+
59
+ Create `.liquid-preview/config.yml` in your theme root to load external assets into the preview:
60
+
61
+ ```yaml
62
+ head:
63
+ - <script src="https://cdn.tailwindcss.com"></script>
64
+ - ../src/styles/main.css
65
+ - ./src/components.js
66
+
67
+ port: 4567
68
+ host: 127.0.0.1
69
+ ```
70
+
71
+ `head` entries accept raw HTML tags or file paths. File paths are resolved relative to the theme root.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ git clone https://github.com/sena-m09/liquidbook.git
77
+ cd liquidbook
78
+ bin/setup
79
+ ```
80
+
81
+ ## License
82
+
83
+ [MIT License](https://opensource.org/licenses/MIT)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/docs/README.ja.md ADDED
@@ -0,0 +1,81 @@
1
+ # Liquidbook
2
+
3
+ Shopify Liquid テンプレートのローカルプレビューサーバー。Storybook のように、sections や snippets をブラウザ上で即座に確認できます。
4
+
5
+ ## 特徴
6
+
7
+ - `sections/` や `snippets/` 内の `.liquid` ファイルを自動検出してプレビュー
8
+ - `{% schema %}` ブロックからデフォルト設定値を自動生成
9
+ - Shopify 互換フィルター・タグ (`section`, `render`) をサポート
10
+ - `.liquid-preview/config.yml` で外部 CSS/JS の読み込みに対応
11
+ - ファイル変更の自動検知(live reload)
12
+
13
+ ## インストール
14
+
15
+ Gemfile に追加:
16
+
17
+ ```ruby
18
+ gem "liquidbook"
19
+ ```
20
+
21
+ または直接インストール:
22
+
23
+ ```bash
24
+ gem install liquidbook
25
+ ```
26
+
27
+ ## 使い方
28
+
29
+ ### プレビューサーバーの起動
30
+
31
+ Shopify テーマのルートディレクトリで実行:
32
+
33
+ ```bash
34
+ liquidbook server
35
+ ```
36
+
37
+ ブラウザで `http://127.0.0.1:4567` を開くと、sections / snippets の一覧とプレビューが表示されます。
38
+
39
+ #### オプション
40
+
41
+ | オプション | 短縮 | デフォルト | 説明 |
42
+ |---|---|---|---|
43
+ | `--port` | `-p` | `4567` | ポート番号 |
44
+ | `--host` | `-H` | `127.0.0.1` | バインドするホスト |
45
+ | `--root` | `-r` | `.` | テーマのルートディレクトリ |
46
+
47
+ ### 単一テンプレートのレンダリング
48
+
49
+ ```bash
50
+ liquidbook render sections/header.liquid
51
+ ```
52
+
53
+ 標準出力に HTML が出力されます。
54
+
55
+ ## 設定
56
+
57
+ テーマルートに `.liquid-preview/config.yml` を作成すると、プレビューに外部アセットを読み込めます。
58
+
59
+ ```yaml
60
+ head:
61
+ - <script src="https://cdn.tailwindcss.com"></script>
62
+ - ../src/styles/main.css
63
+ - ./src/components.js
64
+
65
+ port: 4567
66
+ host: 127.0.0.1
67
+ ```
68
+
69
+ `head` エントリには生の HTML タグまたはファイルパスを指定できます。ファイルパスはテーマルートからの相対パスで解決されます。
70
+
71
+ ## 開発
72
+
73
+ ```bash
74
+ git clone https://github.com/sena-m09/liquidbook.git
75
+ cd liquidbook
76
+ bin/setup
77
+ ```
78
+
79
+ ## ライセンス
80
+
81
+ [MIT License](https://opensource.org/licenses/MIT)
data/exe/liquidbook ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/liquidbook"
5
+ require_relative "../lib/liquidbook/cli"
6
+
7
+ Liquidbook::CLI.start(ARGV)
@@ -0,0 +1,88 @@
1
+ shop:
2
+ name: "Preview Shop"
3
+ url: "https://preview-shop.myshopify.com"
4
+ currency: "JPY"
5
+ money_format: "¥{{amount}}"
6
+
7
+ product:
8
+ title: "サンプル商品"
9
+ handle: "sample-product"
10
+ description: "<p>これはプレビュー用のサンプル商品です。</p>"
11
+ price: 1980
12
+ compare_at_price: 2980
13
+ available: true
14
+ type: "サンプル"
15
+ vendor: "Preview Vendor"
16
+ featured_image:
17
+ src: "https://placehold.co/600x600/EEE/31343C?text=Product"
18
+ alt: "サンプル商品画像"
19
+ width: 600
20
+ height: 600
21
+ images:
22
+ - src: "https://placehold.co/600x600/EEE/31343C?text=Image+1"
23
+ alt: "商品画像 1"
24
+ - src: "https://placehold.co/600x600/EEE/31343C?text=Image+2"
25
+ alt: "商品画像 2"
26
+ variants:
27
+ - title: "デフォルト"
28
+ price: 1980
29
+ available: true
30
+ sku: "SAMPLE-001"
31
+ tags:
32
+ - "sample"
33
+ - "preview"
34
+
35
+ collection:
36
+ title: "サンプルコレクション"
37
+ handle: "sample-collection"
38
+ description: "プレビュー用コレクション"
39
+ products_count: 12
40
+ products:
41
+ - title: "商品 1"
42
+ handle: "product-1"
43
+ price: 1980
44
+ featured_image:
45
+ src: "https://placehold.co/400x400/EEE/31343C?text=1"
46
+ - title: "商品 2"
47
+ handle: "product-2"
48
+ price: 2980
49
+ featured_image:
50
+ src: "https://placehold.co/400x400/EEE/31343C?text=2"
51
+ - title: "商品 3"
52
+ handle: "product-3"
53
+ price: 3980
54
+ featured_image:
55
+ src: "https://placehold.co/400x400/EEE/31343C?text=3"
56
+
57
+ cart:
58
+ item_count: 2
59
+ total_price: 4960
60
+ items:
61
+ - title: "商品 1"
62
+ quantity: 1
63
+ price: 1980
64
+ - title: "商品 2"
65
+ quantity: 1
66
+ price: 2980
67
+
68
+ page:
69
+ title: "サンプルページ"
70
+ handle: "sample-page"
71
+ content: "<p>プレビュー用のページコンテンツです。</p>"
72
+
73
+ customer:
74
+ first_name: "太郎"
75
+ last_name: "テスト"
76
+ email: "test@example.com"
77
+
78
+ request:
79
+ locale:
80
+ iso_code: "ja"
81
+ name: "Japanese"
82
+ host: "localhost:4567"
83
+
84
+ settings:
85
+ type_header_font:
86
+ family: "sans-serif"
87
+ type_body_font:
88
+ family: "sans-serif"
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+
6
+ module Liquidbook
7
+ class CLI < Thor
8
+ default_command :server
9
+
10
+ desc "server", "Start the preview server"
11
+ option :port, type: :numeric, default: 4567, aliases: "-p", desc: "Port number"
12
+ option :host, type: :string, default: "127.0.0.1", aliases: "-H", desc: "Host to bind"
13
+ option :root, type: :string, default: ".", aliases: "-r", desc: "Theme root directory"
14
+ def server
15
+ theme_root = File.expand_path(options[:root])
16
+ Liquidbook.root = theme_root
17
+
18
+ pid_manager = PidManager.new(theme_root: theme_root)
19
+ result = pid_manager.ensure_can_start!
20
+ warn "Warning: Removed stale PID file." if result == :stale_pid_cleaned
21
+
22
+ puts "Liquidbook v#{VERSION}"
23
+ puts "Theme root: #{theme_root}"
24
+ puts "Server: http://#{options[:host]}:#{options[:port]}"
25
+ puts ""
26
+
27
+ sections = Dir.glob(File.join(theme_root, "sections", "*.liquid")).size
28
+ snippets = Dir.glob(File.join(theme_root, "snippets", "*.liquid")).size
29
+ puts "Found #{sections} sections, #{snippets} snippets"
30
+ puts ""
31
+
32
+ pid_manager.write_pid
33
+ at_exit { pid_manager.remove_pid }
34
+
35
+ Server::App.set :port, options[:port]
36
+ Server::App.set :bind, options[:host]
37
+ Server::App.run!
38
+ end
39
+
40
+ desc "stop", "Stop the running preview server"
41
+ option :root, type: :string, default: ".", aliases: "-r", desc: "Theme root directory"
42
+ def stop
43
+ theme_root = File.expand_path(options[:root])
44
+ pid_manager = PidManager.new(theme_root: theme_root)
45
+
46
+ case pid_manager.stop!
47
+ when :stopped
48
+ puts "Server stopped."
49
+ when :not_running
50
+ puts "Server is not running."
51
+ when :stale_pid_cleaned
52
+ puts "Server is not running (removed stale PID file)."
53
+ end
54
+ rescue Liquidbook::Error => e
55
+ warn "Error: #{e.message}"
56
+ exit 1
57
+ end
58
+
59
+ desc "render TEMPLATE", "Render a single template to stdout"
60
+ option :root, type: :string, default: ".", aliases: "-r", desc: "Theme root directory"
61
+ def render(template)
62
+ theme_root = File.expand_path(options[:root])
63
+ Liquidbook.root = theme_root
64
+
65
+ renderer = ThemeRenderer.new(theme_root: theme_root)
66
+
67
+ if template.start_with?("sections/") || template.start_with?("snippets/")
68
+ dir, name = template.split("/", 2)
69
+ name = name.sub(/\.liquid$/, "")
70
+ html = dir == "sections" ? renderer.render_section(name) : renderer.render_snippet(name)
71
+ else
72
+ html = renderer.render_section(template.sub(/\.liquid$/, ""))
73
+ end
74
+
75
+ puts html
76
+ rescue Liquidbook::Error => e
77
+ warn "Error: #{e.message}"
78
+ exit 1
79
+ end
80
+
81
+ desc "version", "Show version"
82
+ def version
83
+ puts "liquidbook #{VERSION}"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Liquidbook
6
+ # Loads .liquid-preview/config.yml from the theme root
7
+ #
8
+ # head entries can be:
9
+ # - Raw HTML: <script src="https://cdn.tailwindcss.com"></script>
10
+ # - File path: ../src-lit/styles/main.css
11
+ # - File path: ./src/components.js
12
+ #
13
+ # File paths are resolved relative to the theme root and served via /__imports__/
14
+ class Config
15
+ DEFAULT_CONFIG = {
16
+ "head" => [],
17
+ "port" => 4567,
18
+ "host" => "127.0.0.1"
19
+ }.freeze
20
+
21
+ IMPORT_PATH_PREFIX = "/__imports__"
22
+
23
+ def initialize(theme_root: nil)
24
+ @theme_root = theme_root || Liquidbook.root
25
+ @data = load_config
26
+ end
27
+
28
+ # Returns HTML strings ready to inject into <head>
29
+ def head_tags_html
30
+ Array(@data["head"]).map { |entry| entry_to_html(entry) }
31
+ end
32
+
33
+ # Returns a map of serve_path => absolute_file_path for file imports
34
+ def import_files
35
+ @import_files ||= build_import_map
36
+ end
37
+
38
+ def port
39
+ @data["port"] || 4567
40
+ end
41
+
42
+ def host
43
+ @data["host"] || "127.0.0.1"
44
+ end
45
+
46
+ def [](key)
47
+ @data[key.to_s]
48
+ end
49
+
50
+ private
51
+
52
+ def load_config
53
+ path = File.join(@theme_root, ".liquid-preview", "config.yml")
54
+ return DEFAULT_CONFIG.dup unless File.exist?(path)
55
+
56
+ loaded = YAML.safe_load(File.read(path), permitted_classes: []) || {}
57
+ DEFAULT_CONFIG.merge(loaded)
58
+ end
59
+
60
+ def file_path?(entry)
61
+ # Not raw HTML, looks like a path
62
+ !entry.strip.start_with?("<") && entry.match?(/\.\w+$/)
63
+ end
64
+
65
+ def resolve_path(entry)
66
+ File.expand_path(entry.strip, @theme_root)
67
+ end
68
+
69
+ def serve_path(entry)
70
+ # Use resolved absolute path for a deterministic, safe URL
71
+ abs = resolve_path(entry)
72
+ safe_name = abs.gsub(/[^a-zA-Z0-9._-]/, "_")
73
+ "#{IMPORT_PATH_PREFIX}/#{safe_name}"
74
+ end
75
+
76
+ def entry_to_html(entry)
77
+ if file_path?(entry)
78
+ url = serve_path(entry)
79
+ ext = File.extname(entry.strip).downcase
80
+ case ext
81
+ when ".css"
82
+ %(<link rel="stylesheet" href="#{url}">)
83
+ when ".js", ".mjs", ".ts"
84
+ %(<script type="module" src="#{url}"></script>)
85
+ else
86
+ %(<link href="#{url}">)
87
+ end
88
+ else
89
+ entry.strip
90
+ end
91
+ end
92
+
93
+ def build_import_map
94
+ map = {}
95
+ Array(@data["head"]).each do |entry|
96
+ next unless file_path?(entry)
97
+
98
+ abs = resolve_path(entry)
99
+ map[serve_path(entry)] = abs
100
+ end
101
+ map
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquidbook
4
+ module Filters
5
+ # Common Shopify Liquid filters for local preview
6
+ module ShopifyFilters
7
+ # Asset URL filters
8
+ def asset_url(input)
9
+ "/assets/#{input}"
10
+ end
11
+
12
+ def asset_img_url(input, size = nil)
13
+ return input unless input.is_a?(String)
14
+
15
+ "/assets/#{input}"
16
+ end
17
+
18
+ def image_url(input, width: nil, height: nil, **_opts)
19
+ return "" unless input
20
+
21
+ src = input.is_a?(Hash) ? input["src"] : input.to_s
22
+ params = []
23
+ params << "width=#{width}" if width
24
+ params << "height=#{height}" if height
25
+ params.empty? ? src : "#{src}?#{params.join("&")}"
26
+ end
27
+
28
+ def img_tag(input, alt: "")
29
+ %(<img src="#{input}" alt="#{alt}" loading="lazy">)
30
+ end
31
+
32
+ # Money filters
33
+ def money(input)
34
+ return "" unless input
35
+
36
+ "¥#{format("%d", input.to_i)}"
37
+ end
38
+
39
+ def money_with_currency(input)
40
+ "#{money(input)} JPY"
41
+ end
42
+
43
+ # String filters (Shopify extensions)
44
+ def handle(input)
45
+ return "" unless input
46
+
47
+ input.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
48
+ end
49
+
50
+ def handleize(input)
51
+ handle(input)
52
+ end
53
+
54
+ def pluralize(input, singular, plural)
55
+ input.to_i == 1 ? singular : plural
56
+ end
57
+
58
+ # URL filters
59
+ def link_to(input, url, title = "")
60
+ title_attr = title.to_s.empty? ? "" : %( title="#{title}")
61
+ %(<a href="#{url}"#{title_attr}>#{input}</a>)
62
+ end
63
+
64
+ def within(url, collection_url)
65
+ "#{collection_url}#{url}"
66
+ end
67
+
68
+ def stylesheet_tag(url)
69
+ %(<link rel="stylesheet" href="#{url}" type="text/css">)
70
+ end
71
+
72
+ def script_tag(url)
73
+ %(<script src="#{url}"></script>)
74
+ end
75
+
76
+ # Collection / pagination helpers
77
+ def paginate(input, page_size)
78
+ input
79
+ end
80
+
81
+ def default_pagination(_input)
82
+ ""
83
+ end
84
+
85
+ # JSON / data
86
+ def json(input)
87
+ require "json"
88
+ input.to_json
89
+ end
90
+
91
+ # Translation stub
92
+ def t(input)
93
+ input.to_s
94
+ end
95
+
96
+ # Placeholder image
97
+ def placeholder_svg_tag(type)
98
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#eee"/><text x="50" y="50" text-anchor="middle" dy=".3em" fill="#999">#{type}</text></svg>)
99
+ end
100
+
101
+ # Color filters
102
+ def color_to_rgb(input)
103
+ input.to_s
104
+ end
105
+
106
+ def color_modify(input, _attr, _value)
107
+ input.to_s
108
+ end
109
+
110
+ def color_to_hex(input)
111
+ input.to_s
112
+ end
113
+
114
+ def color_brightness(input)
115
+ 128
116
+ end
117
+
118
+ # Font filters
119
+ def font_modify(input, _attr, _value)
120
+ input
121
+ end
122
+
123
+ def font_url(input)
124
+ ""
125
+ end
126
+
127
+ def font_face(input)
128
+ ""
129
+ end
130
+
131
+ # Media filters
132
+ def external_video_url(input, **_opts)
133
+ input.to_s
134
+ end
135
+
136
+ def media_tag(input, **_opts)
137
+ %(<div class="media-placeholder">#{input}</div>)
138
+ end
139
+
140
+ # Metafield
141
+ def metafield_tag(input)
142
+ input.to_s
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Liquidbook
6
+ # Provides mock Shopify objects for template rendering
7
+ class MockData
8
+ DEFAULT_FIXTURES_PATH = File.expand_path("../../fixtures/default_mocks.yml", __dir__)
9
+
10
+ def initialize(theme_root: nil)
11
+ @theme_root = theme_root || Liquidbook.root
12
+ @data = load_data
13
+ end
14
+
15
+ def to_assigns
16
+ @data.dup
17
+ end
18
+
19
+ # Merge section schema defaults into the data
20
+ def with_section(schema_parser)
21
+ assigns = to_assigns
22
+ assigns["section"] = {
23
+ "settings" => schema_parser.default_settings,
24
+ "blocks" => schema_parser.default_blocks
25
+ }
26
+ assigns
27
+ end
28
+
29
+ private
30
+
31
+ def load_data
32
+ data = load_yaml(DEFAULT_FIXTURES_PATH)
33
+
34
+ # Override with user's custom mocks if present
35
+ user_mocks = File.join(@theme_root, ".liquid-preview", "mocks.yml")
36
+ data = deep_merge(data, load_yaml(user_mocks)) if File.exist?(user_mocks)
37
+
38
+ data
39
+ end
40
+
41
+ def load_yaml(path)
42
+ return {} unless File.exist?(path)
43
+
44
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time]) || {}
45
+ end
46
+
47
+ def deep_merge(base, override)
48
+ base.merge(override) do |_key, old_val, new_val|
49
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
50
+ deep_merge(old_val, new_val)
51
+ else
52
+ new_val
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end