nightingale 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +56 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +98 -0
  6. data/Rakefile +12 -0
  7. data/examples/demo/app.rb +28 -0
  8. data/frontend/.gitignore +24 -0
  9. data/frontend/README.md +16 -0
  10. data/frontend/components.json +17 -0
  11. data/frontend/eslint.config.js +29 -0
  12. data/frontend/index.html +13 -0
  13. data/frontend/package-lock.json +5467 -0
  14. data/frontend/package.json +47 -0
  15. data/frontend/postcss.config.ts +6 -0
  16. data/frontend/public/vite.svg +1 -0
  17. data/frontend/src/App.css +42 -0
  18. data/frontend/src/App.tsx +192 -0
  19. data/frontend/src/assets/react.svg +1 -0
  20. data/frontend/src/components/ui/button.tsx +56 -0
  21. data/frontend/src/components/ui/card.tsx +79 -0
  22. data/frontend/src/components/ui/input.tsx +22 -0
  23. data/frontend/src/components/ui/label.tsx +24 -0
  24. data/frontend/src/components/ui/separator.tsx +31 -0
  25. data/frontend/src/components/ui/slider.tsx +26 -0
  26. data/frontend/src/components/ui/table.tsx +117 -0
  27. data/frontend/src/index.css +76 -0
  28. data/frontend/src/lib/utils.ts +6 -0
  29. data/frontend/src/main.tsx +10 -0
  30. data/frontend/tailwind.config.ts +77 -0
  31. data/frontend/tsconfig.json +30 -0
  32. data/frontend/tsconfig.node.json +10 -0
  33. data/frontend/vite.config.ts +13 -0
  34. data/lib/nightingale/cli.rb +126 -0
  35. data/lib/nightingale/dsl.rb +74 -0
  36. data/lib/nightingale/runner.rb +79 -0
  37. data/lib/nightingale/server.rb +104 -0
  38. data/lib/nightingale/version.rb +5 -0
  39. data/lib/nightingale.rb +11 -0
  40. data/public/nightingale.png +0 -0
  41. metadata +205 -0
@@ -0,0 +1,76 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222.2 84% 4.9%;
9
+
10
+ --card: 0 0% 100%;
11
+ --card-foreground: 222.2 84% 4.9%;
12
+
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 222.2 84% 4.9%;
15
+
16
+ --primary: 222.2 47.4% 11.2%;
17
+ --primary-foreground: 210 40% 98%;
18
+
19
+ --secondary: 210 40% 96.1%;
20
+ --secondary-foreground: 222.2 47.4% 11.2%;
21
+
22
+ --muted: 210 40% 96.1%;
23
+ --muted-foreground: 215.4 16.3% 46.9%;
24
+
25
+ --accent: 210 40% 96.1%;
26
+ --accent-foreground: 222.2 47.4% 11.2%;
27
+
28
+ --destructive: 0 84.2% 60.2%;
29
+ --destructive-foreground: 210 40% 98%;
30
+
31
+ --border: 214.3 31.8% 91.4%;
32
+ --input: 214.3 31.8% 91.4%;
33
+ --ring: 222.2 84% 4.9%;
34
+
35
+ --radius: 0.5rem;
36
+ }
37
+
38
+ .dark {
39
+ --background: 222.2 84% 4.9%;
40
+ --foreground: 210 40% 98%;
41
+
42
+ --card: 222.2 84% 4.9%;
43
+ --card-foreground: 210 40% 98%;
44
+
45
+ --popover: 222.2 84% 4.9%;
46
+ --popover-foreground: 210 40% 98%;
47
+
48
+ --primary: 210 40% 98%;
49
+ --primary-foreground: 222.2 47.4% 11.2%;
50
+
51
+ --secondary: 217.2 32.6% 17.5%;
52
+ --secondary-foreground: 210 40% 98%;
53
+
54
+ --muted: 217.2 32.6% 17.5%;
55
+ --muted-foreground: 215 20.2% 65.1%;
56
+
57
+ --accent: 217.2 32.6% 17.5%;
58
+ --accent-foreground: 210 40% 98%;
59
+
60
+ --destructive: 0 62.8% 30.6%;
61
+ --destructive-foreground: 210 40% 98%;
62
+
63
+ --border: 217.2 32.6% 17.5%;
64
+ --input: 217.2 32.6% 17.5%;
65
+ --ring: 212.7 26.8% 83.9%;
66
+ }
67
+ }
68
+
69
+ @layer base {
70
+ * {
71
+ @apply border-border;
72
+ }
73
+ body {
74
+ @apply bg-background text-foreground;
75
+ }
76
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
@@ -0,0 +1,77 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ darkMode: ["class"],
4
+ content: [
5
+ './pages/**/*.{ts,tsx}',
6
+ './components/**/*.{ts,tsx}',
7
+ './app/**/*.{ts,tsx}',
8
+ './src/**/*.{ts,tsx}',
9
+ ],
10
+ prefix: "",
11
+ theme: {
12
+ container: {
13
+ center: true,
14
+ padding: "2rem",
15
+ screens: {
16
+ "2xl": "1400px",
17
+ },
18
+ },
19
+ extend: {
20
+ colors: {
21
+ border: "hsl(var(--border))",
22
+ input: "hsl(var(--input))",
23
+ ring: "hsl(var(--ring))",
24
+ background: "hsl(var(--background))",
25
+ foreground: "hsl(var(--foreground))",
26
+ primary: {
27
+ DEFAULT: "hsl(var(--primary))",
28
+ foreground: "hsl(var(--primary-foreground))",
29
+ },
30
+ secondary: {
31
+ DEFAULT: "hsl(var(--secondary))",
32
+ foreground: "hsl(var(--secondary-foreground))",
33
+ },
34
+ destructive: {
35
+ DEFAULT: "hsl(var(--destructive))",
36
+ foreground: "hsl(var(--destructive-foreground))",
37
+ },
38
+ muted: {
39
+ DEFAULT: "hsl(var(--muted))",
40
+ foreground: "hsl(var(--muted-foreground))",
41
+ },
42
+ accent: {
43
+ DEFAULT: "hsl(var(--accent))",
44
+ foreground: "hsl(var(--accent-foreground))",
45
+ },
46
+ popover: {
47
+ DEFAULT: "hsl(var(--popover))",
48
+ foreground: "hsl(var(--popover-foreground))",
49
+ },
50
+ card: {
51
+ DEFAULT: "hsl(var(--card))",
52
+ foreground: "hsl(var(--card-foreground))",
53
+ },
54
+ },
55
+ borderRadius: {
56
+ lg: "var(--radius)",
57
+ md: "calc(var(--radius) - 2px)",
58
+ sm: "calc(var(--radius) - 4px)",
59
+ },
60
+ keyframes: {
61
+ "accordion-down": {
62
+ from: { height: "0" },
63
+ to: { height: "var(--radix-accordion-content-height)" },
64
+ },
65
+ "accordion-up": {
66
+ from: { height: "var(--radix-accordion-content-height)" },
67
+ to: { height: "0" },
68
+ },
69
+ },
70
+ animation: {
71
+ "accordion-down": "accordion-down 0.2s ease-out",
72
+ "accordion-up": "accordion-up 0.2s ease-out",
73
+ },
74
+ },
75
+ },
76
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
77
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+
23
+ "baseUrl": ".",
24
+ "paths": {
25
+ "@/*": ["./src/*"]
26
+ }
27
+ },
28
+ "include": ["src"],
29
+ "references": [{ "path": "./tsconfig.node.json" }]
30
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'path'
4
+
5
+ // https://vitejs.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react()],
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "./src"),
11
+ },
12
+ },
13
+ })
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "socket"
5
+
6
+ module Nightingale
7
+ class CLI
8
+ def self.new_project(name)
9
+ puts "Creating new Nightingale project: #{name}..."
10
+ FileUtils.mkdir_p(name)
11
+
12
+ # Copy frontend template
13
+ # In a real gem, this would be properly located.
14
+ # For this repo, we assume we are running from the root.
15
+ gem_root = File.expand_path("../..", __dir__)
16
+ frontend_template = File.join(gem_root, "frontend")
17
+
18
+ if File.directory?(frontend_template)
19
+ puts "Copying frontend template..."
20
+ FileUtils.cp_r(frontend_template, File.join(name, "frontend"))
21
+
22
+ # Clean up node_modules and other artifacts from the template copy
23
+ FileUtils.rm_rf(File.join(name, "frontend", "node_modules"))
24
+ FileUtils.rm_rf(File.join(name, "frontend", "dist"))
25
+ FileUtils.rm_rf(File.join(name, "frontend", ".git"))
26
+
27
+ # Create a basic app.rb
28
+ puts "Creating sample app.rb..."
29
+ File.write(File.join(name, "app.rb"), <<~RUBY)
30
+ require 'nightingale'
31
+
32
+ title "My Nightingale App"
33
+ markdown "Welcome to your new app!"
34
+
35
+ if button "Click me"
36
+ markdown "You clicked the button!"
37
+ end
38
+ RUBY
39
+
40
+ # Create Gemfile
41
+ puts "Creating Gemfile..."
42
+ File.write(File.join(name, "Gemfile"), <<~RUBY)
43
+ source 'https://rubygems.org'
44
+ gem 'nightingale', path: '#{gem_root}' # Local path for now
45
+ RUBY
46
+
47
+ puts "Installing frontend dependencies..."
48
+ Dir.chdir(File.join(name, "frontend")) do
49
+ system("npm install")
50
+
51
+ puts "Initializing Shadcn UI components..."
52
+ # We assume components.json and tailwind.config.js are already in the template
53
+ # So we just need to add the components
54
+ # Note: 'shadcn add --all' might require interaction or confirmation, adding --yes or similar if available
55
+ # Actually, shadcn add doesn't have a --yes flag usually, but we can try piping yes?
56
+ # Or just installing specific components we use.
57
+ # For now, let's install the ones we use in the template if any, or just let the user do it?
58
+ # The user request said: "when user initialize the app will install them automatically"
59
+ # Let's try to install button, slider, etc.
60
+
61
+ # But wait, we haven't actually implemented the Shadcn components in our App.tsx yet.
62
+ # We are still using HTML buttons.
63
+ # We should update App.tsx to use Shadcn components first?
64
+ # Or we install them now and the user can use them.
65
+
66
+ # Let's install common components
67
+ components = %w[button slider card input label table separator]
68
+ puts "Installing components: #{components.join(", ")}..."
69
+ system("npx shadcn@latest add #{components.join(" ")} --yes --overwrite")
70
+ end
71
+
72
+ puts "Project created successfully!"
73
+ puts "Run: cd #{name} && bundle install && nightingale run app.rb"
74
+ else
75
+ puts "Error: Frontend template not found at #{frontend_template}"
76
+ end
77
+ end
78
+
79
+ def self.run(script_path)
80
+ script_path = File.expand_path(script_path)
81
+ unless File.exist?(script_path)
82
+ puts "Error: Script not found: #{script_path}"
83
+ exit 1
84
+ end
85
+
86
+ puts "Starting Nightingale..."
87
+ puts "Script: #{script_path}"
88
+
89
+ # Start Backend (Sinatra)
90
+ server_pid = fork do
91
+ ENV["NIGHTINGALE_SCRIPT"] = script_path
92
+ # Suppress Sinatra startup logs if needed, or keep them
93
+ require "nightingale/server"
94
+ Nightingale::Server.run!
95
+ end
96
+
97
+ # Start Frontend (Vite)
98
+ # Check if we are in a project with a frontend folder
99
+ frontend_dir = File.join(Dir.pwd, "frontend")
100
+ vite_pid = nil
101
+
102
+ if File.directory?(frontend_dir)
103
+ puts "Starting Frontend (Vite)..."
104
+ vite_pid = fork do
105
+ Dir.chdir(frontend_dir)
106
+ exec "npm run dev"
107
+ end
108
+ else
109
+ # If no local frontend, maybe we should serve the gem's frontend?
110
+ # For now, warn.
111
+ puts "Warning: 'frontend' directory not found. UI might not load."
112
+ end
113
+
114
+ trap("INT") do
115
+ puts "\nStopping..."
116
+ Process.kill("TERM", server_pid)
117
+ Process.kill("TERM", vite_pid) if vite_pid
118
+ Process.wait(server_pid)
119
+ Process.wait(vite_pid) if vite_pid
120
+ exit
121
+ end
122
+
123
+ Process.wait(server_pid)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nightingale
4
+ module DSL
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def components
11
+ @components ||= []
12
+ end
13
+
14
+ def reset_components!
15
+ @components = []
16
+ end
17
+ end
18
+
19
+ def title(text)
20
+ Nightingale::Runner.current.add_component({ type: "title", props: { text: text } })
21
+ end
22
+
23
+ def markdown(text)
24
+ Nightingale::Runner.current.add_component({ type: "markdown", props: { content: text } })
25
+ end
26
+
27
+ def button(label, key: nil)
28
+ key ||= "button_#{label}"
29
+ # Check if this button was clicked in the current event
30
+ clicked = Nightingale::Runner.current.event_triggered?(key, "click")
31
+
32
+ Nightingale::Runner.current.add_component({
33
+ type: "button",
34
+ id: key,
35
+ props: { label: label, value: clicked }
36
+ })
37
+
38
+ clicked
39
+ end
40
+
41
+ def slider(label, min:, max:, value: nil, step: 1, key: nil)
42
+ key ||= "slider_#{label}"
43
+ current_value = Nightingale::Runner.current.get_widget_value(key) || value || min
44
+
45
+ Nightingale::Runner.current.add_component({
46
+ type: "slider",
47
+ id: key,
48
+ props: { label: label, min: min, max: max, value: current_value,
49
+ step: step }
50
+ })
51
+
52
+ current_value
53
+ end
54
+
55
+ def dataframe(data, key: nil)
56
+ key ||= "dataframe_#{data.object_id}"
57
+ # data should be an array of hashes or similar
58
+ Nightingale::Runner.current.add_component({
59
+ type: "dataframe",
60
+ id: key,
61
+ props: { data: data }
62
+ })
63
+ end
64
+
65
+ def session_state
66
+ Nightingale::Runner.current.session_state
67
+ end
68
+
69
+ # Layout helpers could go here (sidebar, etc.)
70
+ def sidebar(&block)
71
+ Nightingale::Runner.current.with_container("sidebar", &block)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Nightingale
6
+ class Runner
7
+ attr_reader :components, :session_state
8
+
9
+ def self.current
10
+ Thread.current[:nightingale_runner]
11
+ end
12
+
13
+ def initialize(script_path)
14
+ @script_path = script_path
15
+ @components = []
16
+ @session_state = {}
17
+ @widget_values = {}
18
+ @current_event = nil
19
+ @container_stack = []
20
+ end
21
+
22
+ def run(event = nil, widget_values = {})
23
+ @components = []
24
+ @current_event = event
25
+ @widget_values.merge!(widget_values) if widget_values
26
+
27
+ Thread.current[:nightingale_runner] = self
28
+
29
+ begin
30
+ # We load the script content and eval it
31
+ # This is a simple way to execute it.
32
+ # In a real app, we might want to use a separate process or more isolation.
33
+ content = File.read(@script_path)
34
+
35
+ # Create a context to evaluate in
36
+ context = Object.new
37
+ context.extend(Nightingale::DSL)
38
+
39
+ context.instance_eval(content, @script_path)
40
+ rescue StandardError => e
41
+ puts "Error running script: #{e.message}"
42
+ puts e.backtrace
43
+ add_component({ type: "error", props: { message: e.message, backtrace: e.backtrace } })
44
+ ensure
45
+ Thread.current[:nightingale_runner] = nil
46
+ end
47
+
48
+ @components
49
+ end
50
+
51
+ def add_component(component)
52
+ if @container_stack.any?
53
+ parent = @container_stack.last
54
+ parent[:children] ||= []
55
+ parent[:children] << component
56
+ else
57
+ @components << component
58
+ end
59
+ end
60
+
61
+ def with_container(type)
62
+ container = { type: type, props: {}, children: [] }
63
+ add_component(container)
64
+ @container_stack.push(container)
65
+ yield
66
+ @container_stack.pop
67
+ end
68
+
69
+ def get_widget_value(key)
70
+ @widget_values[key]
71
+ end
72
+
73
+ def event_triggered?(key, event_type)
74
+ return false unless @current_event
75
+
76
+ @current_event["id"] == key && @current_event["event"] == event_type
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "faye/websocket"
5
+ require "json"
6
+ require "listen"
7
+
8
+ module Nightingale
9
+ class Server < Sinatra::Base
10
+ set :server, "puma"
11
+ set :bind, "0.0.0.0"
12
+ set :port, 4567
13
+
14
+ # Store runners per session (connection)
15
+ # In a real app, we'd use a proper session store
16
+ @@runners = {}
17
+ @@connections = []
18
+
19
+ def self.run!
20
+ # Start file watcher
21
+ script_path = ENV["NIGHTINGALE_SCRIPT"]
22
+ if script_path && File.exist?(script_path)
23
+ puts "Watching #{script_path} for changes..."
24
+ listener = Listen.to(File.dirname(script_path),
25
+ only: /#{File.basename(script_path)}$/) do |modified, added, removed|
26
+ puts "File changed: #{modified}"
27
+ # Trigger rerun for all connections
28
+ @@connections.each do |ws|
29
+ runner = @@runners[ws.object_id]
30
+ next unless runner
31
+
32
+ puts "Rerunning for connection #{ws.object_id}"
33
+ tree = runner.run
34
+ ws.send({ type: "render", components: tree }.to_json)
35
+ end
36
+ end
37
+ listener.start
38
+ end
39
+
40
+ super
41
+ end
42
+
43
+ get "/" do
44
+ "Nightingale Server Running. Connect via WebSocket at /ws"
45
+ end
46
+
47
+ get "/ws" do
48
+ if Faye::WebSocket.websocket?(env)
49
+ ws = Faye::WebSocket.new(env)
50
+
51
+ ws.on :open do |event|
52
+ puts "WebSocket connection open"
53
+ @@connections << ws
54
+
55
+ # Initialize runner for this connection
56
+ script_path = ENV["NIGHTINGALE_SCRIPT"]
57
+ runner = Nightingale::Runner.new(script_path)
58
+ @@runners[ws.object_id] = runner
59
+
60
+ # Initial run
61
+ tree = runner.run
62
+ ws.send({ type: "render", components: tree }.to_json)
63
+ end
64
+
65
+ ws.on :message do |event|
66
+ data = JSON.parse(event.data)
67
+ puts "Received message: #{data}"
68
+
69
+ if data["type"] == "event"
70
+ runner = @@runners[ws.object_id]
71
+ if runner
72
+ # Rerun with event
73
+ # We pass the widget value from the event if applicable
74
+ # But usually the frontend sends all widget values or we track them?
75
+ # For MVP, let's assume the event contains the value of the widget that changed.
76
+ # And maybe we need to sync other widget values?
77
+ # Streamlit sends all widget states.
78
+ # For MVP, let's assume we just update the specific widget value in the runner's state?
79
+ # Or we pass it to run.
80
+
81
+ widget_values = data["values"] || {}
82
+ widget_values[data["id"]] = data["value"] if data["id"] && data.key?("value")
83
+
84
+ tree = runner.run(data, widget_values)
85
+ ws.send({ type: "render", components: tree }.to_json)
86
+ end
87
+ end
88
+ end
89
+
90
+ ws.on :close do |event|
91
+ puts "WebSocket connection closed"
92
+ @@connections.delete(ws)
93
+ @@runners.delete(ws.object_id)
94
+ ws = nil
95
+ end
96
+
97
+ return ws.rack_response
98
+ else
99
+ # Fallback for non-WS requests
100
+ "WebSocket connection required"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nightingale
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nightingale/version"
4
+ require_relative "nightingale/cli"
5
+ require_relative "nightingale/server"
6
+ require_relative "nightingale/runner"
7
+ require_relative "nightingale/dsl"
8
+
9
+ module Nightingale
10
+ class Error < StandardError; end
11
+ end
Binary file