liquidbook 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c2c697f9bb5b094c752a0798ef24b5bf0265f60c5ed490abe4b9ad2abbb2d60
4
- data.tar.gz: dda835eaec1bef74610452dfe1596a1b517122ca9e66a852592aa5ca79a43700
3
+ metadata.gz: 0ff58ef9ec9c365f0d4d43ee4858493a3e8986a23ff705bef68c8a87ac2b6815
4
+ data.tar.gz: 6880caf5653cb43b33efbdf0438ecda8577d263bb827938344734a803a84e79e
5
5
  SHA512:
6
- metadata.gz: a5ff5a395bdf717724ebec570cb4fb8ff039d4cd9940dbb2df6320e402e0ae18511d5ec2d1ebeca9f598b73f0b81cc96977bae7bcb996117fc0e07e8380d10ff
7
- data.tar.gz: 731b75e5e253632d37d58033f49d12c4ef74820397212750174bc2e66b9d01c8ad980be48108197effefab90ec4009c6d57becd4b494793f17f0471659665efa
6
+ metadata.gz: 24a7f538b9a52aa7860fd172addf9c0c3cfbdfd244371b122c138240d08680750a108bc01802c2c0ea83235b624ba5a1fbd812991d9a617be6b4ee026f8d27c1
7
+ data.tar.gz: c2d92ed42b16be71375b8457896307d98e9a83ebfa28639dab13151d50718a4b69905ebdd18856593d91f0a91952e8264f1dab2e7ffebfdf79828e55d49cb7ac
@@ -0,0 +1,24 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(ls:*)",
5
+ "Bash(grep:*)",
6
+ "Bash(find:*)",
7
+ "Bash(git add:*)",
8
+ "Bash(git commit -m ':*)",
9
+ "Bash(git push:*)",
10
+ "WebFetch(domain:guides.rubygems.org)"
11
+ ],
12
+ "deny": [
13
+ "Bash(rm -rf /)",
14
+ "Bash(rm -rf ~)",
15
+ "Bash(*--force*push*)",
16
+ "Bash(*push --force*)",
17
+ "Bash(*push -f*)",
18
+ "Bash(curl:*)",
19
+ "Read(./.env)",
20
+ "Read(./.env.*)",
21
+ "Read(./secrets/**)"
22
+ ]
23
+ }
24
+ }
data/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-05-03
11
+
12
+ ### Added
13
+
14
+ - `TemplateAnalyzer`: Liquid AST を走査してテンプレートで参照される変数を自動検出([ADR-0001](docs/adr/0001-template-variable-detection-strategy.md))
15
+ - `ParameterMerger`: 自動検出した変数と `@param` メタデータをマージして `ThemeRenderer` に統合
16
+ - `FilterTypeMap` によるフィルタベースの型推論(適用されているフィルタ名から変数の型を推定)
17
+ - サイドバーにテンプレート検索窓を追加(`q` URL パラメータで画面遷移後も検索状態を保持)
18
+ - サイドバー初期表示時にアクティブなテンプレートまで自動スクロール
19
+ - 動作確認用 `example/` テーマ(`bundle exec liquidbook server -r example` で起動)
20
+ - ADR-0001: テンプレート変数検出戦略の決定記録
21
+
22
+ ### Changed
23
+
24
+ - カスタム `RenderTag` を削除し公式 Liquid `render` タグ + `ThemeFileSystem` に置き換え(探索対象を `snippets/` のみに限定し、`ThemeRenderer` で `ThemeFileSystem` をキャッシュ)
25
+ - スニペットパラメータ編集フォームを GET + クエリパラメータ方式に変更し、URL でプレビュー状態をブックマーク/共有可能に(`Rendered HTML` ビューも同期)
26
+ - `example/` ディレクトリを gem パッケージから除外
27
+
28
+ ### Removed
29
+
30
+ - `POST /api/render/:type/:name` および `GET /api/render/:type/:name` エンドポイント、および 1.5 秒間隔のライブリロードポーリング
31
+
32
+ ## [0.1.1] - 2026-04-09
33
+
34
+ ### Added
35
+
36
+ - グレースフルなサーバー停止のための PID ファイル管理と `stop` コマンド
37
+ - CD ワークフロー: タグ push 時に CHANGELOG から GitHub Release を自動生成
38
+ - CD ワークフロー: `workflow_dispatch` による RubyGems への手動公開
39
+
40
+ ### Changed
41
+
42
+ - CI ワークフローのファイル名を `test.yml` から `ci.yml` にリネーム
43
+
44
+ ## [0.1.0] - 2026-04-07
45
+
46
+ ### Added
47
+
48
+ - モックデータをサポートする Liquid テンプレートレンダリングエンジン
49
+ - Sinatra によるブラウザベースのプレビューサーバー
50
+ - Thor による CLI コマンド (`start`, `stop`)
51
+ - Listen による自動リロード対応のファイル監視
52
+ - セクション/スニペットテンプレートのサポート
53
+ - RSpec によるユニットテストと統合テスト
54
+ - GitHub Actions による CI パイプライン (Ruby 3.2 - 4.0)
data/CLAUDE.md ADDED
@@ -0,0 +1,17 @@
1
+ # CLAUDE.md
2
+
3
+ ## コーディング方針
4
+
5
+ - コードを変更・追加するとき、設計判断の「なぜ(Why)」が自明でなければコメントを残す
6
+ - 「何をしているか(What)」はコードで表現する。コメントにしない
7
+ - 例: Shopify 互換性のために本来不要な処理をしている、パフォーマンス上の理由で構造を変えた、等
8
+
9
+ ## プロジェクト概要
10
+
11
+ - Shopify Liquid テンプレートのプレビューサーバー(Storybook 的なツール)
12
+ - Ruby gem(Liquid 5.0 / Sinatra / Puma)
13
+
14
+ ## 開発コマンド
15
+
16
+ - テスト: `bundle exec rspec`
17
+ - サーバー起動: `bundle exec liquidbook serve`
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Liquidbook
2
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)
3
+ [![CI](https://github.com/sena-m09/liquidbook/actions/workflows/ci.yml/badge.svg)](https://github.com/sena-m09/liquidbook/actions/workflows/ci.yml)
4
4
 
5
5
  A Storybook-like local preview server for Shopify Liquid templates. Browse and preview your sections and snippets in the browser instantly.
6
6
 
@@ -78,6 +78,14 @@ cd liquidbook
78
78
  bin/setup
79
79
  ```
80
80
 
81
+ Run the preview server against the bundled example theme:
82
+
83
+ ```bash
84
+ bundle exec liquidbook server -r example
85
+ ```
86
+
87
+ See [Release Process](docs/RELEASING.md) for how to publish a new version.
88
+
81
89
  ## License
82
90
 
83
91
  [MIT License](https://opensource.org/licenses/MIT)
data/docs/README.ja.md CHANGED
@@ -76,6 +76,8 @@ cd liquidbook
76
76
  bin/setup
77
77
  ```
78
78
 
79
+ 新しいバージョンのリリース方法は [Release Process](RELEASING.md) を参照してください。
80
+
79
81
  ## ライセンス
80
82
 
81
83
  [MIT License](https://opensource.org/licenses/MIT)
data/docs/RELEASING.md ADDED
@@ -0,0 +1,51 @@
1
+ # Release Process
2
+
3
+ ## CI/CD ワークフロー構成
4
+
5
+ | ワークフロー | ファイル | トリガー | 内容 |
6
+ |---|---|---|---|
7
+ | CI | `ci.yml` | push (main) / PR | テスト実行 (Ruby 3.2 - 4.0) |
8
+ | GitHub Release | `github_release.yml` | `v*` タグ push | CHANGELOG から抽出 → GitHub Release 作成 |
9
+ | RubyGems 公開 | `gem_release.yml` | 手動 (`workflow_dispatch`) | RubyGems に gem push |
10
+
11
+ ## リリース手順
12
+
13
+ ### 1. バージョンと CHANGELOG を更新
14
+
15
+ `lib/liquidbook/version.rb` のバージョンを更新:
16
+
17
+ ```ruby
18
+ VERSION = "X.Y.Z"
19
+ ```
20
+
21
+ `CHANGELOG.md` に変更内容を追記(`[Unreleased]` から新バージョンのセクションへ移動):
22
+
23
+ ```markdown
24
+ ## [X.Y.Z] - YYYY-MM-DD
25
+
26
+ ### Added
27
+ - ...
28
+ ```
29
+
30
+ ### 2. コミットしてタグを打つ
31
+
32
+ ```bash
33
+ git add lib/liquidbook/version.rb CHANGELOG.md
34
+ git commit -m "chore: bump version to X.Y.Z"
35
+ git tag vX.Y.Z
36
+ git push origin main --tags
37
+ ```
38
+
39
+ ### 3. GitHub Release を確認
40
+
41
+ タグ push をトリガーに GitHub Release が自動作成されます。CHANGELOG から該当バージョンのノートが抽出されます。
42
+
43
+ [Actions > Create GitHub Release](https://github.com/sena-m09/liquidbook/actions/workflows/github_release.yml) で結果を確認してください。
44
+
45
+ ### 4. RubyGems に公開
46
+
47
+ GitHub Release の内容を確認した後、手動で gem を公開します:
48
+
49
+ [Actions > Publish to RubyGems](https://github.com/sena-m09/liquidbook/actions/workflows/gem_release.yml) → "Run workflow" を実行
50
+
51
+ > gem push を手動トリガーにしているのは、公開前に最終確認できるようにするためです。
@@ -0,0 +1,69 @@
1
+ # ADR-0001: テンプレート変数検出の戦略
2
+
3
+ - **Status**: Accepted
4
+ - **Date**: 2026-04-21
5
+ - **Deciders**: @sena-m09
6
+
7
+ ## Context
8
+
9
+ Liquidbook はテンプレートのプレビューに必要な変数(パラメータ)を知る必要がある。
10
+ 現在は2つの方法で宣言的に定義している:
11
+
12
+ - **sections**: `{% schema %}` ブロック内の JSON で `settings` / `blocks` を定義(Shopify 公式仕様)
13
+ - **snippets**: `@param` JSDoc 風コメントで型・デフォルト値・説明を記述
14
+
15
+ この方式には以下の課題がある:
16
+
17
+ 1. `@param` を書かない snippet は変数が一切検出されず、プレビューフォームが空になる
18
+ 2. 宣言と実際のテンプレート使用が乖離しても検知できない(書き忘れ、削除忘れ)
19
+ 3. Storybook のような別ファイル方式(`.stories.yml`)の導入も検討したが、Liquid テンプレートの規模感では管理コストが過剰
20
+
21
+ ## Decision
22
+
23
+ **Liquid AST からの変数自動検出を基盤とし、`@param` / `{% schema %}` を補足情報として重ねる方式(方式2)を採用する。**
24
+
25
+ ### 具体的な構成
26
+
27
+ ```
28
+ TemplateAnalyzer (AST 変数抽出)
29
+ |
30
+ v
31
+ 自動検出された変数: [title, price, featured]
32
+ |
33
+ v
34
+ ParamParser / SchemaParser (型・デフォルト値・説明をマージ)
35
+ |
36
+ v
37
+ 最終結果: [title: String "My Card", price: Number 1980, featured: Boolean false]
38
+ ```
39
+
40
+ - `TemplateAnalyzer` が `Liquid::Template.parse` の AST を走査し、外部依存変数を抽出する
41
+ - ループ変数(`for item in ...` の `item`)や `assign` のローカル変数は自動除外する
42
+ - `section.settings.*` / `section.blocks` は schema 由来として分類する
43
+ - `@param` が存在する変数には型・デフォルト値・説明を上書きマージする
44
+ - `@param` がない変数も「unknown 型」としてプレビューフォームに表示する
45
+
46
+ ## Alternatives Considered
47
+
48
+ ### 方式1: 自動検出のみ(宣言ファイル不要)
49
+
50
+ - メリット: 何も書かなくていい
51
+ - デメリット: 型推論に限界がある(`title` が String か Number かは AST からは判断できない)、デフォルト値や説明を付けられない
52
+ - 判断: 自動検出だけでは UI の品質が不十分
53
+
54
+ ### 方式3: 別ファイル(Storybook 式 `.stories.yml`)
55
+
56
+ - メリット: テンプレートが汚れない、variants を複数定義できる
57
+ - デメリット: ファイル管理コスト、テンプレートとの sync 切れリスク
58
+ - 判断: コンポーネント数が数百規模のプロジェクト向け。Liquid テンプレートの規模感では過剰
59
+
60
+ ### 方式2 から方式3 への移行可能性
61
+
62
+ 方式3 に移行する場合でも、TemplateAnalyzer は「`.stories.yml` に書き忘れた変数を警告する」用途でそのまま活用できる。TemplateAnalyzer の実装は無駄にならない。
63
+
64
+ ## Consequences
65
+
66
+ - `TemplateAnalyzer` を新規作成する(Liquid AST 走査)
67
+ - `ParamParser` は TemplateAnalyzer の結果に型情報をマージする役割に変わる
68
+ - `@param` なしの snippet でもプレビューフォームが自動生成される
69
+ - 将来的に `@param` の文法拡張(複合型など)を行う場合、ParamParser の書き換えが必要になるが、TemplateAnalyzer とは独立している
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquidbook
4
+ # Maps Liquid filter names to inferred parameter types.
5
+ # Used by ParameterMerger as a fallback when @param annotations are absent.
6
+ #
7
+ # Type names match ParamParser's normalized types: "text", "number", "checkbox".
8
+ module FilterTypeMap
9
+ FILTER_TYPES = {
10
+ # Numeric filters
11
+ "money" => "number",
12
+ "money_with_currency" => "number",
13
+ "plus" => "number",
14
+ "minus" => "number",
15
+ "times" => "number",
16
+ "divided_by" => "number",
17
+ "modulo" => "number",
18
+ "round" => "number",
19
+ "ceil" => "number",
20
+ "floor" => "number",
21
+ "abs" => "number",
22
+ "at_least" => "number",
23
+ "at_most" => "number",
24
+ # Text filters
25
+ "upcase" => "text",
26
+ "downcase" => "text",
27
+ "capitalize" => "text",
28
+ "strip" => "text",
29
+ "lstrip" => "text",
30
+ "rstrip" => "text",
31
+ "strip_html" => "text",
32
+ "strip_newlines" => "text",
33
+ "newline_to_br" => "text",
34
+ "escape" => "text",
35
+ "escape_once" => "text",
36
+ "url_encode" => "text",
37
+ "url_decode" => "text",
38
+ "truncate" => "text",
39
+ "truncatewords" => "text",
40
+ "append" => "text",
41
+ "prepend" => "text",
42
+ "remove" => "text",
43
+ "remove_first" => "text",
44
+ "replace" => "text",
45
+ "replace_first" => "text",
46
+ "split" => "text",
47
+ "handle" => "text",
48
+ "handleize" => "text",
49
+ "md5" => "text",
50
+ "sha1" => "text",
51
+ "sha256" => "text",
52
+ "base64_encode" => "text",
53
+ "base64_decode" => "text"
54
+ }.freeze
55
+
56
+ # Infer a type from a list of filter names.
57
+ # Returns the type if all filters agree, nil if ambiguous or no filters match.
58
+ def self.infer(filters)
59
+ types = filters.filter_map { |f| FILTER_TYPES[f] }.uniq
60
+ types.size == 1 ? types.first : nil
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquidbook
4
+ # Merges auto-detected template variables (from TemplateAnalyzer) with
5
+ # type/default/description metadata from @param comments (ParamParser).
6
+ #
7
+ # Variables with @param get their declared type/default/description.
8
+ # Variables without @param are type-inferred from filters and control structures
9
+ # (e.g. money → number, for-loop → array, truthy if → checkbox).
10
+ # When inference is inconclusive, type falls back to "unknown".
11
+ # Section variables (name == "section") are excluded.
12
+ #
13
+ # Usage:
14
+ # merger = ParameterMerger.new(
15
+ # variables: analyzer.external_variables,
16
+ # param_defs: param_parser.parse
17
+ # )
18
+ # merger.merge
19
+ # # => [{ "name" => "title", "type" => "text", "default" => "My Card", "description" => "Card heading" },
20
+ # # { "name" => "color", "type" => "unknown", "default" => nil, "description" => nil }]
21
+ class ParameterMerger
22
+ def initialize(variables:, param_defs:)
23
+ @variables = variables
24
+ @param_defs = param_defs
25
+ end
26
+
27
+ def merge
28
+ @variables
29
+ .reject { |var| section_variable?(var) }
30
+ .map { |var| resolve(var) }
31
+ .uniq { |p| p["name"] }
32
+ end
33
+
34
+ private
35
+
36
+ def resolve(var)
37
+ name = var[:name].to_s
38
+ param_index[name] || inferred(var)
39
+ end
40
+
41
+ def param_index
42
+ @param_index ||= @param_defs.each_with_object({}) do |p, h|
43
+ h[p["name"]] = p
44
+ end
45
+ end
46
+
47
+ def section_variable?(var)
48
+ var[:name].to_s == "section"
49
+ end
50
+
51
+ def inferred(var)
52
+ name = var[:name].to_s
53
+ type = infer_type(var)
54
+ { "name" => name, "type" => type, "default" => nil, "description" => nil }
55
+ end
56
+
57
+ def infer_type(var)
58
+ properties = var[:properties] || []
59
+
60
+ return "array" if properties.any? { |p| p[:collection] }
61
+ return "checkbox" if properties.any? { |p| p[:truthy_condition] } && all_filters_empty?(properties)
62
+
63
+ all_filters = properties.flat_map { |p| p[:filters] }.uniq
64
+ FilterTypeMap.infer(all_filters) || "unknown"
65
+ end
66
+
67
+ def all_filters_empty?(properties)
68
+ properties.all? { |p| p[:filters].empty? }
69
+ end
70
+ end
71
+ end
@@ -1,14 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sinatra/base"
4
- require "json"
5
4
 
6
5
  module Liquidbook
7
6
  module Server
8
7
  class App < Sinatra::Base
8
+ RESERVED_QUERY_KEYS = %w[name splat captures q].freeze
9
+
9
10
  set :views, File.expand_path("views", __dir__)
10
11
  set :public_folder, -> { File.join(Liquidbook.root, "assets") }
11
12
 
13
+ before do
14
+ @nav_sections = ThemeRenderer.new(theme_root: Liquidbook.root).sections
15
+ @nav_snippets = ThemeRenderer.new(theme_root: Liquidbook.root).snippets
16
+ @current_path = request.path_info
17
+ end
18
+
12
19
  helpers do
13
20
  def renderer
14
21
  @renderer ||= ThemeRenderer.new(theme_root: Liquidbook.root)
@@ -43,7 +50,6 @@ module Liquidbook
43
50
  @name = name
44
51
  @type = "section"
45
52
  @schema = load_schema("sections", name)
46
- @params = []
47
53
  erb :preview
48
54
  rescue Liquidbook::Error => e
49
55
  status 404
@@ -55,12 +61,13 @@ module Liquidbook
55
61
  get "/snippets/:name" do
56
62
  name = params[:name]
57
63
  begin
58
- overrides = parse_overrides(params)
64
+ @form_params = renderer.snippet_params(name)
65
+ overrides = parse_overrides(params, @form_params)
59
66
  @rendered = renderer.render_snippet(name, overrides: overrides)
60
67
  @name = name
61
68
  @type = "snippet"
62
69
  @schema = {}
63
- @params = renderer.snippet_params(name)
70
+ @form_params = with_overrides_applied(@form_params, overrides)
64
71
  erb :preview
65
72
  rescue Liquidbook::Error => e
66
73
  status 404
@@ -94,47 +101,6 @@ module Liquidbook
94
101
  end
95
102
  end
96
103
 
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
104
  private
139
105
 
140
106
  def load_schema(dir, name)
@@ -144,15 +110,57 @@ module Liquidbook
144
110
  SchemaParser.new(File.read(path)).parse
145
111
  end
146
112
 
147
- def parse_overrides(params)
113
+ # Build override hash from query params, coercing types based on @param metadata.
114
+ # Strings (including Japanese / multibyte) pass through as Rack-decoded UTF-8.
115
+ def parse_overrides(params, params_meta = nil)
116
+ type_by_name = build_type_index(params_meta)
148
117
  overrides = {}
118
+
149
119
  params.each do |key, value|
150
- next if %w[name splat captures].include?(key)
120
+ next if RESERVED_QUERY_KEYS.include?(key)
151
121
 
152
- overrides[key] = value
122
+ overrides[key] = coerce_override(value, type_by_name[key])
153
123
  end
124
+
154
125
  overrides
155
126
  end
127
+
128
+ def build_type_index(params_meta)
129
+ return {} unless params_meta
130
+
131
+ params_meta.each_with_object({}) { |p, h| h[p["name"]] = p["type"] }
132
+ end
133
+
134
+ def coerce_override(value, type)
135
+ case type
136
+ when "checkbox" then value.to_s == "true"
137
+ when "number" then coerce_number(value)
138
+ else value
139
+ end
140
+ end
141
+
142
+ def coerce_number(value)
143
+ str = value.to_s.strip
144
+ return str if str.empty?
145
+
146
+ Integer(str, 10)
147
+ rescue ArgumentError
148
+ begin
149
+ Float(str)
150
+ rescue ArgumentError
151
+ str
152
+ end
153
+ end
154
+
155
+ # Return a new params_meta list with each param's "default" replaced by the
156
+ # current override value, so the form re-renders with submitted values.
157
+ # Non-destructive: original hashes are preserved (snippet_params caching-safe).
158
+ def with_overrides_applied(params_meta, overrides)
159
+ params_meta.map do |p|
160
+ name = p["name"]
161
+ overrides.key?(name) ? p.merge("default" => overrides[name]) : p
162
+ end
163
+ end
156
164
  end
157
165
  end
158
166
  end
@@ -1,16 +1,28 @@
1
+ <style>
2
+ .lp-card {
3
+ display: block;
4
+ padding: 16px;
5
+ background: var(--lp-surface);
6
+ border: 1px solid var(--lp-border);
7
+ border-radius: var(--lp-radius);
8
+ text-decoration: none;
9
+ color: var(--lp-text);
10
+ transition: border-color 0.2s;
11
+ }
12
+ .lp-card:hover {
13
+ border-color: var(--lp-accent);
14
+ }
15
+ </style>
16
+
1
17
  <h2 style="margin-bottom: 16px; font-size: 20px;">Sections</h2>
2
18
  <% if @sections.empty? %>
3
- <p style="color: var(--text-sub);">No sections found in <code>sections/</code></p>
19
+ <p style="color: var(--lp-text-sub);">No sections found in <code>sections/</code></p>
4
20
  <% else %>
5
21
  <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 32px;">
6
22
  <% @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)'">
23
+ <a href="/sections/<%= h(name) %>" class="lp-card">
12
24
  <div style="font-weight: 500;"><%= h(name) %></div>
13
- <div style="font-size: 12px; color: var(--text-sub); margin-top: 4px;">section</div>
25
+ <div style="font-size: 12px; color: var(--lp-text-sub); margin-top: 4px;">section</div>
14
26
  </a>
15
27
  <% end %>
16
28
  </div>
@@ -18,17 +30,13 @@
18
30
 
19
31
  <h2 style="margin-bottom: 16px; font-size: 20px;">Snippets</h2>
20
32
  <% if @snippets.empty? %>
21
- <p style="color: var(--text-sub);">No snippets found in <code>snippets/</code></p>
33
+ <p style="color: var(--lp-text-sub);">No snippets found in <code>snippets/</code></p>
22
34
  <% else %>
23
35
  <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px;">
24
36
  <% @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)'">
37
+ <a href="/snippets/<%= h(name) %>" class="lp-card">
30
38
  <div style="font-weight: 500;"><%= h(name) %></div>
31
- <div style="font-size: 12px; color: var(--text-sub); margin-top: 4px;">snippet</div>
39
+ <div style="font-size: 12px; color: var(--lp-text-sub); margin-top: 4px;">snippet</div>
32
40
  </a>
33
41
  <% end %>
34
42
  </div>
@@ -14,46 +14,205 @@
14
14
  --lp-accent: #5c6ac4;
15
15
  --lp-border: #e0e0e0;
16
16
  --lp-radius: 8px;
17
+ --lp-sidebar-w: 240px;
18
+ --lp-topbar-h: 48px;
17
19
  }
20
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
23
+ background: var(--lp-bg);
24
+ color: var(--lp-text);
25
+ }
26
+
27
+ /* ── Topbar ── */
18
28
  .lp-topbar {
19
29
  background: var(--lp-surface);
20
30
  border-bottom: 1px solid var(--lp-border);
21
- padding: 12px 24px;
31
+ padding: 0 20px;
32
+ height: var(--lp-topbar-h);
22
33
  display: flex;
23
34
  align-items: center;
24
35
  gap: 16px;
25
- position: sticky;
36
+ position: fixed;
26
37
  top: 0;
27
- z-index: 10000;
28
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
38
+ left: 0;
39
+ right: 0;
40
+ z-index: 100;
29
41
  }
30
42
  .lp-topbar h1 {
31
- font-size: 16px;
32
- font-weight: 600;
43
+ font-size: 15px;
44
+ font-weight: 700;
33
45
  color: var(--lp-accent);
34
- margin: 0;
35
46
  }
36
- .lp-topbar nav a {
47
+ .lp-topbar h1 a {
48
+ color: inherit;
49
+ text-decoration: none;
50
+ }
51
+
52
+ /* ── Sidebar ── */
53
+ .lp-sidebar {
54
+ position: fixed;
55
+ top: var(--lp-topbar-h);
56
+ left: 0;
57
+ bottom: 0;
58
+ width: var(--lp-sidebar-w);
59
+ background: var(--lp-surface);
60
+ border-right: 1px solid var(--lp-border);
61
+ overflow-y: auto;
62
+ padding: 16px 0;
63
+ font-size: 13px;
64
+ }
65
+ .lp-sidebar-group {
66
+ margin-bottom: 8px;
67
+ }
68
+ .lp-sidebar-heading {
69
+ padding: 4px 16px;
70
+ font-size: 11px;
71
+ font-weight: 600;
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.05em;
37
74
  color: var(--lp-text-sub);
75
+ }
76
+ .lp-sidebar-item {
77
+ display: block;
78
+ padding: 6px 16px 6px 24px;
79
+ color: var(--lp-text);
38
80
  text-decoration: none;
39
- font-size: 14px;
81
+ border-left: 3px solid transparent;
82
+ transition: background 0.15s, border-color 0.15s;
40
83
  }
41
- .lp-topbar nav a:hover { color: var(--lp-accent); }
42
- .lp-container {
43
- max-width: 1200px;
44
- margin: 0 auto;
84
+ .lp-sidebar-item[hidden] {
85
+ display: none;
86
+ }
87
+ .lp-sidebar-item:hover {
88
+ background: var(--lp-bg);
89
+ }
90
+ .lp-sidebar-item.active {
91
+ background: var(--lp-bg);
92
+ border-left-color: var(--lp-accent);
93
+ color: var(--lp-accent);
94
+ font-weight: 500;
95
+ }
96
+ .lp-sidebar-empty {
97
+ padding: 6px 16px 6px 24px;
98
+ color: var(--lp-text-sub);
99
+ font-style: italic;
100
+ }
101
+ .lp-sidebar-search {
102
+ padding: 8px 12px 12px;
103
+ }
104
+ .lp-sidebar-search-input {
105
+ width: 100%;
106
+ padding: 6px 8px;
107
+ border: 1px solid var(--lp-border);
108
+ border-radius: 4px;
109
+ font-size: 13px;
110
+ font-family: inherit;
111
+ }
112
+ .lp-sidebar-search-input:focus-visible {
113
+ border-color: var(--lp-accent);
114
+ outline: 2px solid var(--lp-accent);
115
+ outline-offset: -1px;
116
+ }
117
+
118
+ /* ── Main ── */
119
+ .lp-main {
120
+ margin-left: var(--lp-sidebar-w);
121
+ margin-top: var(--lp-topbar-h);
45
122
  padding: 24px;
46
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
123
+ min-height: calc(100vh - var(--lp-topbar-h));
47
124
  }
48
125
  </style>
49
126
  </head>
50
127
  <body>
51
128
  <div class="lp-topbar">
52
- <h1>Liquidbook</h1>
53
- <nav><a href="/">Home</a></nav>
129
+ <h1><a href="/">Liquidbook</a></h1>
54
130
  </div>
55
- <div class="lp-container">
131
+
132
+ <nav class="lp-sidebar">
133
+ <div class="lp-sidebar-search">
134
+ <input type="text" class="lp-sidebar-search-input" placeholder="テンプレートを検索">
135
+ </div>
136
+ <div class="lp-sidebar-group">
137
+ <div class="lp-sidebar-heading">Sections</div>
138
+ <% if @nav_sections.empty? %>
139
+ <div class="lp-sidebar-empty">No sections</div>
140
+ <% else %>
141
+ <% @nav_sections.each do |name| %>
142
+ <a href="/sections/<%= h(name) %>"
143
+ class="lp-sidebar-item<%= ' active' if @current_path == "/sections/#{name}" %>">
144
+ <%= h(name) %>
145
+ </a>
146
+ <% end %>
147
+ <% end %>
148
+ </div>
149
+ <div class="lp-sidebar-group">
150
+ <div class="lp-sidebar-heading">Snippets</div>
151
+ <% if @nav_snippets.empty? %>
152
+ <div class="lp-sidebar-empty">No snippets</div>
153
+ <% else %>
154
+ <% @nav_snippets.each do |name| %>
155
+ <a href="/snippets/<%= h(name) %>"
156
+ class="lp-sidebar-item<%= ' active' if @current_path == "/snippets/#{name}" %>">
157
+ <%= h(name) %>
158
+ </a>
159
+ <% end %>
160
+ <% end %>
161
+ </div>
162
+ </nav>
163
+
164
+ <main class="lp-main">
56
165
  <%= yield %>
57
- </div>
166
+ </main>
167
+ <script>
168
+ (function() {
169
+ const input = document.querySelector('.lp-sidebar-search-input');
170
+ const links = document.querySelectorAll('.lp-sidebar-item');
171
+
172
+ function filterSidebar(query) {
173
+ document.querySelectorAll('.lp-sidebar-group').forEach(function(group) {
174
+ const items = group.querySelectorAll('.lp-sidebar-item');
175
+ if (items.length === 0) return;
176
+ let visibleCount = 0;
177
+ items.forEach(function(item) {
178
+ const match = item.textContent.toLowerCase().includes(query);
179
+ item.hidden = !match;
180
+ if (match) visibleCount++;
181
+ });
182
+ group.hidden = visibleCount === 0;
183
+ });
184
+ }
185
+
186
+ function updateLinks(value) {
187
+ links.forEach(function(a) {
188
+ const url = new URL(a.href);
189
+ if (value) {
190
+ url.searchParams.set('q', value);
191
+ } else {
192
+ url.searchParams.delete('q');
193
+ }
194
+ a.href = url;
195
+ });
196
+ }
197
+
198
+ input.addEventListener('input', function() {
199
+ filterSidebar(this.value.toLowerCase());
200
+ updateLinks(this.value);
201
+ });
202
+
203
+ var q = new URLSearchParams(location.search).get('q');
204
+ if (q) {
205
+ input.value = q;
206
+ filterSidebar(q.toLowerCase());
207
+ updateLinks(q);
208
+ }
209
+
210
+ const active = document.querySelector('.lp-sidebar-item.active');
211
+ if (active) {
212
+ const sidebar = document.querySelector('.lp-sidebar');
213
+ sidebar.scrollTop = active.offsetTop;
214
+ }
215
+ })();
216
+ </script>
58
217
  </body>
59
218
  </html>
@@ -38,22 +38,32 @@
38
38
  <div style="width: 320px; flex-shrink: 0; position: sticky; top: 64px;">
39
39
 
40
40
  <!-- Snippet @param editor -->
41
- <% if @params && !@params.empty? %>
41
+ <% if @form_params && !@form_params.empty? %>
42
42
  <div style="background: var(--lp-surface); border: 1px solid var(--lp-border); border-radius: var(--lp-radius); padding: 16px; margin-bottom: 16px;">
43
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| %>
44
+ <form method="get" action="/snippets/<%= h(@name) %>" accept-charset="UTF-8">
45
+ <% if (sidebar_q = request.params["q"]) && !sidebar_q.empty? %>
46
+ <input type="hidden" name="q" value="<%= h(sidebar_q) %>">
47
+ <% end %>
48
+ <% @form_params.each do |param| %>
46
49
  <div style="margin-bottom: 12px;">
47
50
  <label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px;">
48
51
  <%= h(param["name"]) %>
49
52
  <span style="font-weight: normal; color: var(--lp-text-sub);"><%= h(param["description"]) %></span>
50
53
  </label>
51
54
  <% if param["type"] == "checkbox" %>
52
- <input type="checkbox" name="<%= h(param["name"]) %>" <%= "checked" if param["default"] %> data-param>
55
+ <div style="display: flex; gap: 12px; font-size: 13px;">
56
+ <label style="display: flex; align-items: center; gap: 4px;">
57
+ <input type="radio" name="<%= h(param["name"]) %>" value="true" <%= "checked" if param["default"] %>> Yes
58
+ </label>
59
+ <label style="display: flex; align-items: center; gap: 4px;">
60
+ <input type="radio" name="<%= h(param["name"]) %>" value="false" <%= "checked" unless param["default"] %>> No
61
+ </label>
62
+ </div>
53
63
  <% 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>
64
+ <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;">
55
65
  <% 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>
66
+ <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;">
57
67
  <% end %>
58
68
  </div>
59
69
  <% end %>
@@ -128,56 +138,4 @@
128
138
  function setWidth(w) {
129
139
  document.getElementById('preview-frame').style.maxWidth = w;
130
140
  }
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
141
  </script>
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquidbook
4
+ # Analyzes Liquid templates by walking the AST to detect external variable dependencies.
5
+ #
6
+ # Usage:
7
+ # analyzer = TemplateAnalyzer.new(template_source)
8
+ # analyzer.external_variables
9
+ # # => [{ name: "title", properties: [{ lookups: [], filters: ["upcase"] }] },
10
+ # # { name: "product", properties: [{ lookups: ["name"], filters: ["money"] }] }]
11
+ class TemplateAnalyzer
12
+ def initialize(source)
13
+ @source = source
14
+ end
15
+
16
+ def external_variables
17
+ clean_source = strip_schema(@source)
18
+ template = Liquid::Template.parse(clean_source)
19
+ vars = {}
20
+ scope = Scope.new
21
+
22
+ template.root.nodelist&.each { |node| walk(node, vars, scope) }
23
+
24
+ vars.values
25
+ end
26
+
27
+ def section_variable?(var)
28
+ var[:name] == "section"
29
+ end
30
+
31
+ private
32
+
33
+ # Tracks local variables with proper scoping for for-loops.
34
+ # Uses a parent chain so child scopes see parent locals,
35
+ # while scoped variables (loop vars) don't leak upward.
36
+ class Scope
37
+ def initialize(parent = nil)
38
+ @parent = parent
39
+ @locals = []
40
+ end
41
+
42
+ def add_local(name)
43
+ @locals << name
44
+ end
45
+
46
+ def local?(name)
47
+ @locals.include?(name) || @parent&.local?(name) || false
48
+ end
49
+
50
+ def child_with(extra_locals)
51
+ child = Scope.new(self)
52
+ extra_locals.each { |l| child.add_local(l) }
53
+ child
54
+ end
55
+ end
56
+
57
+ SCHEMA_REGEX = /\{%-?\s*schema\s*-?%\}.*?\{%-?\s*endschema\s*-?%\}/m
58
+
59
+ def strip_schema(source)
60
+ source.gsub(SCHEMA_REGEX, "")
61
+ end
62
+
63
+ def walk(node, vars, scope)
64
+ case node
65
+ when Liquid::Variable
66
+ collect_variable(node, vars, scope)
67
+ when Liquid::For
68
+ walk_for(node, vars, scope)
69
+ return
70
+ when Liquid::Assign
71
+ scope.add_local(node.instance_variable_get(:@to).to_s)
72
+ when Liquid::Capture
73
+ scope.add_local(node.instance_variable_get(:@to).to_s)
74
+ when Liquid::If
75
+ walk_if(node, vars, scope)
76
+ return
77
+ end
78
+
79
+ walk_nodelist(node, vars, scope)
80
+ end
81
+
82
+ def walk_for(node, vars, scope)
83
+ coll = node.collection_name
84
+ add_lookup(vars, coll, scope, collection: true) if coll.is_a?(Liquid::VariableLookup)
85
+
86
+ inner_scope = scope.child_with([node.variable_name, "forloop"])
87
+ node.nodelist&.each { |child| walk(child, vars, inner_scope) }
88
+
89
+ else_block = node.instance_variable_get(:@else_block)
90
+ else_block&.nodelist&.each { |child| walk(child, vars, scope) }
91
+ end
92
+
93
+ def walk_if(node, vars, scope)
94
+ node.blocks.each do |condition|
95
+ walk_condition(condition, vars, scope)
96
+ condition.attachment&.nodelist&.each { |child| walk(child, vars, scope) }
97
+ end
98
+ end
99
+
100
+ def walk_condition(condition, vars, scope)
101
+ return unless condition
102
+
103
+ left = condition.left
104
+ right = condition.right
105
+ operator = condition.instance_variable_get(:@operator)
106
+ truthy = left.is_a?(Liquid::VariableLookup) && operator.nil? && right.nil?
107
+
108
+ add_lookup(vars, left, scope, truthy_condition: truthy) if left.is_a?(Liquid::VariableLookup)
109
+ add_lookup(vars, right, scope) if right.is_a?(Liquid::VariableLookup)
110
+
111
+ child = condition.child_condition
112
+ walk_condition(child, vars, scope) if child
113
+ end
114
+
115
+ def collect_variable(node, vars, scope)
116
+ vl = node.is_a?(Liquid::Variable) ? node.name : nil
117
+ if vl.is_a?(Liquid::VariableLookup)
118
+ filters = node.filters&.map { |name, _, _| name } || []
119
+ add_lookup(vars, vl, scope, filters: filters)
120
+ end
121
+
122
+ return unless node.is_a?(Liquid::Variable)
123
+
124
+ node.filters&.each do |_name, args, kwargs|
125
+ args&.each { |a| add_lookup(vars, a, scope) if a.is_a?(Liquid::VariableLookup) }
126
+ kwargs&.each_value { |v| add_lookup(vars, v, scope) if v.is_a?(Liquid::VariableLookup) }
127
+ end
128
+ end
129
+
130
+ def add_lookup(vars, lookup, scope, filters: [], collection: false, truthy_condition: false)
131
+ return unless lookup.is_a?(Liquid::VariableLookup)
132
+ return if scope.local?(lookup.name)
133
+
134
+ key = lookup.name
135
+ vars[key] ||= { name: key, properties: [] }
136
+
137
+ prop = vars[key][:properties].find { |p| p[:lookups] == lookup.lookups }
138
+ unless prop
139
+ prop = { lookups: lookup.lookups, filters: [] }
140
+ vars[key][:properties] << prop
141
+ end
142
+
143
+ prop[:filters].concat(filters)
144
+ prop[:filters].uniq!
145
+ prop[:collection] = true if collection
146
+ prop[:truthy_condition] = true if truthy_condition
147
+ end
148
+
149
+ def walk_nodelist(node, vars, scope)
150
+ return unless node.respond_to?(:nodelist)
151
+
152
+ node.nodelist&.each { |child| walk(child, vars, scope) }
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquidbook
4
+ # FileSystem implementation for Liquid's render tag.
5
+ # Resolves template names by searching snippets/ directory.
6
+ class ThemeFileSystem
7
+ VALID_NAME = /\A[a-zA-Z0-9_-]+\z/
8
+
9
+ def initialize(theme_root)
10
+ @theme_root = theme_root
11
+ end
12
+
13
+ def read_template_file(template_name)
14
+ raise Liquid::FileSystemError, "Illegal template name '#{template_name}'" unless VALID_NAME.match?(template_name)
15
+
16
+ path = full_path(template_name)
17
+ raise Liquid::FileSystemError, "No such template '#{template_name}'" unless path
18
+
19
+ File.read(path)
20
+ end
21
+
22
+ private
23
+
24
+ def full_path(template_name)
25
+ path = File.join(@theme_root, "snippets", "#{template_name}.liquid")
26
+ path if File.exist?(path)
27
+ end
28
+ end
29
+ end
@@ -46,16 +46,20 @@ module Liquidbook
46
46
  template = Liquid::Template.parse(template_source, environment: Liquidbook.environment)
47
47
  template.render(
48
48
  assigns,
49
- registers: { theme_root: @theme_root }
49
+ registers: {
50
+ theme_root: @theme_root,
51
+ file_system: theme_file_system
52
+ }
50
53
  )
51
54
  end
52
55
 
53
- # Extract @param definitions from a snippet
56
+ # Extract merged parameter definitions for a snippet.
57
+ # Combines TemplateAnalyzer auto-detected variables with @param metadata.
54
58
  def snippet_params(name)
55
59
  path = File.join(@theme_root, "snippets", "#{name}.liquid")
56
60
  return [] unless File.exist?(path)
57
61
 
58
- ParamParser.new(File.read(path)).parse
62
+ merged_params(File.read(path))
59
63
  end
60
64
 
61
65
  # List available sections
@@ -71,7 +75,21 @@ module Liquidbook
71
75
  private
72
76
 
73
77
  def snippet_defaults(source)
74
- ParamParser.new(source).default_assigns
78
+ merged_params(source).each_with_object({}) do |param, hash|
79
+ next if param["type"] == "unknown"
80
+
81
+ hash[param["name"]] = param["default"]
82
+ end
83
+ end
84
+
85
+ def merged_params(source)
86
+ variables = TemplateAnalyzer.new(source).external_variables
87
+ param_defs = ParamParser.new(source).parse
88
+ ParameterMerger.new(variables: variables, param_defs: param_defs).merge
89
+ end
90
+
91
+ def theme_file_system
92
+ @theme_file_system ||= ThemeFileSystem.new(@theme_root)
75
93
  end
76
94
 
77
95
  def list_templates(dir)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Liquidbook
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/liquidbook.rb CHANGED
@@ -5,10 +5,13 @@ require_relative "liquidbook/version"
5
5
  require_relative "liquidbook/config"
6
6
  require_relative "liquidbook/schema_parser"
7
7
  require_relative "liquidbook/param_parser"
8
+ require_relative "liquidbook/template_analyzer"
9
+ require_relative "liquidbook/filter_type_map"
10
+ require_relative "liquidbook/parameter_merger"
8
11
  require_relative "liquidbook/mock_data"
9
12
  require_relative "liquidbook/filters/shopify_filters"
10
13
  require_relative "liquidbook/tags/section_tag"
11
- require_relative "liquidbook/tags/render_tag"
14
+ require_relative "liquidbook/theme_file_system"
12
15
  require_relative "liquidbook/pid_manager"
13
16
  require_relative "liquidbook/theme_renderer"
14
17
  require_relative "liquidbook/server/app"
@@ -43,7 +46,6 @@ module Liquidbook
43
46
  Liquid::Environment.build do |e|
44
47
  e.register_filter(Filters::ShopifyFilters)
45
48
  e.register_tag("section", Tags::SectionTag)
46
- e.register_tag("render", Tags::RenderTag)
47
49
  end
48
50
  end
49
51
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liquidbook
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sena-m09
@@ -102,27 +102,35 @@ executables:
102
102
  extensions: []
103
103
  extra_rdoc_files: []
104
104
  files:
105
+ - ".claude/settings.json"
105
106
  - ".rspec"
107
+ - CHANGELOG.md
108
+ - CLAUDE.md
106
109
  - LICENSE
107
110
  - README.md
108
111
  - Rakefile
109
112
  - docs/README.ja.md
113
+ - docs/RELEASING.md
114
+ - docs/adr/0001-template-variable-detection-strategy.md
110
115
  - exe/liquidbook
111
116
  - fixtures/default_mocks.yml
112
117
  - lib/liquidbook.rb
113
118
  - lib/liquidbook/cli.rb
114
119
  - lib/liquidbook/config.rb
120
+ - lib/liquidbook/filter_type_map.rb
115
121
  - lib/liquidbook/filters/shopify_filters.rb
116
122
  - lib/liquidbook/mock_data.rb
117
123
  - lib/liquidbook/param_parser.rb
124
+ - lib/liquidbook/parameter_merger.rb
118
125
  - lib/liquidbook/pid_manager.rb
119
126
  - lib/liquidbook/schema_parser.rb
120
127
  - lib/liquidbook/server/app.rb
121
128
  - lib/liquidbook/server/views/index.erb
122
129
  - lib/liquidbook/server/views/layout.erb
123
130
  - lib/liquidbook/server/views/preview.erb
124
- - lib/liquidbook/tags/render_tag.rb
125
131
  - lib/liquidbook/tags/section_tag.rb
132
+ - lib/liquidbook/template_analyzer.rb
133
+ - lib/liquidbook/theme_file_system.rb
126
134
  - lib/liquidbook/theme_renderer.rb
127
135
  - lib/liquidbook/version.rb
128
136
  - sig/liquidbook.rbs
@@ -147,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
155
  - !ruby/object:Gem::Version
148
156
  version: '0'
149
157
  requirements: []
150
- rubygems_version: 3.6.9
158
+ rubygems_version: 4.0.6
151
159
  specification_version: 4
152
160
  summary: Storybook-like preview server for Shopify Liquid sections and snippets
153
161
  test_files: []
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Liquidbook
4
- module Tags
5
- # Handles {% render 'snippet' %} and {% render 'snippet' with object %}
6
- # Also handles {% render 'snippet' for collection %}
7
- class RenderTag < Liquid::Tag
8
- SYNTAX = /['"]([^'"]+)['"](?:\s+(?:with|for)\s+(\S+)(?:\s+as\s+(\w+))?)?/
9
-
10
- def initialize(tag_name, markup, tokens)
11
- super
12
- if markup.strip =~ SYNTAX
13
- @snippet_name = Regexp.last_match(1)
14
- @variable_expr = Regexp.last_match(2)
15
- @alias_name = Regexp.last_match(3)
16
- else
17
- # Try simple variable assignments: {% render 'snippet', var: value %}
18
- if markup.strip =~ /['"]([^'"]+)['"]\s*,?\s*(.*)/
19
- @snippet_name = Regexp.last_match(1)
20
- @inline_vars = parse_inline_vars(Regexp.last_match(2))
21
- else
22
- raise Liquid::SyntaxError, "Invalid syntax for render tag: #{markup}"
23
- end
24
- end
25
- end
26
-
27
- def render(context)
28
- theme_root = context.registers[:theme_root] || Liquidbook.root
29
- snippet_path = find_snippet(theme_root)
30
-
31
- unless snippet_path
32
- return "<!-- snippet '#{@snippet_name}' not found -->"
33
- end
34
-
35
- source = File.read(snippet_path)
36
- template = Liquid::Template.parse(source, environment: Liquidbook.environment)
37
-
38
- # Build isolated scope
39
- inner = {}
40
-
41
- if @variable_expr
42
- value = context[@variable_expr]
43
- alias_key = @alias_name || @snippet_name
44
- inner[alias_key] = value
45
- end
46
-
47
- if @inline_vars
48
- @inline_vars.each do |key, expr|
49
- inner[key] = context[expr] || expr
50
- end
51
- end
52
-
53
- # Merge parent assigns with inner scope
54
- parent_assigns = {}
55
- context.environments.each { |env| parent_assigns.merge!(env) if env.is_a?(Hash) }
56
- assigns = parent_assigns.merge(inner)
57
-
58
- template.render(
59
- assigns,
60
- registers: { theme_root: theme_root }
61
- )
62
- end
63
-
64
- private
65
-
66
- def find_snippet(theme_root)
67
- paths = [
68
- File.join(theme_root, "snippets", "#{@snippet_name}.liquid"),
69
- File.join(theme_root, "sections", "#{@snippet_name}.liquid")
70
- ]
71
- paths.find { |p| File.exist?(p) }
72
- end
73
-
74
- def parse_inline_vars(str)
75
- return {} if str.nil? || str.strip.empty?
76
-
77
- vars = {}
78
- str.scan(/(\w+)\s*:\s*([^,]+)/) do |key, value|
79
- vars[key.strip] = value.strip.gsub(/\A['"]|['"]\z/, "")
80
- end
81
- vars
82
- end
83
- end
84
- end
85
- end