diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..3dbb2fe
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,27 @@
+name: Ruby
+
+on:
+ push:
+ branches:
+ - master
+
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Ruby ${{ matrix.ruby }}
+ strategy:
+ matrix:
+ ruby:
+ - '3.2.4'
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+ - name: Make sure assets can compile in the dummy app
+ run: bundle exec rails app:assets:precompile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..840e793
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,84 @@
+name: Release Gem
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ build:
+ name: Build Gem
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Run NPM install
+ run: npm install
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+ - name: Build gem
+ run: bundle exec rake build
+ - name: List gem
+ run: |
+ find pkg
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ path: pkg/*.gem
+
+ test:
+ runs-on: ubuntu-latest
+ name: Test gem
+ needs:
+ - build
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ path: 'pkg'
+ - name: List gem
+ run: |
+ find pkg
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ - name: Install gem
+ run: |
+ gem install pkg/artifact/*.gem
+
+ push:
+ name: Push Gem to Server
+ runs-on: ubuntu-latest
+ needs:
+ - test
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ path: pkg
+ - name: List gems
+ run: |
+ find pkg
+ - name: Set up GitHub Packages authentication
+ run: |
+ mkdir -p ~/.gem
+ cat > ~/.gem/credentials <<'CREDENTIALS'
+ ---
+ :github: Bearer ${{ secrets.GITHUB_TOKEN }}
+ CREDENTIALS
+ chmod 0600 ~/.gem/credentials
+ - name: Push gem
+ run: |
+ find pkg/artifact -name '*.gem' | while read -r gem; do
+ echo "=== pushing '${gem}'"
+ gem push --key github --host https://rubygems.pkg.github.com/hedgeyedev "${gem}"
+ done
+ - name: Clean up credentials
+ run: |
+ rm -rvf ~/.gem/credentials
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..8499e97
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,28 @@
+name: Ruby
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ ruby-version: ['3.1', '3.2', '3.3']
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1.163.0
+ with:
+ ruby-version: ${{ matrix.ruby-version }}
+ bundler-cache: true
+ - name: Run tests
+ run: bundle exec rails test
diff --git a/.github_changelog_generator b/.github_changelog_generator
new file mode 100644
index 0000000..955b22e
--- /dev/null
+++ b/.github_changelog_generator
@@ -0,0 +1,4 @@
+project=phlex_preview
+unreleased=false
+future-release=0.2.0
+since-tag=0.1.0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7ca49db
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,20 @@
+/.bundle/
+/doc/
+/log/*.log
+/pkg/
+/tmp/
+/test/dummy/db/*.sqlite3
+/test/dummy/db/*.sqlite3-*
+/test/dummy/log/*.log
+/test/dummy/storage/
+/test/dummy/tmp/
+/test/dummy/public/assets/
+*.gem
+coverage/
+.DS_Store
+/.idea/
+/.ruby-lsp/
+app/assets/builds
+bun.lockb
+node_modules/
+package.json
diff --git a/.ruby-gemset b/.ruby-gemset
new file mode 100644
index 0000000..058a5f0
--- /dev/null
+++ b/.ruby-gemset
@@ -0,0 +1 @@
+phlex_preview
diff --git a/.ruby-version b/.ruby-version
index be94e6f..351227f 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.2.2
+3.2.4
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/Gemfile b/Gemfile
index 4dcd185..78b3eca 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,10 +1,11 @@
-# frozen_string_literal: true
-
source "https://rubygems.org"
+git_source(:github) { |repo| "https://github.com/#{repo}.git" }
-# gem "rails"
-
-gem "phlex", "~> 1.10"
-gem "roda", "~> 3.79"
+gemspec
-gem "rouge", "~> 4.2"
+group :development do
+ gem "puma"
+ gem "sprockets-rails"
+ gem "foreman"
+ gem "github_changelog_generator", "~> 1.16"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index b3be6a4..4d690be 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,20 +1,325 @@
+PATH
+ remote: .
+ specs:
+ phlex_storybook (0.1.0)
+ importmap-rails
+ phlex-icons (~> 0.11.0)
+ phlex-rails (~> 1.2)
+ rails (>= 7.2)
+ rouge (~> 4.3.0)
+ stimulus-rails
+ tailwindcss-rails (~> 2.7)
+ turbo-rails
+ turbo_power (~> 0.6.2)
+
GEM
remote: https://rubygems.org/
specs:
- phlex (1.10.2)
- rack (3.0.10)
- roda (3.79.0)
- rack
- rouge (4.2.1)
+ actioncable (7.2.1)
+ actionpack (= 7.2.1)
+ activesupport (= 7.2.1)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (7.2.1)
+ actionpack (= 7.2.1)
+ activejob (= 7.2.1)
+ activerecord (= 7.2.1)
+ activestorage (= 7.2.1)
+ activesupport (= 7.2.1)
+ mail (>= 2.8.0)
+ actionmailer (7.2.1)
+ actionpack (= 7.2.1)
+ actionview (= 7.2.1)
+ activejob (= 7.2.1)
+ activesupport (= 7.2.1)
+ mail (>= 2.8.0)
+ rails-dom-testing (~> 2.2)
+ actionpack (7.2.1)
+ actionview (= 7.2.1)
+ activesupport (= 7.2.1)
+ nokogiri (>= 1.8.5)
+ racc
+ rack (>= 2.2.4, < 3.2)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ useragent (~> 0.16)
+ actiontext (7.2.1)
+ actionpack (= 7.2.1)
+ activerecord (= 7.2.1)
+ activestorage (= 7.2.1)
+ activesupport (= 7.2.1)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (7.2.1)
+ activesupport (= 7.2.1)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (7.2.1)
+ activesupport (= 7.2.1)
+ globalid (>= 0.3.6)
+ activemodel (7.2.1)
+ activesupport (= 7.2.1)
+ activerecord (7.2.1)
+ activemodel (= 7.2.1)
+ activesupport (= 7.2.1)
+ timeout (>= 0.4.0)
+ activestorage (7.2.1)
+ actionpack (= 7.2.1)
+ activejob (= 7.2.1)
+ activerecord (= 7.2.1)
+ activesupport (= 7.2.1)
+ marcel (~> 1.0)
+ activesupport (7.2.1)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
+ async (2.16.1)
+ console (~> 1.26)
+ fiber-annotation
+ io-event (~> 1.6, >= 1.6.5)
+ async-http (0.75.0)
+ async (>= 2.10.2)
+ async-pool (~> 0.7)
+ io-endpoint (~> 0.11)
+ io-stream (~> 0.4)
+ protocol-http (~> 0.30)
+ protocol-http1 (~> 0.20)
+ protocol-http2 (~> 0.18)
+ traces (>= 0.10)
+ async-http-faraday (0.19.0)
+ async-http (~> 0.42)
+ faraday
+ async-pool (0.8.1)
+ async (>= 1.25)
+ metrics
+ traces
+ base64 (0.2.0)
+ bigdecimal (3.1.8)
+ builder (3.3.0)
+ concurrent-ruby (1.3.4)
+ connection_pool (2.4.1)
+ console (1.27.0)
+ fiber-annotation
+ fiber-local (~> 1.1)
+ json
+ crass (1.0.6)
+ date (3.3.4)
+ drb (2.2.1)
+ erubi (1.13.0)
+ faraday (2.11.0)
+ faraday-net_http (>= 2.0, < 3.4)
+ logger
+ faraday-http-cache (2.5.1)
+ faraday (>= 0.8)
+ faraday-net_http (3.3.0)
+ net-http
+ fiber-annotation (0.2.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (1.0.0)
+ foreman (0.88.1)
+ github_changelog_generator (1.16.4)
+ activesupport
+ async (>= 1.25.0)
+ async-http-faraday
+ faraday-http-cache
+ multi_json
+ octokit (~> 4.6)
+ rainbow (>= 2.2.1)
+ rake (>= 10.0)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
+ i18n (1.14.5)
+ concurrent-ruby (~> 1.0)
+ importmap-rails (2.0.1)
+ actionpack (>= 6.0.0)
+ activesupport (>= 6.0.0)
+ railties (>= 6.0.0)
+ io-console (0.7.2)
+ io-endpoint (0.13.1)
+ io-event (1.6.5)
+ io-stream (0.4.0)
+ irb (1.14.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.7.2)
+ logger (1.6.0)
+ loofah (2.22.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ mail (2.8.1)
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.0.4)
+ metrics (0.10.2)
+ mini_mime (1.1.5)
+ minitest (5.25.1)
+ multi_json (1.15.0)
+ net-http (0.4.1)
+ uri
+ net-imap (0.4.15)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.5.0)
+ net-protocol
+ nio4r (2.7.3)
+ nokogiri (1.16.7-aarch64-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-arm-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86_64-linux)
+ racc (~> 1.4)
+ octokit (4.25.1)
+ faraday (>= 1, < 3)
+ sawyer (~> 0.9)
+ phlex (1.10.3)
+ phlex-icons (0.11.0)
+ phlex (~> 1.10)
+ phlex-rails (1.2.1)
+ phlex (~> 1.10.0)
+ railties (>= 6.1, < 8)
+ protocol-hpack (1.5.0)
+ protocol-http (0.33.0)
+ protocol-http1 (0.22.0)
+ protocol-http (~> 0.22)
+ protocol-http2 (0.18.0)
+ protocol-hpack (~> 1.4)
+ protocol-http (~> 0.18)
+ psych (5.1.2)
+ stringio
+ public_suffix (6.0.1)
+ puma (6.4.2)
+ nio4r (~> 2.0)
+ racc (1.8.1)
+ rack (3.1.7)
+ rack-session (2.0.0)
+ rack (>= 3.0.0)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rackup (2.1.0)
+ rack (>= 3)
+ webrick (~> 1.8)
+ rails (7.2.1)
+ actioncable (= 7.2.1)
+ actionmailbox (= 7.2.1)
+ actionmailer (= 7.2.1)
+ actionpack (= 7.2.1)
+ actiontext (= 7.2.1)
+ actionview (= 7.2.1)
+ activejob (= 7.2.1)
+ activemodel (= 7.2.1)
+ activerecord (= 7.2.1)
+ activestorage (= 7.2.1)
+ activesupport (= 7.2.1)
+ bundler (>= 1.15.0)
+ railties (= 7.2.1)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ railties (7.2.1)
+ actionpack (= 7.2.1)
+ activesupport (= 7.2.1)
+ irb (~> 1.13)
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ zeitwerk (~> 2.6)
+ rainbow (3.1.1)
+ rake (13.2.1)
+ rdoc (6.7.0)
+ psych (>= 4.0.0)
+ reline (0.5.9)
+ io-console (~> 0.5)
+ rouge (4.3.0)
+ sawyer (0.9.2)
+ addressable (>= 2.3.5)
+ faraday (>= 0.17.3, < 3)
+ securerandom (0.3.1)
+ sprockets (4.2.1)
+ concurrent-ruby (~> 1.0)
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.5.2)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ sprockets (>= 3.0.0)
+ stimulus-rails (1.3.4)
+ railties (>= 6.0.0)
+ stringio (3.1.1)
+ tailwindcss-rails (2.7.3)
+ railties (>= 7.0.0)
+ tailwindcss-rails (2.7.3-aarch64-linux)
+ railties (>= 7.0.0)
+ tailwindcss-rails (2.7.3-arm-linux)
+ railties (>= 7.0.0)
+ tailwindcss-rails (2.7.3-arm64-darwin)
+ railties (>= 7.0.0)
+ tailwindcss-rails (2.7.3-x86_64-darwin)
+ railties (>= 7.0.0)
+ tailwindcss-rails (2.7.3-x86_64-linux)
+ railties (>= 7.0.0)
+ thor (1.3.2)
+ timeout (0.4.1)
+ traces (0.13.1)
+ turbo-rails (2.0.6)
+ actionpack (>= 6.0.0)
+ activejob (>= 6.0.0)
+ railties (>= 6.0.0)
+ turbo_power (0.6.2)
+ turbo-rails (>= 1.3.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ uri (0.13.1)
+ useragent (0.16.10)
+ webrick (1.8.1)
+ websocket-driver (0.7.6)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ zeitwerk (2.6.17)
PLATFORMS
- ruby
- x86_64-darwin-20
+ aarch64-linux
+ arm-linux
+ arm64-darwin
+ x86-linux
+ x86_64-darwin
+ x86_64-linux
DEPENDENCIES
- phlex (~> 1.10)
- roda (~> 3.79)
- rouge (~> 4.2)
+ foreman
+ github_changelog_generator (~> 1.16)
+ phlex_storybook!
+ puma
+ sprockets-rails
BUNDLED WITH
- 2.5.6
+ 2.5.5
diff --git a/Procfile.dev b/Procfile.dev
new file mode 100644
index 0000000..fa35d41
--- /dev/null
+++ b/Procfile.dev
@@ -0,0 +1,2 @@
+web: bin/rails server -b 0.0.0.0
+css: bin/rails app:tailwind_engine_watch
diff --git a/README.md b/README.md
index dcdc225..e91e284 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,28 @@
-# Purpose
+# PhlexStorybook
+Short description and motivation.
-A quick and dirty phlex preview written because I was trying to understand some phlex functionality and just wanted to be able to see the changes easily
+## Usage
+How to use my plugin.
-# Guiding principles
-- Be simple
-- Minimum Javascript/CSS load
- - Was no JS at all until I added Codemirror to syntax highlight the ruby
- - No CSS framework
- - How long can we keep it like this
- - ![image](https://github.com/hedgeyedev/phlex_preview/assets/13941/306f0df6-91c9-40e9-9a33-9d2c0eff419e)
- - Though technically not true, there's some embedded css and js
-
-- Explore the perks of having everything Ruby and mostly server side (blog post forthcoming)
-
-# Aspirations
-- Currently a standalone playground meant to be run locally
-- Maybe to be converted a rack app/rubygem that I can mount to any Ruby app to preview one's phlex components from their app
- - A poor man's Storybook
-
-# Running
-- clone
-- bundle install
-- rackup (I use rerun to autoload in development)
-- hit localhost:9292
-- Create the code and the invocation to be rendered
-
-# Specifying invocations
-- For this app, and it's potential "poor man's storybook" I wanted to include a way to invoke the object and pass it data in the files themselves and came up with a the format
-
-``` ruby
-# Sample invocation:
-# All the setup you need
-# TestComponent.new(stuff_I_setup)
-# End Sample invocation
+## Installation
+Add this line to your application's Gemfile:
+```ruby
+gem "phlex_storybook"
```
-- example from app_layout_component.rb
-
-``` ruby
-# Sample invocation:
-# code = "class UserProfileComponent < Phlex::HTML\n def initialize(user)\n @user = user\n end\n\n def view_template\n div {\n h1 { @user.name }\n p { @user.email }\n }\n end\nend"
-# params = "UserProfileComponent.new(User.new('John Doe', 'john@example.com')) "
-# AppLayoutComponent.new(code, params)
-# End Sample invocation
+And then execute:
+```bash
+$ bundle
```
-- This serves a part documentation as well as as something to automatically put into the invocation portion to render
-- Also serves the future "poor man's Storybook" feature to be able to auto preview all your components
-- To be delightly recursive
- - open the either of the rendered_results_preview_component.rb or app_layout_component.rb to see it render it's own components in itself
-
-# Caveats
-- Initial version super not safe, only intended to be run in development, may explore sandboxing later, but trying to think about also being able to handle components that invoke other components, so I want to have it in same app. TBD
+Or install it yourself as:
+```bash
+$ gem install phlex_storybook
+```
-# Pics
-## Edit component code, specify invocation
-![image](https://github.com/hedgeyedev/phlex_preview/assets/13941/336bc4dd-caec-49b2-be89-2c437e178e57)
+## Contributing
+Contribution directions go here.
-## See both rendered and raw html - meta example, rendering that component
-![image](https://github.com/hedgeyedev/phlex_preview/assets/13941/fd9140d3-3c0b-41e5-a019-32186c58eca6)
-## Actual html of that component
-![image](https://github.com/hedgeyedev/phlex_preview/assets/13941/c127e5bd-7242-4c6e-a5a7-f6c70c3012dc)
+## License
+The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..4dbe116
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,8 @@
+require "bundler/setup"
+
+APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
+
+load "rails/tasks/engine.rake"
+load "rails/tasks/statistics.rake"
+
+require "bundler/gem_tasks"
diff --git a/app.rb b/app.rb
deleted file mode 100644
index 5f4c08d..0000000
--- a/app.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-require 'roda'
-require 'phlex'
-require 'tempfile'
-require 'uri'
-
-require_relative 'application_component'
-require_relative 'app_layout_component'
-require_relative 'rendered_results_preview_component'
-
-class PreviewApp < Roda
- plugin :render
-
- route do |r|
- r.root do
- phlex_code = r.params['phlex_code']
- params_code = r.params['params']
- AppLayoutComponent.new(phlex_code, params_code).call
- end
-
- r.post 'render' do
- code = r.params['code']
-
- # Create a temporary file to store the class definition
- file = Tempfile.new(['phlex_preview', '.rb'])
- begin
- file.write(code)
- file.close
-
- # Dynamically load the class from the file
- load file.path
- instance = eval r.params['params']
-
- # Render the instance of the newly defined class
- response['Content-Type'] = 'text/html'
- html = instance.call
- RenderedResultsPreviewComponent.new(html).call
-
- rescue => e
- response.status = 422
- e.message + "\n" + e.backtrace.join("
\n")
- ensure
- file.unlink # Delete the temp file
- end
- end
-
- r.post 'load' do
- pp r.params
- tempfile = r.params['file'][:tempfile]
- content = File.read(tempfile)
-
- # Extracting Phlex code
- phlex_code = content
-
- # Extracting invocation parameters
- params_match = content.match(/# Sample invocation:(.*?)# end Sample invocation/mi)
- params_code = params_match[1].strip.gsub(/^#/, '') if params_match
- puts "params_code = |#{params_code}|"
- response.redirect "/?phlex_code=#{URI.encode_www_form_component(phlex_code)}¶ms=#{URI.encode_www_form_component(params_code)}"
-
- end
- end
-end
diff --git a/app/assets/config/phlex_storybook_manifest.js b/app/assets/config/phlex_storybook_manifest.js
new file mode 100644
index 0000000..fee476a
--- /dev/null
+++ b/app/assets/config/phlex_storybook_manifest.js
@@ -0,0 +1,3 @@
+//= link_tree ../images/phlex_storybook .svg
+//= link_directory ../../javascript/phlex_storybook .js
+//= link_directory ../../javascript/phlex_storybook/controllers .js
diff --git a/app/assets/images/phlex_storybook/.keep b/app/assets/images/phlex_storybook/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/assets/stylesheets/phlex_storybook/application.tailwind.css b/app/assets/stylesheets/phlex_storybook/application.tailwind.css
new file mode 100644
index 0000000..55f8d6a
--- /dev/null
+++ b/app/assets/stylesheets/phlex_storybook/application.tailwind.css
@@ -0,0 +1,62 @@
+@import url("//fonts.googleapis.com/css2?family=Karla:ital,wght@0,200..800;1,200..800&display=swap");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply bg-white text-gray-900;
+ @apply dark:bg-gray-900 dark:text-gray-300;
+ font-family: 'Karla', sans-serif;
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ @apply scroll-m-20;
+ @apply dark:text-gray-300;
+ }
+ h1 {
+ @apply text-3xl font-bold leading-normal lg:leading-normal tracking-tight lg:text-4xl;
+ }
+ h2 {
+ @apply text-2xl font-semibold tracking-tight transition-colors first:mt-0;
+ }
+ h3 {
+ @apply text-xl font-semibold tracking-tight;
+ }
+ h4 {
+ @apply text-lg font-medium tracking-tight;
+ }
+
+ fieldset {
+ @apply border border-slate-400 p-4 mb-2;
+ legend {
+ @apply px-2 font-semibold;
+ }
+ }
+
+ .story-selector {
+ ul {
+ @apply mb-3 w-full;
+
+ li {
+ @apply w-full;
+
+ a {
+ @apply pl-2 block hover:bg-slate-900 hover:rounded-md;
+ }
+ }
+ }
+ }
+
+ li.story-preview-active, li.story-code-active {
+ @apply rounded border-slate-200 bg-slate-900;
+ }
+}
+
+.lineno {
+ line-height: 0.75rem;
+}
+.rouge-gutter {
+ @apply text-gray-600;
+}
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/app/controllers/phlex_storybook/application_controller.rb b/app/controllers/phlex_storybook/application_controller.rb
new file mode 100644
index 0000000..8648822
--- /dev/null
+++ b/app/controllers/phlex_storybook/application_controller.rb
@@ -0,0 +1,4 @@
+module PhlexStorybook
+ class ApplicationController < ActionController::Base
+ end
+end
diff --git a/app/controllers/phlex_storybook/stories_controller.rb b/app/controllers/phlex_storybook/stories_controller.rb
new file mode 100644
index 0000000..4ee304c
--- /dev/null
+++ b/app/controllers/phlex_storybook/stories_controller.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ class StoriesController < ApplicationController
+ layout -> { Layouts::ApplicationLayout }
+
+ def index
+ respond_to do |format|
+ format.html { render Components::Stories::Index.new(story_components: story_components) }
+ end
+ end
+
+ def show
+ respond_to do |format|
+ format.html do
+ story_id = params[:story_id]
+ render Components::Stories::Index.new(
+ story_components: story_components,
+ selected: story_component,
+ selected_story: story_id,
+ )
+ end
+
+ format.turbo_stream do
+ story_id = params[:story_id]
+ render turbo_stream: [
+ turbo_stream.push_state(story_path(story_component, story_id: story_id)),
+ turbo_stream.replace(
+ "component_selector",
+ Components::Stories::ComponentSelector.new(
+ story_components: story_components,
+ selected: story_component,
+ selected_story: story_id
+ ),
+ ),
+ turbo_stream.replace(
+ "component_display",
+ Components::Stories::ComponentDisplay.new(story_component: story_component, story_id: story_id),
+ ),
+ turbo_stream.replace(
+ "component_properties",
+ Components::Stories::ComponentProperties.new(
+ story_component: story_component,
+ story_id: story_id,
+ ),
+ ),
+ ]
+ end
+ end
+ end
+
+ def update
+ respond_to do |format|
+ format.turbo_stream do
+ story_id = params[:story_id]
+ render turbo_stream: [
+ turbo_stream.replace(
+ "component_display",
+ Components::Stories::ComponentDisplay.new(
+ story_component: story_component,
+ story_id: story_id,
+ **story_component.transform_props(params[:props].permit!.to_h.symbolize_keys),
+ ),
+ )
+ ]
+ end
+ end
+ end
+
+ private
+
+ def story_components
+ @story_components ||= PhlexStorybook.configuration.components
+ end
+
+ def story_component
+ story_components.detect { |e| e.component_name == params[:id] }
+ end
+ end
+end
diff --git a/app/helpers/phlex_storybook/application_helper.rb b/app/helpers/phlex_storybook/application_helper.rb
new file mode 100644
index 0000000..89c320d
--- /dev/null
+++ b/app/helpers/phlex_storybook/application_helper.rb
@@ -0,0 +1,4 @@
+module PhlexStorybook
+ module ApplicationHelper
+ end
+end
diff --git a/app/javascript/phlex_storybook/application.js b/app/javascript/phlex_storybook/application.js
new file mode 100644
index 0000000..6da88af
--- /dev/null
+++ b/app/javascript/phlex_storybook/application.js
@@ -0,0 +1,5 @@
+// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
+import { Turbo } from '@hotwired/turbo'
+import "controllers"
+import TurboPower from 'turbo_power'
+TurboPower.initialize(Turbo.StreamActions)
diff --git a/app/javascript/phlex_storybook/controllers/application.js b/app/javascript/phlex_storybook/controllers/application.js
new file mode 100644
index 0000000..1213e85
--- /dev/null
+++ b/app/javascript/phlex_storybook/controllers/application.js
@@ -0,0 +1,9 @@
+import { Application } from "@hotwired/stimulus"
+
+const application = Application.start()
+
+// Configure Stimulus development experience
+application.debug = false
+window.Stimulus = application
+
+export { application }
diff --git a/app/javascript/phlex_storybook/controllers/copy_controller.js b/app/javascript/phlex_storybook/controllers/copy_controller.js
new file mode 100644
index 0000000..6c63cce
--- /dev/null
+++ b/app/javascript/phlex_storybook/controllers/copy_controller.js
@@ -0,0 +1,33 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["btn", "source"];
+
+ copy(event) {
+ event.preventDefault();
+ const text = this.sourceTarget.textContent;
+ const originalHtml = event.target.htmlContent;
+ navigator.clipboard.writeText(text).then(() => {
+ this.btnTarget.querySelector('.clipboard').classList.add('hidden');
+ this.btnTarget.querySelector('.check').classList.remove('hidden');
+ setTimeout(() => {
+ this.btnTarget.querySelector('.clipboard').classList.remove('hidden');
+ this.btnTarget.querySelector('.check').classList.add('hidden');
+ }, 1000);
+ }).catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
+ }
+
+ enable() {
+ this.btnTarget.disabled = false;
+ // this.btnTarget.querySelector('.clipboard').classList.remove("stroke-slate-500");
+ // this.btnTarget.classList.add("text-slate-100");
+ }
+
+ disable() {
+ this.btnTarget.disabled = true;
+ // this.btnTarget.classList.add("text-slate-500");
+ // this.btnTarget.classList.remove("text-slate-100");
+ }
+}
diff --git a/app/javascript/phlex_storybook/controllers/index.js b/app/javascript/phlex_storybook/controllers/index.js
new file mode 100644
index 0000000..54ad4ca
--- /dev/null
+++ b/app/javascript/phlex_storybook/controllers/index.js
@@ -0,0 +1,11 @@
+// Import and register all your controllers from the importmap under controllers/*
+
+import { application } from "controllers/application"
+
+// Eager load all controllers defined in the import map under controllers/**/*_controller
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
+
+// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
+// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
+// lazyLoadControllersFrom("controllers", application)
diff --git a/app/javascript/phlex_storybook/controllers/story_display_controller.js b/app/javascript/phlex_storybook/controllers/story_display_controller.js
new file mode 100644
index 0000000..6b5907f
--- /dev/null
+++ b/app/javascript/phlex_storybook/controllers/story_display_controller.js
@@ -0,0 +1,19 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["previewBtn", "codeBtn", "preview", "code"];
+
+ showCode() {
+ this.previewTarget.classList.add('hidden');
+ this.codeTarget.classList.remove('hidden');
+ this.previewBtnTarget.classList.remove('story-preview-active');
+ this.codeBtnTarget.classList.add('story-code-active');
+ }
+
+ showPreview() {
+ this.previewTarget.classList.remove('hidden');
+ this.codeTarget.classList.add('hidden');
+ this.previewBtnTarget.classList.add('story-preview-active');
+ this.codeBtnTarget.classList.remove('story-code-active');
+ }
+}
diff --git a/app_layout_component.rb b/app_layout_component.rb
deleted file mode 100644
index 16ad00d..0000000
--- a/app_layout_component.rb
+++ /dev/null
@@ -1,222 +0,0 @@
-require 'phlex'
-
-class AppLayoutComponent < ApplicationComponent
- def initialize(phlex_code, params)
- @phlex_code = phlex_code
- @params = params
- end
-
- def app_styles
- style {
- unsafe_raw <<~CSS
-
-body, html {
- font-family: 'Helvetica Neue', Arial, sans-serif;
- margin: 0;
- padding: 0;
- background: #f9f9f9;
- color: #333;
-}
-
-.container {
- width: 90%;
- margin: 0 auto;
- padding: 20px;
-}
-
-.textarea {
- width: 100%;
- height: 500px;
- margin-top: 10px;
- padding: 8px;
- box-sizing: border-box;
- border: 1px solid #ccc;
- border-radius: 4px;
- resize: none;
- background-color: #fff;
- font-family: 'Courier New', monospace;
-}
-
-button {
- padding: 10px 20px;
- margin-top: 10px;
- border: none;
- border-radius: 4px;
- background-color: #007BFF;
- color: white;
- cursor: pointer;
- font-size: 16px;
-}
-
-button:hover {
- background-color: #0056b3;
-}
-
-.section {
- background: white;
- border: 1px solid #ddd;
- padding: 15px;
- margin-top: 20px;
- border-radius: 4px;
-}
-
-h1 {
- color: #333;
- font-size: 24px;
-}
-
-.output-pane {
- background-color: #f4f4f4;
- border: 1px solid #ccc;
- padding: 10px;
- margin-top: 20px;
- overflow-x: auto;
- white-space: pre-wrap; /* Ensures that spaces and line breaks are respected */
-}
-textarea, iframe { width: 100%; height: 500px; margin-bottom: 20px; }
-.params { height: 100px; margin-bottom: 20px; }
-
-CSS
- }
- end
- def view_template
- html do
- head do
- title { "Phlex Preview App" }
- common_styles
- app_styles
- codemirror_includes
- end
-
- body do
- div(class: "container") do
- form(action: "/load", method: "post", enctype: "multipart/form-data") do
- whitespace
- input(type: "file", name: "file", accept: ".rb")
- whitespace
- button(type: "submit") { "Load Phlex Component" }
- end
- h1 {"Phlex Preview App"}
- div(class: "section") do
-
- form action: "/render", method: "post", target: "preview_frame" do
- label(for: 'code') { "Phlex Code:"}
- textarea( name: "code", id: :code, placeholder: "#Type your Phlex component code here...\nclass YourComponent < Phlex::HTML\n ...") { @phlex_code }
-
- label(for: 'params') {"How to invoke your component(include necessary params):"}
- textarea(class: 'params', id: :params, name: "params", placeholder: "e.g., UserProfileComponent(User.new(name: 'John Doe'))") { @params }
-
- button( type: "submit") { "Render"}
- end
- end
-
- div(class: 'section output-pane') do
- h2 {"Preview:"}
- iframe name: "preview_frame", srcdoc: "Your rendered output will appear here..."
- end
- end
- codemirror_attach
- end
- end
- end
-
- def codemirror_includes
-
- comment { "CodeMirror CSS" }
- link(
- rel: "stylesheet",
- href:
- "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css"
- )
-
- comment { "CodeMirror JavaScript Library" }
- script(
- src:
- "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"
- )
-
- comment { "Ruby mode" }
- script(
- src:
- "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/ruby/ruby.min.js"
- )
- style { unsafe_raw <<~CSS
-/* Basic styling for CodeMirror */
-.CodeMirror {
- border: 1px solid #ccc; /* Adds a light grey border around the editor */
- padding: 10px; /* Adds padding inside the editor for better text alignment */
- background-color: #f7f7f7; /* A light background color */
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
- height: auto; /* Adjust height as needed */
- min-height: 200px; /* Minimum height to ensure sufficient editing space */
-}
-
-/* Specific styling for focused editor */
-.CodeMirror-focused {
- border-color: #blue; /* Highlight border when editor is focused */
- box-shadow: 0 0 5px rgba(81, 203, 238, 1); /* Soft blue glow around editor */
-}
-
-
-#code + .CodeMirror, #invocation-textarea + .CodeMirror {
- height: 500px !important; /* Use !important to override any inline styles */
-}
-
-#params + .CodeMirror {
- height: 100px !important; /* Separate rule to set a different height */
-}
-
-CSS
- }
- end
-
- def codemirror_attach
- script do
- unsafe_raw <<~JS
- document.addEventListener("DOMContentLoaded", function() {
- CodeMirror.fromTextArea(document.getElementById('code'), {
- lineNumbers: true,
- mode: 'ruby',
- theme: 'default',
- foldGutter: true,
- indentUnit: 2,
- tabSize: 2,
- indentWithTabs: false
- });
-
- CodeMirror.fromTextArea(document.getElementById('params'), {
- lineNumbers: true,
- mode: 'ruby',
- theme: 'default',
- indentUnit: 2,
- tabSize: 2,
- indentWithTabs: false
- });
- });
- JS
- end
- end
-end
-
-# Sample invocation:
-# code = <<~RUBY
-# class UserProfileComponent < Phlex::HTML
-# def initialize(user)
-# @user = user
-# end
-
-# def view_template
-# div {
-# h1 { @user.name }
-# p { @user.email }
-# }
-# end
-# end
-# RUBY
-# params = <<~RUBY
-# user = Object.new
-# def user.name = 'John Doe'
-# def user.email = 'john@example.com'
-# RUBY
-# AppLayoutComponent.new(code, params)
-# End Sample invocation
diff --git a/application_component.rb b/application_component.rb
deleted file mode 100644
index 5130d57..0000000
--- a/application_component.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-class ApplicationComponent < Phlex::HTML
- def common_styles
- style {
- unsafe_raw <<-CSS
- body, html {
- font-family: 'Arial', sans-serif;
- color: #333;
- line-height: 1.6;
- }
- .rendered-results-preview, .raw-html {
- margin: 20px;
- padding: 10px;
- border: 1px solid #ccc;
- border-radius: 5px;
- background-color: #fff;
- overflow-x: auto;
- }
- .highlight {
- background-color: #f4f4f4;
- border: 1px dashed #ddd;
- font-family: 'Courier New', monospace;
- white-space: pre-wrap;
- }
- CSS
- }
- end
-end
diff --git a/bin/dev b/bin/dev
new file mode 100755
index 0000000..eda330c
--- /dev/null
+++ b/bin/dev
@@ -0,0 +1,11 @@
+#!/usr/bin/env sh
+
+if gem list --no-installed --exact --silent foreman; then
+ echo "Installing foreman..."
+ gem install foreman
+fi
+
+# Default to port 3000 if not specified
+export PORT="${PORT:-3000}"
+
+exec foreman start -f Procfile.dev "$@"
diff --git a/bin/foreman b/bin/foreman
new file mode 100755
index 0000000..90b50e2
--- /dev/null
+++ b/bin/foreman
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'foreman' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("foreman", "foreman")
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..d9e49b1
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,32 @@
+#!/usr/bin/env ruby
+# This command will automatically be run when you run "rails" with Rails gems
+# installed from the root of your application.
+
+ENGINE_ROOT = File.expand_path("..", __dir__)
+ENGINE_PATH = File.expand_path("../lib/phlex_storybook/engine", __dir__)
+APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)
+
+# Set up gems listed in the Gemfile.
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
+
+require "rails"
+# Pick the frameworks you want:
+# require "active_model/railtie"
+
+## require "active_job/railtie"
+## require "active_record/railtie"
+## require "active_storage/engine"
+
+require "action_controller/railtie"
+
+## require "action_mailer/railtie"
+## require "action_mailbox/engine"
+## require "action_text/engine"
+
+require "action_view/railtie"
+require "action_cable/engine"
+# require "rails/test_unit/railtie"
+
+require "rails/commands"
+require "rails/engine/commands"
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 0000000..8d89fff
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+
+require "rake"
+Rake.application.run
diff --git a/config.ru b/config.ru
deleted file mode 100644
index 1566098..0000000
--- a/config.ru
+++ /dev/null
@@ -1,2 +0,0 @@
-require './app' # Requires the application file
-run PreviewApp.freeze.app
diff --git a/config/importmap.rb b/config/importmap.rb
new file mode 100644
index 0000000..681e255
--- /dev/null
+++ b/config/importmap.rb
@@ -0,0 +1,6 @@
+pin "phlex_storybook", to: "phlex_storybook/application.js", preload: true
+pin "@hotwired/turbo", to: "turbo.min.js", preload: true
+pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
+pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
+pin "turbo_power", to: "https://ga.jspm.io/npm:turbo_power@0.1.6/dist/index.js"
+pin_all_from PhlexStorybook::Engine.root.join("app/javascript/phlex_storybook/controllers"), under: "controllers", to: "phlex_storybook/controllers"
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..20ae717
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,9 @@
+PhlexStorybook::Engine.routes.draw do
+ root to: 'stories#index'
+
+ resources :stories, only: [:index, :show, :update] do
+ collection do
+ get :all
+ end
+ end
+end
diff --git a/config/tailwind.config.js b/config/tailwind.config.js
new file mode 100644
index 0000000..9a05cf0
--- /dev/null
+++ b/config/tailwind.config.js
@@ -0,0 +1,25 @@
+const defaultTheme = require('tailwindcss/defaultTheme')
+
+module.exports = {
+ darkMode: 'class',
+ content: [
+ './public/*.html',
+ './app/helpers/**/*.rb',
+ './app/javascript/**/*.js',
+ './app/views/**/*.{rb,erb,haml,html,slim}',
+ './lib/phlex_storybook/**/*.rb'
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ['Karla', ...defaultTheme.fontFamily.sans],
+ },
+ },
+ },
+ plugins: [
+ require('@tailwindcss/forms'),
+ require('@tailwindcss/aspect-ratio'),
+ require('@tailwindcss/typography'),
+ require('@tailwindcss/container-queries'),
+ ]
+}
diff --git a/lib/phlex_storybook.rb b/lib/phlex_storybook.rb
new file mode 100644
index 0000000..d26a4dd
--- /dev/null
+++ b/lib/phlex_storybook.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "phlex_storybook/version"
+require "phlex_storybook/engine"
+require "phlex_storybook/configuration"
+
+# require "zeitwerk"
+
+# loader = Zeitwerk::Loader.new
+# loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
+# loader.push_dir(File.expand_path("../app", __dir__))
+# loader.setup
+
+module PhlexStorybook
+ class << self
+ attr_writer :configuration
+
+ def configuration
+ @configuration ||= Configuration.new
+ end
+
+ def configure
+ yield(configuration) if block_given?
+ end
+ end
+end
diff --git a/lib/phlex_storybook/application_component.rb b/lib/phlex_storybook/application_component.rb
new file mode 100644
index 0000000..22bdc4a
--- /dev/null
+++ b/lib/phlex_storybook/application_component.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ class ApplicationComponent < Phlex::HTML
+ include Phlex::Rails::Helpers::Routes
+ include Phlex::Rails::Helpers::TurboFrameTag
+
+ if Rails.env.development?
+ def before_template
+ # comment { "Before #{self.class.name}" }
+ super
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/application_view.rb b/lib/phlex_storybook/application_view.rb
new file mode 100644
index 0000000..29612ea
--- /dev/null
+++ b/lib/phlex_storybook/application_view.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module PhlexStorybook
+ class ApplicationView < ApplicationComponent
+ include Phlex::Rails::Helpers::CheckBox
+ include Phlex::Rails::Helpers::FormFor
+ include Phlex::Rails::Helpers::ImageTag
+ include Phlex::Rails::Helpers::StylesheetLinkTag
+ include Phlex::Rails::Helpers::JavascriptImportmapTags
+ include Phlex::Rails::Helpers::JavascriptIncludeTag
+ include Phlex::Rails::Helpers::AssetPath
+ include Phlex::Rails::Helpers::PathToAsset
+ include Phlex::Rails::Helpers::Request
+ include Phlex::Rails::Helpers::Tag
+ include ApplicationHelper
+ end
+end
diff --git a/lib/phlex_storybook/components/stories/component_display.rb b/lib/phlex_storybook/components/stories/component_display.rb
new file mode 100644
index 0000000..6f882e9
--- /dev/null
+++ b/lib/phlex_storybook/components/stories/component_display.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Components
+ module Stories
+ class ComponentDisplay < ApplicationView
+ include Phlex::Rails::Helpers::FieldSetTag
+
+ def initialize(story_component:, story_id: nil, **props)
+ @story_component = story_component
+ @story_id = story_id
+ @props = props
+ end
+
+ def view_template
+ if @story_component.nil?
+ turbo_frame_tag("component_display") do
+ blank_template
+ end
+ return
+ end
+
+ turbo_frame_tag("component_display") do
+ div(class: "flex flex-col h-screen max-h-screen", data: { controller: "story-display copy" }) do
+ render_header @story_component.component_name
+ div(class: "px-2 flex-1 overflow-y-scroll overflow-x-hidden") do
+ props = @props.blank? ? @story_component.story_for(@story_id) : @props
+ if @story_id && props.nil?
+ h1 { "Story not found" }
+ next
+ end
+
+ div(class: "mb-4") { @story_component.component_description }
+
+ if @story_id
+ div(class: "container w-full h-fit min-w-0 mr-0") do
+ render_story_header
+ render ComponentRendering.new(
+ story_component: @story_component,
+ story_id: @story_id,
+ **props.except(:title).map.with_object({}) { |(k, v), h| h[k] = v }
+ )
+ end
+ elsif @story_component.component_stories.present?
+ div { "Select a story from the left to see its usage..." }
+ else
+ div(class: "container w-full h-fit min-w-0 mr-0") do
+ render_story_header
+ render ComponentRendering.new(story_component: @story_component)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def blank_template
+ render_header("Select a component")
+ div(class: "px-2") { "Select a component from the left to see its details" }
+ end
+
+ def render_header(text)
+ h2(class: "bg-slate-900 text-white border-x border-slate-700 p-2 flex-none") { text }
+ end
+
+ def render_story_header
+ div(class: "flex justify-between") do
+ ul(class: "text-sm text-white inline-flex bg-slate-700 rounded border-slate-200") do
+ li(
+ class: "relative px-3 py-2 flex-grow-1 story-preview-active",
+ data: {
+ action: "click->story-display#showPreview click->copy#disable:prevent",
+ story_display_target: "previewBtn",
+ },
+ ) do
+ button(class: "font-semibold") { "Preview" }
+ end
+
+ li(
+ class: "relative px-3 py-2 flex-grow-1",
+ data: {
+ action: 'click->story-display#showCode click->copy#enable:prevent',
+ story_display_target: 'codeBtn',
+ },
+ ) do
+ button(class: "font-semibold") { "Ruby Code" }
+ end
+ end
+ button(
+ disabled: true,
+ data: { copy_target: "btn", action: "click->copy#copy" },
+ class: "bg-slate-700 group px-2 py-1 rounded",
+ ) do
+ span(class: 'clipboard') do
+ render Phlex::Icons::Lucide::Copy.new(
+ classes: "group-enabled:stroke-slate-100 group-disabled:stroke-slate-500 size-5",
+ )
+ end
+ span(class: 'check hidden') do
+ render Phlex::Icons::Lucide::CopyCheck.new(classes: "stroke-green-400 size-5")
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/components/stories/component_properties.rb b/lib/phlex_storybook/components/stories/component_properties.rb
new file mode 100644
index 0000000..e6a975e
--- /dev/null
+++ b/lib/phlex_storybook/components/stories/component_properties.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Components
+ module Stories
+ class ComponentProperties < ApplicationView
+ def initialize(story_component:, story_id: nil)
+ @story_component = story_component
+ @story_id = story_id
+
+ if story_id
+ @props = story_component.story_for(story_id)
+ end
+ end
+
+ def view_template
+ if @story_component&.component_props.blank?
+ turbo_frame_tag("component_properties") do
+ h2(class: "bg-slate-900 p-2") { "Properties" }
+ div(class: "px-2") { "No properties" }
+ end
+ return
+ end
+
+ turbo_frame_tag("component_properties") do
+ h2(class: "bg-slate-900 p-2") { "Properties" }
+ div(class: "px-2") do
+ form(action: helpers.story_path(@story_component, story_id: @story_id), method: 'PUT') do
+ ul do
+ @story_component.component_props.each do |prop|
+ li do
+ label(class: 'grid grid-cols-1 w-full') do
+ div { prop.label || prop.key.to_s.capitalize }
+ render prop.clone_with_value(@props&.fetch(prop.key, nil))
+ end
+ end
+ end
+ end
+
+ button(
+ type: "submit",
+ class: "mt-4 px-3 py-2 rounded-lg bg-slate-600 hover:ring-1 hover:ring-slate-100",
+ ) { "« Render" }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/components/stories/component_rendering.rb b/lib/phlex_storybook/components/stories/component_rendering.rb
new file mode 100644
index 0000000..17f5e98
--- /dev/null
+++ b/lib/phlex_storybook/components/stories/component_rendering.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Components
+ module Stories
+ class ComponentRendering < ApplicationView
+ def initialize(story_component:, story_id: nil, **props)
+ @story_component = story_component
+ @story_id = story_id
+ @props = props.blank? ? story_component.default_story : props
+ end
+
+ def view_template
+ turbo_frame_tag("story-rendering") do
+ div(data: { story_display_target: "preview" }) do
+ render @story_component.component.new(**@props)
+ end
+
+ div(
+ class: "hidden mt-4 w-full",
+ data: { story_display_target: "code" },
+ ) do
+ div(id: "component-code", class: "p-2 overflow-auto scroll") do
+ source = @props.blank? ? "render #{@story_component.component.name}.new" : <<~RUBY.strip
+ render #{@story_component.component.name}.new(
+ #{@props.map { |k, v| "#{k}: #{v.inspect}," }.join("\n ")}
+ )
+ RUBY
+ pre(class: "hidden", data: { copy_target: "source" }) { source }
+ formatter = Rouge::Formatters::HTMLLineTable.new(Rouge::Formatters::HTML.new)
+ lexer = Rouge::Lexers::Ruby.new
+ unsafe_raw formatter.format(lexer.lex(source))
+ end
+ style do
+ unsafe_raw Rouge::Themes::Molokai.render(scope: '#component-code')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/components/stories/component_selector.rb b/lib/phlex_storybook/components/stories/component_selector.rb
new file mode 100644
index 0000000..b85a920
--- /dev/null
+++ b/lib/phlex_storybook/components/stories/component_selector.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Components
+ module Stories
+ class ComponentSelector < ApplicationView
+ def initialize(story_components:, selected: nil, selected_story: nil)
+ @story_components_by_category = story_components.group_by(&:component_category).sort
+ @selected = selected
+ @selected_story = selected_story
+ end
+
+ def view_template
+ turbo_frame_tag("component_selector") do
+ h2(class: "bg-slate-900 p-2") { "Components" }
+ div(class: "px-2") do
+ @story_components_by_category.each do |category, story_components|
+ h4 { category }
+ ul do
+ story_components.each do |story_component|
+ li do
+ component_link(story_component)
+
+ if story_component.component_stories.present?
+ ul do
+ story_component.component_stories&.each do |props|
+ li(class: "pl-4 text-sm") { story_link(story_component, props) }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def active_selection
+ 'font-semibold text-sky-500'
+ end
+
+ def component_link(story_component)
+ active = story_component == @selected
+ a(
+ data: { turbo_stream: true },
+ href: helpers.story_path(story_component.component_name),
+ class: "#{active ? active_selection : ''}",
+ ) do
+ span(class: "pr-1") do
+ render Phlex::Icons::Lucide::Component.new(classes: "#{icon_color(active)} size-4 inline")
+ end
+ span { story_component.component_name }
+ end
+ end
+
+ def icon_color(state)
+ state ? 'stroke-sky-500' : 'stroke-slate-300'
+ end
+
+ def story_link(story_component, props)
+ title = props.delete :title
+ id = story_component.id_for(title)
+ active = id == @selected_story
+ icon = active ? Phlex::Icons::Lucide::NotebookText : Phlex::Icons::Lucide::Notebook
+
+ a(
+ class: "#{active ? active_selection : ''}",
+ href: helpers.story_path(story_component, story_id: id),
+ data: { turbo_stream: true },
+ ) do
+ span(class: "pr-1") { render icon.new(classes: "#{icon_color(active)} size-4 inline") }
+ span { "#{title}"}
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/components/stories/index.rb b/lib/phlex_storybook/components/stories/index.rb
new file mode 100644
index 0000000..5cb7979
--- /dev/null
+++ b/lib/phlex_storybook/components/stories/index.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Components
+ module Stories
+ class Index < ApplicationView
+ def initialize(story_components:, selected: nil, selected_story: nil)
+ @story_components = story_components
+ @selected = selected
+ @selected_story = selected_story
+ end
+
+ def view_template
+ turbo_frame_tag("story_components") do
+ div class: "flex h-screen w-screen" do
+ div class: "flex-none w-1/4 min-w-40 max-w-80 bg-slate-700 text-white story-selector" do
+ render ComponentSelector.new(story_components: @story_components, selected: @selected, selected_story: @selected_story)
+ end
+
+ div class: "flex-auto h-full w-1/2 bg-white" do
+ render ComponentDisplay.new(story_component: @selected, story_id: @selected_story)
+ end
+
+ div class: "flex-none w-1/4 min-w-40 max-w-80 bg-slate-700 text-white" do
+ render ComponentProperties.new(story_component: @selected, story_id: @selected_story)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/configuration.rb b/lib/phlex_storybook/configuration.rb
new file mode 100644
index 0000000..9ebe58d
--- /dev/null
+++ b/lib/phlex_storybook/configuration.rb
@@ -0,0 +1,25 @@
+module PhlexStorybook
+ class Configuration
+ attr_accessor :component_directories
+
+ def initialize
+ @component_directories = %w[app/components]
+ end
+
+ def components
+ component_directories.flat_map do |dir|
+ Dir.glob("#{dir}/**/*.rb").select { |f| File.file?(f) }.map do |file|
+ StoryComponent.new File.basename(file, ".rb").camelize.constantize, file
+ end
+ end
+ end
+
+ def component_directories
+ @component_directories.map { |dir| Rails.root.join dir }
+ end
+
+ def component_directories=(directories)
+ @component_directories = directories
+ end
+ end
+end
diff --git a/lib/phlex_storybook/engine.rb b/lib/phlex_storybook/engine.rb
new file mode 100644
index 0000000..e653ca5
--- /dev/null
+++ b/lib/phlex_storybook/engine.rb
@@ -0,0 +1,28 @@
+require "importmap-rails"
+require "turbo-rails"
+require "stimulus-rails"
+
+require "phlex"
+require "phlex_icons"
+require "phlex-rails"
+require "rouge"
+require "turbo_power"
+# require "phlex_ui"
+
+module PhlexStorybook
+ class Engine < ::Rails::Engine
+ isolate_namespace PhlexStorybook
+
+ config.autoload_paths << "#{root}/lib"
+
+ initializer "phlex_storybook.assets" do |app|
+ app.config.assets.paths << root.join("app/javascript")
+ app.config.assets.precompile += %w[ phlex_storybook_manifest ]
+ end
+
+ initializer "phlex_storybook.importmap", before: "importmap" do |app|
+ app.config.importmap.paths << root.join("config/importmap.rb")
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
+ end
+ end
+end
diff --git a/lib/phlex_storybook/layouts/application_layout.rb b/lib/phlex_storybook/layouts/application_layout.rb
new file mode 100644
index 0000000..08ceb9e
--- /dev/null
+++ b/lib/phlex_storybook/layouts/application_layout.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module PhlexStorybook
+ module Layouts
+ class ApplicationLayout < ApplicationView
+ include Phlex::Rails::Layout
+ include Phlex::Rails::Helpers::CSRFMetaTags
+ include Phlex::Rails::Helpers::CSPMetaTag
+ include Phlex::Rails::Helpers::TurboRefreshesWith
+
+ def view_template(&)
+ doctype
+
+ html lang: "en" do
+ head do
+ meta charset: "UTF-8"
+ meta name: "viewport", content: "width=device-width, initial-scale=1.0"
+ title { "Phlex Component Preview" }
+ csrf_meta_tags
+ csp_meta_tag
+ javascript_importmap_tags "phlex_storybook"
+ stylesheet_link_tag "phlex_storybook_application", media: "all"
+ turbo_refreshes_with method: :morph, scroll: :preserve
+ end
+ body class: "bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100" do
+ yield
+ end
+ end
+ end
+ end
+
+ def identifier
+ "phlex_storybook"
+ end
+ end
+end
diff --git a/lib/phlex_storybook/props/base.rb b/lib/phlex_storybook/props/base.rb
new file mode 100644
index 0000000..897c7d8
--- /dev/null
+++ b/lib/phlex_storybook/props/base.rb
@@ -0,0 +1,30 @@
+module PhlexStorybook
+ module Props
+ class Base < ApplicationView
+ attr_reader :default, :key, :label, :placeholder, :required, :value
+
+ def initialize(key:, default: nil, label: nil, placeholder: nil, required: false)
+ @key = key
+ @default = default
+ @label = label
+ @placeholder = placeholder
+ @required = required
+ @value = nil
+ end
+
+ def transform(value)
+ value.blank? ? default : value
+ end
+
+ def view_template(&block)
+ raise "Subclass responsibility"
+ end
+
+ def clone_with_value(v)
+ p = dup
+ p.instance_variable_set(:@value, v)
+ p
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/props/boolean.rb b/lib/phlex_storybook/props/boolean.rb
new file mode 100644
index 0000000..1e94722
--- /dev/null
+++ b/lib/phlex_storybook/props/boolean.rb
@@ -0,0 +1,20 @@
+module PhlexStorybook
+ module Props
+ class Boolean < Base
+ def self.default = false
+
+ def transform(value)
+ Array(value).include? "1"
+ end
+
+ def view_template
+ check_box(
+ "props[#{key}]",
+ nil,
+ required: required,
+ checked: value,
+ )
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/props/select.rb b/lib/phlex_storybook/props/select.rb
new file mode 100644
index 0000000..60047ee
--- /dev/null
+++ b/lib/phlex_storybook/props/select.rb
@@ -0,0 +1,36 @@
+module PhlexStorybook
+ module Props
+ class Select < Base
+ attr_reader :include_blank, :multiple, :options
+
+ def self.default = []
+
+ def initialize(key:, options:, label: nil, include_blank: false, multiple: false, placeholder: nil, required: nil)
+ super(key: key, label: label, placeholder: placeholder, required: required)
+ @include_blank = include_blank
+ @multiple = multiple
+ @options = options
+ end
+
+ def view_template
+ select(
+ class: 'w-full text-slate-700',
+ name: name,
+ required: required,
+ multiple: multiple,
+ ) do
+ option(value: "", selected: Array(value).empty?) { "Select..." } if include_blank
+ options.each do |option|
+ option(value: option, selected: Array(value).include?(option)) { option }
+ end
+ end
+ end
+
+ def name
+ return "props[#{key}]" unless multiple
+
+ "props[#{key}][]"
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/props/string.rb b/lib/phlex_storybook/props/string.rb
new file mode 100644
index 0000000..4676bb2
--- /dev/null
+++ b/lib/phlex_storybook/props/string.rb
@@ -0,0 +1,18 @@
+module PhlexStorybook
+ module Props
+ class String < Base
+ def self.default = ""
+
+ def view_template
+ input(
+ class: 'w-full text-slate-700',
+ name: "props[#{key}]",
+ placeholder: placeholder,
+ required: required,
+ type: "text",
+ value: value,
+ )
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/props/text.rb b/lib/phlex_storybook/props/text.rb
new file mode 100644
index 0000000..b52b47f
--- /dev/null
+++ b/lib/phlex_storybook/props/text.rb
@@ -0,0 +1,17 @@
+module PhlexStorybook
+ module Props
+ class Text < String
+ def self.default = ""
+
+ def view_template
+ textarea(
+ class: 'w-full text-slate-700',
+ name: "props[#{key}]",
+ placeholder: placeholder,
+ required: required,
+ rows: 5,
+ ) { value }
+ end
+ end
+ end
+end
diff --git a/lib/phlex_storybook/story_component.rb b/lib/phlex_storybook/story_component.rb
new file mode 100644
index 0000000..e667766
--- /dev/null
+++ b/lib/phlex_storybook/story_component.rb
@@ -0,0 +1,57 @@
+module PhlexStorybook
+ class StoryComponent
+ attr_reader :component, :location
+
+ def initialize(component, location)
+ @component = component
+ @location = location
+ end
+
+ def component_category
+ component.respond_to?(:component_category) ? component.component_category : "Uncategorized"
+ end
+
+ def component_description
+ component.respond_to?(:component_description) ? component.component_description : "No description provided"
+ end
+
+ def component_name
+ component.respond_to?(:component_name) ? component.component_name : component.name
+ end
+ alias to_param component_name
+
+ def component_props
+ component.respond_to?(:component_props) ? component.component_props : []
+ end
+
+ def component_stories
+ component.respond_to?(:component_stories) ? component.component_stories : []
+ end
+
+ def default_story
+ component_props
+ .select { |prop| prop.required }
+ .map { |prop| [prop.key, prop.default || prop.class.default] }
+ .to_h
+ end
+
+ def default_string = ""
+ def default_string_list = []
+
+ def id_for(title)
+ Digest::MD5.hexdigest(title)
+ end
+
+ def story_for(id)
+ component_stories.detect { |props| id_for(props[:title]) == id }
+ end
+
+ def transform_props(props)
+ return props if component_props.blank?
+
+ props.map.with_object({}) do |(k, v), h|
+ h[k] = component_props.detect { |prop| prop.key == k }&.transform(v)
+ end.compact
+ end
+ end
+end
diff --git a/lib/phlex_storybook/version.rb b/lib/phlex_storybook/version.rb
new file mode 100644
index 0000000..d601dd7
--- /dev/null
+++ b/lib/phlex_storybook/version.rb
@@ -0,0 +1,3 @@
+module PhlexStorybook
+ VERSION = "0.1.0"
+end
diff --git a/lib/tasks/css_task.rake b/lib/tasks/css_task.rake
new file mode 100644
index 0000000..9c6010e
--- /dev/null
+++ b/lib/tasks/css_task.rake
@@ -0,0 +1,9 @@
+task :tailwind_engine_watch do
+ require "tailwindcss-rails"
+ # NOTE: tailwindcss-rails is an engine
+ system "#{Tailwindcss::Engine.root.join("exe/tailwindcss")} \
+ -i #{PhlexStorybook::Engine.root.join("app/assets/stylesheets/phlex_storybook/application.tailwind.css")} \
+ -o #{PhlexStorybook::Engine.root.join("app/assets/builds/phlex_storybook_application.css")} \
+ -c #{PhlexStorybook::Engine.root.join("config/tailwind.config.js")} \
+ -w"
+end
diff --git a/lib/tasks/phlex_storybook_tasks.thor b/lib/tasks/phlex_storybook_tasks.thor
new file mode 100644
index 0000000..f3ea215
--- /dev/null
+++ b/lib/tasks/phlex_storybook_tasks.thor
@@ -0,0 +1,15 @@
+# desc "Explaining what the task does"
+# task :phlex_storybook do
+# # Task goes here
+# end
+
+class PhlexStorybook < Thor
+ include Thor::Actions
+
+ desc "install", "Install Phlex Storybook"
+ def install
+ say "Hi! I'm going to install Phlex Storybook for you."
+ exec "bin/importmap pin phlex_ui"
+ append_to_file("app/javascript/application.js", "import 'phlex_ui';\n")
+ end
+end
diff --git a/phlex_storybook.gemspec b/phlex_storybook.gemspec
new file mode 100644
index 0000000..b4f463c
--- /dev/null
+++ b/phlex_storybook.gemspec
@@ -0,0 +1,37 @@
+require_relative "lib/phlex_storybook/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "phlex_storybook"
+ spec.version = PhlexStorybook::VERSION
+ spec.authors = [ "Hedgeye Developers" ]
+ spec.email = [ "developers@hedgeye.com" ]
+ spec.homepage = "https://github.com/hedgeyedev/phlex_preview"
+ spec.summary = "A storybook implementation for Phlex components."
+ spec.description = "Showcase your Phlex components: rendering, documentation, and copyable code."
+ spec.license = "MIT"
+
+ spec.post_install_message = <<~MESSAGE
+ PhlexStorybook requires additional setup.
+ Please run the following command to install the necessary files:
+ thor phlex_storybook:install
+ MESSAGE
+
+ spec.metadata["homepage_uri"] = spec.homepage
+ spec.metadata["source_code_uri"] = spec.homepage
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
+ Dir["{app,config,lib}/**/*", "LICENSE", "Rakefile", "README.md", "CHANGELOG.md"]
+ end
+
+ spec.add_dependency "importmap-rails"
+ spec.add_dependency "phlex-rails", "~> 1.2"
+ spec.add_dependency "rails", ">= 7.2"
+ spec.add_dependency "stimulus-rails"
+ spec.add_dependency "tailwindcss-rails", "~> 2.7"
+ spec.add_dependency "turbo-rails"
+ spec.add_dependency "turbo_power", "~> 0.6.2"
+ spec.add_dependency "phlex-icons", "~> 0.11.0"
+ spec.add_dependency "rouge", "~> 4.3.0"
+end
diff --git a/rendered_results_preview_component.rb b/rendered_results_preview_component.rb
deleted file mode 100644
index 55a5c21..0000000
--- a/rendered_results_preview_component.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-require 'rouge'
-class RenderedResultsPreviewComponent < ApplicationComponent
- def initialize(html)
- @html = html
- @formatter = Rouge::Formatters::HTML.new(css_class: 'highlight')
- @lexer = Rouge::Lexers::HTML.new
- end
-
- def view_template
- common_styles
- style do
- <<-CSS
- .rendered-results-preview {
- margin: 20px;
- padding: 10px;
- border: 1px solid #ccc;
- border-radius: 5px;
- }
- .raw-html {
- white-space: pre-wrap; /* Maintains spacing and format */
- background-color: #f4f4f4;
- border: 1px dashed #ddd;
- padding: 10px;
- overflow-x: auto;
- }
- .highlight {
- background-color: #ffffcc;
- }
- CSS
- end
- style do
- Rouge::Themes::ThankfulEyes.render(scope: '.highlight').gsub("\n", "")
- end
- div(class: 'rendered-results-preview') do
- h1 { 'Rendered Results Preview' }
- hr
- unsafe_raw @html
- hr
- end
- div(class: 'rendered-results-preview raw-html') do
- h1 { 'Raw HTML' }
- div(class: 'highlight') { unsafe_raw @formatter.format(@lexer.lex(@html)) }
- end
- end
-
-end
-
-# Sample invocation:
-# RenderedResultsPreviewComponent.new("
This is a sample HTML document.
+ + + HTML + end + + def self.long_html + list_items = 50.times.map { |i| "You may have mistyped the address or the page may have moved.
+If you are the application owner check the logs for more information.
+Maybe you tried to change something you didn't have access to.
+If you are the application owner check the logs for more information.
+If you are the application owner check the logs for more information.
+