liquidbook 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/settings.json +24 -0
- data/CHANGELOG.md +33 -11
- data/CLAUDE.md +17 -0
- data/README.md +6 -0
- data/docs/adr/0001-template-variable-detection-strategy.md +69 -0
- data/lib/liquidbook/filter_type_map.rb +63 -0
- data/lib/liquidbook/parameter_merger.rb +71 -0
- data/lib/liquidbook/server/app.rb +56 -48
- data/lib/liquidbook/server/views/index.erb +22 -14
- data/lib/liquidbook/server/views/layout.erb +177 -18
- data/lib/liquidbook/server/views/preview.erb +16 -58
- data/lib/liquidbook/template_analyzer.rb +155 -0
- data/lib/liquidbook/theme_file_system.rb +29 -0
- data/lib/liquidbook/theme_renderer.rb +22 -4
- data/lib/liquidbook/version.rb +1 -1
- data/lib/liquidbook.rb +4 -2
- metadata +8 -2
- data/lib/liquidbook/tags/render_tag.rb +0 -85
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ff58ef9ec9c365f0d4d43ee4858493a3e8986a23ff705bef68c8a87ac2b6815
|
|
4
|
+
data.tar.gz: 6880caf5653cb43b33efbdf0438ecda8577d263bb827938344734a803a84e79e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
CHANGED
|
@@ -7,26 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
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
|
+
|
|
10
32
|
## [0.1.1] - 2026-04-09
|
|
11
33
|
|
|
12
34
|
### Added
|
|
13
35
|
|
|
14
|
-
- PID
|
|
15
|
-
- CD
|
|
16
|
-
- CD
|
|
36
|
+
- グレースフルなサーバー停止のための PID ファイル管理と `stop` コマンド
|
|
37
|
+
- CD ワークフロー: タグ push 時に CHANGELOG から GitHub Release を自動生成
|
|
38
|
+
- CD ワークフロー: `workflow_dispatch` による RubyGems への手動公開
|
|
17
39
|
|
|
18
40
|
### Changed
|
|
19
41
|
|
|
20
|
-
-
|
|
42
|
+
- CI ワークフローのファイル名を `test.yml` から `ci.yml` にリネーム
|
|
21
43
|
|
|
22
44
|
## [0.1.0] - 2026-04-07
|
|
23
45
|
|
|
24
46
|
### Added
|
|
25
47
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- CLI
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
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
|
@@ -78,6 +78,12 @@ 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
|
+
|
|
81
87
|
See [Release Process](docs/RELEASING.md) for how to publish a new version.
|
|
82
88
|
|
|
83
89
|
## License
|
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
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) %>"
|
|
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) %>"
|
|
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:
|
|
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:
|
|
36
|
+
position: fixed;
|
|
26
37
|
top: 0;
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
left: 0;
|
|
39
|
+
right: 0;
|
|
40
|
+
z-index: 100;
|
|
29
41
|
}
|
|
30
42
|
.lp-topbar h1 {
|
|
31
|
-
font-size:
|
|
32
|
-
font-weight:
|
|
43
|
+
font-size: 15px;
|
|
44
|
+
font-weight: 700;
|
|
33
45
|
color: var(--lp-accent);
|
|
34
|
-
margin: 0;
|
|
35
46
|
}
|
|
36
|
-
.lp-topbar
|
|
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
|
-
|
|
81
|
+
border-left: 3px solid transparent;
|
|
82
|
+
transition: background 0.15s, border-color 0.15s;
|
|
40
83
|
}
|
|
41
|
-
.lp-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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 @
|
|
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
|
|
45
|
-
<%
|
|
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
|
-
<
|
|
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;"
|
|
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;"
|
|
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: {
|
|
49
|
+
registers: {
|
|
50
|
+
theme_root: @theme_root,
|
|
51
|
+
file_system: theme_file_system
|
|
52
|
+
}
|
|
50
53
|
)
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
# Extract
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/liquidbook/version.rb
CHANGED
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/
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- sena-m09
|
|
@@ -102,29 +102,35 @@ executables:
|
|
|
102
102
|
extensions: []
|
|
103
103
|
extra_rdoc_files: []
|
|
104
104
|
files:
|
|
105
|
+
- ".claude/settings.json"
|
|
105
106
|
- ".rspec"
|
|
106
107
|
- CHANGELOG.md
|
|
108
|
+
- CLAUDE.md
|
|
107
109
|
- LICENSE
|
|
108
110
|
- README.md
|
|
109
111
|
- Rakefile
|
|
110
112
|
- docs/README.ja.md
|
|
111
113
|
- docs/RELEASING.md
|
|
114
|
+
- docs/adr/0001-template-variable-detection-strategy.md
|
|
112
115
|
- exe/liquidbook
|
|
113
116
|
- fixtures/default_mocks.yml
|
|
114
117
|
- lib/liquidbook.rb
|
|
115
118
|
- lib/liquidbook/cli.rb
|
|
116
119
|
- lib/liquidbook/config.rb
|
|
120
|
+
- lib/liquidbook/filter_type_map.rb
|
|
117
121
|
- lib/liquidbook/filters/shopify_filters.rb
|
|
118
122
|
- lib/liquidbook/mock_data.rb
|
|
119
123
|
- lib/liquidbook/param_parser.rb
|
|
124
|
+
- lib/liquidbook/parameter_merger.rb
|
|
120
125
|
- lib/liquidbook/pid_manager.rb
|
|
121
126
|
- lib/liquidbook/schema_parser.rb
|
|
122
127
|
- lib/liquidbook/server/app.rb
|
|
123
128
|
- lib/liquidbook/server/views/index.erb
|
|
124
129
|
- lib/liquidbook/server/views/layout.erb
|
|
125
130
|
- lib/liquidbook/server/views/preview.erb
|
|
126
|
-
- lib/liquidbook/tags/render_tag.rb
|
|
127
131
|
- lib/liquidbook/tags/section_tag.rb
|
|
132
|
+
- lib/liquidbook/template_analyzer.rb
|
|
133
|
+
- lib/liquidbook/theme_file_system.rb
|
|
128
134
|
- lib/liquidbook/theme_renderer.rb
|
|
129
135
|
- lib/liquidbook/version.rb
|
|
130
136
|
- sig/liquidbook.rbs
|
|
@@ -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
|