diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ead4dfa8..a8b9026b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -32,8 +32,6 @@ jobs:
with:
ruby-version: "3.3.0"
bundler-cache: true
- - name: Check types
- run: bundle exec srb tc
- name: Run tests
run: bundle exec rake test
- uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 4888a070..b3334770 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ node_modules
*.gem
lib/mayu/vdom/profile.html
profile/
+mayu-vnode-update.json
diff --git a/.tool-versions b/.tool-versions
index bbd421c2..46b592f8 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-ruby 3.3.0
-nodejs 21.4.0
+ruby 4.0.1
+nodejs 24.1.0
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..8b64a455
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,47 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+
+- `lib/` contains the Ruby framework code; tests live alongside source as `*.test.rb` (e.g., `lib/mayu/state.rb` → `lib/mayu/state.test.rb`).
+- `lib/mayu/client/` holds the browser runtime (Node workspace).
+- `example/` is a runnable sample app plus `mayu.toml` server config.
+- `bin/` and `exe/` provide CLI entry points and scripts.
+- `vendor/` and `node_modules/` are dependency/vendor directories.
+
+## Architecture Overview
+
+- Server-rendered HTML with server-side state; the client runtime applies DOM patches over streaming updates.
+- Components return VDOM descriptors; the server diffs component trees and sends patch instructions.
+- Client stream and event serialization live under `lib/mayu/client/src/` and `lib/mayu/client/src/serializeEvent.ts`.
+
+## Build, Test, and Development Commands
+
+- `bundle install` installs Ruby dependencies.
+- `npm install` installs Node dependencies (root workspace).
+- `npm run build` builds the browser runtime via the `lib/mayu/client` workspace.
+- `rake test` runs the Minitest suite (glob: `lib/**/*.test.rb`).
+- `rake build` runs the client production build and builds the gem.
+- `cd example && bundle install && bin/mayu dev` starts the example app at `https://localhost:9292/`.
+
+## Coding Style & Naming Conventions
+
+- Ruby code uses `.rb` with adjacent tests named `*.test.rb`.
+- Prettier is configured (with `@prettier/plugin-ruby`) and is used via `npx prettier --write '**/*'`.
+- `lint-staged` runs Prettier on common file types before commits; keep changes formatted.
+
+## Testing Guidelines
+
+- Test framework: Minitest (see `test_helper.rb` and `Rakefile`).
+- Prefer higher-level tests; add focused unit tests for tricky edge cases.
+- Keep example app behavior up to date; it serves as a practical integration test.
+
+## Commit & Pull Request Guidelines
+
+- Commit messages in this repo are short, imperative sentences (e.g., “Fix margins”, “Update dependencies and modernize code”).
+- Include a clear PR description, link issues when relevant, and add screenshots/gifs for UI changes.
+- Confirm tests (`rake test`) and build (`npm run build`) before opening a PR.
+
+## Security & Configuration Tips
+
+- Development HTTPS uses a self-signed certificate; follow README instructions if your browser blocks `https://localhost:9292/`.
+- Server settings live in `example/mayu.toml`; use `[dev.server]` options for local development.
diff --git a/Gemfile b/Gemfile
index 7f8e27ff..6ce3ba12 100644
--- a/Gemfile
+++ b/Gemfile
@@ -10,16 +10,16 @@ group :development do
gem "guard", require: false
gem "localhost", require: false
gem "minitest", require: false
+ gem "minitest-mock", require: false
gem "minitest-reporters", require: false
+ gem "minitest-focus", require: false
gem "prettier", require: false
gem "rexml", require: false
gem "ruby-prof", require: false
- gem "sorbet", require: false
- gem "tapioca", require: false
- gem "nokogiri", require: false
gem "benchmark", require: false
-end
-
-gem "fuzzy_match"
+ gem "vernier", require: false
+ gem "irb", require: false
-gem "reline", "~> 0.4.2"
+ gem "readline", require: false
+ gem "reline", require: false
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index cf7b7db7..1bbed8c9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,74 +2,80 @@ PATH
remote: .
specs:
mayu-live (0.0.6)
- async (~> 2.8.0)
- async-container (~> 0.16.12)
- async-http (~> 0.61.0)
- base64 (~> 0.2.0)
- brotli (~> 0.4.0)
- image_size (~> 3.2.0)
- kramdown (~> 2.4.0)
- listen (~> 3.7.1)
- localhost (~> 1.1.9)
- mayu-css (~> 0.1.2)
- mime-types (~> 3.4.1)
- msgpack (~> 1.6.0)
- nanoid (~> 2.0.0)
- prometheus-client (~> 4.0.0)
- protocol-http (~> 0.25.0)
- pry (~> 0.14.2)
- rack (>= 3.0.4.1, < 3.0.10.0)
- rake (~> 13.0.6)
- rbnacl (~> 7.1.1)
- rmagick (~> 5.3.0)
- rouge (~> 4.0.0)
- sorbet-runtime (~> 0.5.10634)
- source_map (~> 3.0.1)
- syntax_tree (~> 5.3.0)
- syntax_tree-haml (~> 3.0.0)
+ async (~> 2.36.0)
+ async-container (~> 0.30.0)
+ async-http (~> 0.94.0)
+ base64 (~> 0.3)
+ brotli (~> 0.6.0)
+ dotenv (~> 3.2)
+ image_size (~> 3.4)
+ listen (~> 3.10.0)
+ localhost (~> 1.7)
+ mayu-css (~> 0.1.5)
+ mime-types (~> 3.7)
+ minitest (~> 6.0)
+ msgpack (~> 1.8)
+ oga (~> 3.4)
+ prometheus-client (~> 4.2.4)
+ pry (~> 0.15)
+ rack (>= 3.2.4)
+ rake (~> 13.3)
+ rbnacl (~> 7.1)
+ reline (~> 0.6)
+ rmagick (~> 6.1)
+ rouge (~> 4.7)
+ samovar (~> 2.4)
+ syntax_tree (~> 6.3)
+ syntax_tree-haml (~> 4.0)
syntax_tree-xml (~> 0.1.0)
- terminal-table (~> 3.0.2)
- toml-rb (~> 2.2.0)
+ terminal-table (~> 4.0)
+ toml (~> 0.3)
+ tsort (~> 0.2.0)
GEM
remote: https://rubygems.org/
specs:
- abbrev (0.1.2)
ansi (1.5.0)
- async (2.8.0)
- console (~> 1.10)
+ ast (2.4.3)
+ async (2.36.0)
+ console (~> 1.29)
fiber-annotation
- io-event (~> 1.1)
- timers (~> 4.1)
- async-container (0.16.12)
- async
- async-io
- async-http (0.61.0)
- async (>= 1.25)
- async-io (>= 1.28)
- async-pool (>= 0.2)
- protocol-http (~> 0.25.0)
- protocol-http1 (~> 0.16.0)
- protocol-http2 (~> 0.15.0)
- traces (>= 0.10.0)
- async-io (1.38.1)
- async
- async-pool (0.4.0)
- async (>= 1.25)
- base64 (0.2.0)
+ io-event (~> 1.11)
+ metrics (~> 0.12)
+ traces (~> 0.18)
+ async-container (0.30.0)
+ async (~> 2.22)
+ async-http (0.94.2)
+ async (>= 2.10.2)
+ async-pool (~> 0.11)
+ io-endpoint (~> 0.14)
+ io-stream (~> 0.6)
+ metrics (~> 0.12)
+ protocol-http (~> 0.58)
+ protocol-http1 (~> 0.36)
+ protocol-http2 (~> 0.22)
+ protocol-url (~> 0.2)
+ traces (~> 0.10)
+ async-pool (0.11.1)
+ async (>= 2.0)
+ base64 (0.3.0)
benchmark (0.3.0)
- brotli (0.4.0)
- builder (3.2.4)
- citrus (3.0.2)
+ brotli (0.6.0)
+ builder (3.3.0)
coderay (1.1.3)
- console (1.23.3)
+ console (1.30.2)
fiber-annotation
- fiber-local
- ffi (1.16.3)
+ fiber-local (~> 1.1)
+ json
+ date (3.5.1)
+ dotenv (3.2.0)
+ erb (6.0.1)
+ ffi (1.17.1)
fiber-annotation (0.2.0)
- fiber-local (1.0.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (0.1.2)
formatador (1.1.0)
- fuzzy_match (2.1.0)
guard (2.18.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
@@ -83,104 +89,121 @@ GEM
temple (>= 0.8.2)
thor
tilt
- image_size (3.2.0)
- io-console (0.7.1)
- io-event (1.4.1)
- json (2.7.1)
- kramdown (2.4.0)
- rexml
- listen (3.7.1)
+ image_size (3.4.0)
+ io-console (0.8.0)
+ io-endpoint (0.15.2)
+ io-event (1.14.2)
+ io-stream (0.6.1)
+ irb (1.16.0)
+ pp (>= 0.6.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.7.2)
+ listen (3.10.0)
+ logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- localhost (1.1.10)
+ localhost (1.7.0)
+ logger (1.6.0)
lumberjack (1.2.10)
- mayu-css (0.1.2-arm64-darwin)
- mayu-css (0.1.2-x86_64-darwin)
- mayu-css (0.1.2-x86_64-linux)
- method_source (1.0.0)
- mime-types (3.4.1)
- mime-types-data (~> 3.2015)
- mime-types-data (3.2023.1205)
- minitest (5.20.0)
+ mapping (1.1.1)
+ mayu-css (0.1.5)
+ mayu-css (0.1.5-aarch64-linux)
+ mayu-css (0.1.5-arm64-darwin)
+ mayu-css (0.1.5-x86_64-darwin)
+ mayu-css (0.1.5-x86_64-linux)
+ method_source (1.1.0)
+ metrics (0.12.2)
+ mime-types (3.7.0)
+ logger
+ mime-types-data (~> 3.2025, >= 3.2025.0507)
+ mime-types-data (3.2026.0113)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ minitest-focus (1.4.1)
+ minitest (> 5.0)
+ minitest-mock (5.27.0)
minitest-reporters (1.6.1)
ansi
builder
minitest (>= 5.0)
ruby-progressbar
- msgpack (1.6.1)
- nanoid (2.0.0)
+ msgpack (1.8.0)
nenv (0.3.0)
- netrc (0.11.0)
- nokogiri (1.16.2-arm64-darwin)
- racc (~> 1.4)
- nokogiri (1.16.2-x86_64-darwin)
- racc (~> 1.4)
- nokogiri (1.16.2-x86_64-linux)
- racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
- parallel (1.24.0)
+ observer (0.1.2)
+ oga (3.4)
+ ast
+ ruby-ll (~> 2.1)
+ parslet (2.0.0)
pkg-config (1.5.6)
+ pp (0.6.3)
+ prettyprint
prettier (4.0.4)
syntax_tree (>= 4.0.1)
syntax_tree-haml (>= 2.0.0)
syntax_tree-rbs (>= 0.2.0)
prettier_print (1.2.1)
- prism (0.19.0)
- prometheus-client (4.0.0)
- protocol-hpack (1.4.2)
- protocol-http (0.25.0)
- protocol-http1 (0.16.1)
- protocol-http (~> 0.22)
- protocol-http2 (0.15.1)
+ prettyprint (0.2.0)
+ prism (1.8.0)
+ prometheus-client (4.2.4)
+ base64
+ protocol-hpack (1.4.3)
+ protocol-http (0.58.1)
+ protocol-http1 (0.37.0)
+ protocol-http (~> 0.58)
+ protocol-http2 (0.22.1)
protocol-hpack (~> 1.4)
- protocol-http (~> 0.18)
- pry (0.14.2)
+ protocol-http (~> 0.47)
+ protocol-url (0.4.0)
+ pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
- racc (1.7.3)
- rack (3.0.9.1)
- rake (13.0.6)
+ psych (5.3.1)
+ date
+ stringio
+ rack (3.2.4)
+ rake (13.3.1)
rb-fsevent (0.11.2)
- rb-inotify (0.10.1)
+ rb-inotify (0.11.1)
ffi (~> 1.0)
- rbi (0.1.6)
- prism (>= 0.18.0, < 0.20)
- sorbet-runtime (>= 0.5.9204)
rbnacl (7.1.1)
ffi
- rbs (3.4.1)
- abbrev
- reline (0.4.2)
+ rbs (3.5.1)
+ logger
+ rdoc (7.1.0)
+ erb
+ psych (>= 4.0.0)
+ tsort
+ readline (0.0.4)
+ reline
+ reline (0.6.0)
io-console (~> 0.5)
- rexml (3.2.6)
- rmagick (5.3.0)
+ rexml (3.3.0)
+ strscan
+ rmagick (6.1.1)
+ observer (~> 0.1)
pkg-config (~> 1.4)
- rouge (4.0.1)
+ rouge (4.7.0)
+ ruby-ll (2.1.4)
+ ansi
+ ast
ruby-prof (1.7.0)
ruby-progressbar (1.13.0)
+ samovar (2.4.1)
+ console (~> 1.0)
+ mapping (~> 1.0)
shellany (0.0.1)
- sorbet (0.5.11181)
- sorbet-static (= 0.5.11181)
- sorbet-runtime (0.5.11181)
- sorbet-static (0.5.11181-universal-darwin)
- sorbet-static (0.5.11181-x86_64-linux)
- sorbet-static-and-runtime (0.5.11181)
- sorbet (= 0.5.11181)
- sorbet-runtime (= 0.5.11181)
- source_map (3.0.1)
- json
- spoom (1.2.1)
- sorbet (>= 0.5.10187)
- sorbet-runtime (>= 0.5.9204)
- thor (>= 0.19.2)
- syntax_tree (5.3.0)
+ stringio (3.2.0)
+ strscan (3.1.0)
+ syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
- syntax_tree-haml (3.0.0)
- haml (>= 5.2, != 6.0.0)
- prettier_print (>= 1.0.0)
- syntax_tree (>= 5.0.1)
+ syntax_tree-haml (4.0.3)
+ haml (>= 5.2)
+ prettier_print (>= 1.2.1)
+ syntax_tree (>= 6.0.0)
syntax_tree-rbs (1.0.0)
prettier_print
rbs
@@ -188,51 +211,50 @@ GEM
syntax_tree-xml (0.1.0)
prettier_print
syntax_tree (>= 2.0.1)
- tapioca (0.11.15)
- bundler (>= 2.2.25)
- netrc (>= 0.11.0)
- parallel (>= 1.21.0)
- rbi (>= 0.1.4, < 0.2)
- sorbet-static-and-runtime (>= 0.5.10820)
- spoom (~> 1.2.0, >= 1.2.0)
- thor (>= 1.2.0)
- yard-sorbet
temple (0.10.3)
- terminal-table (3.0.2)
- unicode-display_width (>= 1.1.1, < 3)
- thor (1.3.0)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ thor (1.3.1)
tilt (2.3.0)
- timers (4.3.5)
- toml-rb (2.2.0)
- citrus (~> 3.0, > 3.0)
- traces (0.11.1)
+ toml (0.3.0)
+ parslet (>= 1.8.0, < 3.0.0)
+ traces (0.18.2)
+ tsort (0.2.0)
unicode-display_width (2.5.0)
- yard (0.9.36)
- yard-sorbet (0.8.1)
- sorbet-runtime (>= 0.5)
- yard (>= 0.9)
+ vernier (1.9.0)
PLATFORMS
- arm64-darwin-22
- arm64-darwin-23
- x86_64-darwin-20
+ aarch64-linux
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86-linux
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
x86_64-linux
+ x86_64-linux-gnu
+ x86_64-linux-musl
DEPENDENCIES
benchmark
- fuzzy_match
guard
+ irb
localhost
mayu-live!
minitest
+ minitest-focus
+ minitest-mock
minitest-reporters
- nokogiri
prettier
- reline (~> 0.4.2)
+ readline
+ reline
rexml
ruby-prof
- sorbet
- tapioca
+ vernier
BUNDLED WITH
- 2.4.6
+ 2.5.11
diff --git a/README.md b/README.md
index dd07c67d..6372bbda 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,6 @@ having to configure anything!
- [Server](#server)
- [Development server](#development-server)
- [Production server](#production-server)
- - [Static typing](#static-typing)
- [Contributing](#contributing)
# Getting started
@@ -530,8 +529,6 @@ rather than to have a separate tree for tests.
It's also preferred to test things on a higher level, and only write unit
tests for specific edge cases and trickier situations.
-[Sorbet](https://sorbet.org/) is pretty good at finding errors.
-If the higher level tests pass, then everything works as expected.
The example app could also be considered to be a test.
It should always work and be updated to use the latest features.
@@ -582,13 +579,6 @@ hot_swap = false
self_signed_cert = false
```
-## Static typing
-
-Most files are strictly typed with [Sorbet](https://sorbet.org/).
-
-Some aren't strictly typed yet, but the goal is to enable
-strict typechecking everywhere.
-
# Contributing
Bug reports and pull requests are welcome on GitHub at
diff --git a/Rakefile b/Rakefile
index 3f745554..e97ebfa4 100644
--- a/Rakefile
+++ b/Rakefile
@@ -26,3 +26,10 @@ task :build do
system("npm", "-w", "lib/mayu/client", "run", "build:production")
system("gem", "build")
end
+
+namespace :profile do
+ desc "Profile vnode update path with Vernier"
+ task :vnodes_update do
+ sh "bundle", "exec", "ruby", "lib/mayu/runtime/vnodes/__test__/update_profile.rb"
+ end
+end
diff --git a/bin/tapioca b/bin/tapioca
deleted file mode 100755
index c0d89b7c..00000000
--- a/bin/tapioca
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-#
-# This file was generated by Bundler.
-#
-# The application 'tapioca' 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) =~ /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("tapioca", "tapioca")
diff --git a/bin/transform b/bin/transform
index 67165f92..cd0ddd7b 100755
--- a/bin/transform
+++ b/bin/transform
@@ -1,103 +1,7 @@
#!/usr/bin/env ruby
-# typed: true
# frozen_string_literal: true
require "bundler/setup"
-require "sorbet-runtime"
+require_relative "../lib/mayu/commands/transform"
-require_relative "../lib/mayu/resources/transformers/haml"
-
-require "rouge"
-
-DEFAULT_LINE_NUMBERS = true
-DEFAULT_COLORS = true
-
-module Main
- def self.main(source, source_path, line_numbers: DEFAULT_LINE_NUMBERS, colors: DEFAULT_COLORS)
- formatter = CodeFormatter.new(line_numbers:, colors:)
-
- puts "\e[1mInput:\e[0;2m #{source_path}\e[0m"
- puts formatter.format(source, Rouge::Lexers::Haml)
-
- transformed = Mayu::Resources::Transformers::Haml.transform(
- Mayu::Resources::Transformers::Haml::TransformOptions.new(
- source:,
- source_path:,
- source_line: 1,
- )
- ).output
-
- puts "\e[1mOutput:\e[0m"
-
- formatter.handle_parse_error(transformed) do
- puts formatter.format(transformed, Rouge::Lexers::Ruby)
- end
- end
-
- class CodeFormatter
- def initialize(line_numbers:, colors:, theme: Rouge::Themes::Monokai.new)
- @line_numbers = line_numbers
- @colors = colors
- @formatter = Rouge::Formatters::Terminal256.new(theme:)
- end
-
- def format(source, lexer)
- source.chomp
- .then { colorize(_1, lexer) }
- .then { prepend_line_numbers(_1) }
- end
-
- def handle_parse_error(source)
- yield
- rescue SyntaxTree::Parser::ParseError => e
- log_parse_error(source, e)
- raise
- end
-
- private
-
- def colorize(source, lexer)
- if @colors
- @formatter.format(lexer.lex(source))
- else
- source
- end
- end
-
- def prepend_line_numbers(lines, start_line: 1, error_line: nil)
- return lines unless @line_numbers
-
- number_format = "\e[38;5;250;48;5;236m%3d \e[0m"
- error_format = "\e[41m%s\e[0m"
-
- lines.each_line.map.with_index(start_line) do |line, i|
- if error_line == i
- Kernel.format(error_format, line.chomp) + "\n"
- else
- line
- end.prepend(Kernel.format(number_format, i))
- end
- end
-
- def extract_lines(str, from, to)
- str.each_line.to_a[from..to] || []
- end
-
- def log_parse_error(source, e)
- start_line = [0, 0].max
- formatted_source =
- prepend_line_numbers(
- extract_lines(source.to_s, start_line, -1),
- start_line: start_line + 1,
- error_line: e.lineno
- ).join
-
- puts(<<~ERROR)
- #{e.message} on line #{e.lineno} col #{e.column}
- #{formatted_source}
- ERROR
- end
- end
-end
-
-Main.main(ARGF.read, ARGF.path)
+Mayu::Commands::Transform.call
diff --git a/bin/transform_css b/bin/transform_css
deleted file mode 100755
index 87589d08..00000000
--- a/bin/transform_css
+++ /dev/null
@@ -1,100 +0,0 @@
-#!/usr/bin/env ruby
-# typed: true
-# frozen_string_literal: true
-
-require "bundler/setup"
-require "sorbet-runtime"
-
-require_relative "../lib/mayu/resources/transformers/css"
-
-require "rouge"
-
-DEFAULT_LINE_NUMBERS = true
-DEFAULT_COLORS = true
-
-module Main
- def self.main(source, source_path, line_numbers: DEFAULT_LINE_NUMBERS, colors: DEFAULT_COLORS)
- formatter = CodeFormatter.new(line_numbers:, colors:)
- puts "\e[1mInput:\e[0;2m #{source_path}\e[0m"
- puts formatter.format(source, Rouge::Lexers::Haml)
-
- transformed = Mayu::Resources::Transformers::CSS.transform(
- source:,
- source_path:,
- source_line: 1,
- ).output
-
- puts "\e[1mOutput:\e[0m"
-
- formatter.handle_parse_error(transformed) do
- puts formatter.format(transformed, Rouge::Lexers::Ruby)
- end
- end
-
- class CodeFormatter
- def initialize(line_numbers:, colors:, theme: Rouge::Themes::Monokai.new)
- @line_numbers = line_numbers
- @colors = colors
- @formatter = Rouge::Formatters::Terminal256.new(theme:)
- end
-
- def format(source, lexer)
- source.chomp
- .then { colorize(_1, lexer) }
- .then { prepend_line_numbers(_1) }
- end
-
- def handle_parse_error(source)
- yield
- rescue SyntaxTree::Parser::ParseError => e
- log_parse_error(source, e)
- raise
- end
-
- private
-
- def colorize(source, lexer)
- if @colors
- @formatter.format(lexer.lex(source))
- else
- source
- end
- end
-
- def prepend_line_numbers(lines, start_line: 1, error_line: nil)
- return lines unless @line_numbers
-
- number_format = "\e[38;5;250;48;5;236m%3d \e[0m"
- error_format = "\e[41m%s\e[0m"
-
- lines.each_line.map.with_index(start_line) do |line, i|
- if error_line == i
- Kernel.format(error_format, line.chomp) + "\n"
- else
- line
- end.prepend(Kernel.format(number_format, i))
- end
- end
-
- def extract_lines(str, from, to)
- str.each_line.to_a[from..to] || []
- end
-
- def log_parse_error(source, e)
- start_line = [0, 0].max
- formatted_source =
- prepend_line_numbers(
- extract_lines(source.to_s, start_line, -1),
- start_line: start_line + 1,
- error_line: e.lineno
- ).join
-
- puts(<<~ERROR)
- #{e.message} on line #{e.lineno} col #{e.column}
- #{formatted_source}
- ERROR
- end
- end
-end
-
-Main.main(ARGF.read, ARGF.path)
diff --git a/example/.dockerignore b/example/.dockerignore
index 9c2ea1d2..118f6d35 100644
--- a/example/.dockerignore
+++ b/example/.dockerignore
@@ -1,8 +1,12 @@
+/*.mayu-bundle
+/vendor
+/.assets
+/Dockerfile
+/dist
+/tmp
+/fly.toml
+
*.ipc
.DS_store
-.assets
-Dockerfile
-dist
-tmp
-vendor
-fly.toml
+
+*.env*
diff --git a/example/.gitignore b/example/.gitignore
index 2d1c0931..a6be3195 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -1,4 +1,8 @@
-vendor/bundle
-*.mayu-bundle
-tmp/
+/vendor
+/*.mayu-bundle
+/tmp
+
*.ipc
+.DS_store
+
+.env*
diff --git a/example/Gemfile b/example/Gemfile
index 7013b9c1..f0dcd230 100644
--- a/example/Gemfile
+++ b/example/Gemfile
@@ -2,7 +2,9 @@
source "https://rubygems.org"
-gem "mayu-live"
-# gem "mayu-live", path: ".."
+# gem "mayu-live"
+gem "mayu-live", path: ".."
gem "fuzzy_match"
+
+gem "kramdown", "~> 2.4"
diff --git a/example/Gemfile.lock b/example/Gemfile.lock
index c6427852..25734fe0 100644
--- a/example/Gemfile.lock
+++ b/example/Gemfile.lock
@@ -1,142 +1,202 @@
+PATH
+ remote: ..
+ specs:
+ mayu-live (0.0.6)
+ async (~> 2.36.0)
+ async-container (~> 0.30.0)
+ async-http (~> 0.94.0)
+ base64 (~> 0.3)
+ brotli (~> 0.6.0)
+ dotenv (~> 3.2)
+ image_size (~> 3.4)
+ listen (~> 3.10.0)
+ localhost (~> 1.7)
+ mayu-css (~> 0.1.5)
+ mime-types (~> 3.7)
+ minitest (~> 6.0)
+ msgpack (~> 1.8)
+ oga (~> 3.4)
+ prometheus-client (~> 4.2.4)
+ pry (~> 0.15)
+ rack (>= 3.2.4)
+ rake (~> 13.3)
+ rbnacl (~> 7.1)
+ reline (~> 0.6)
+ rmagick (~> 6.1)
+ rouge (~> 4.7)
+ samovar (~> 2.4)
+ syntax_tree (~> 6.3)
+ syntax_tree-haml (~> 4.0)
+ syntax_tree-xml (~> 0.1.0)
+ terminal-table (~> 4.0)
+ toml (~> 0.3)
+ tsort (~> 0.2.0)
+
GEM
remote: https://rubygems.org/
specs:
- async (2.8.0)
- console (~> 1.10)
+ ansi (1.5.0)
+ ast (2.4.3)
+ async (2.36.0)
+ console (~> 1.29)
fiber-annotation
- io-event (~> 1.1)
- timers (~> 4.1)
- async-container (0.16.12)
- async
- async-io
- async-http (0.61.0)
- async (>= 1.25)
- async-io (>= 1.28)
- async-pool (>= 0.2)
- protocol-http (~> 0.25.0)
- protocol-http1 (~> 0.16.0)
- protocol-http2 (~> 0.15.0)
- traces (>= 0.10.0)
- async-io (1.38.1)
- async
- async-pool (0.4.0)
- async (>= 1.25)
- base64 (0.2.0)
- brotli (0.4.0)
- citrus (3.0.2)
+ io-event (~> 1.11)
+ metrics (~> 0.12)
+ traces (~> 0.18)
+ async-container (0.30.0)
+ async (~> 2.22)
+ async-http (0.94.2)
+ async (>= 2.10.2)
+ async-pool (~> 0.11)
+ io-endpoint (~> 0.14)
+ io-stream (~> 0.6)
+ metrics (~> 0.12)
+ protocol-http (~> 0.58)
+ protocol-http1 (~> 0.36)
+ protocol-http2 (~> 0.22)
+ protocol-url (~> 0.2)
+ traces (~> 0.10)
+ async-pool (0.11.1)
+ async (>= 2.0)
+ base64 (0.3.0)
+ brotli (0.6.0)
coderay (1.1.3)
- console (1.23.3)
+ console (1.30.2)
fiber-annotation
- fiber-local
- ffi (1.16.3)
+ fiber-local (~> 1.1)
+ json
+ dotenv (3.2.0)
+ ffi (1.17.1)
+ ffi (1.17.1-aarch64-linux-gnu)
+ ffi (1.17.1-aarch64-linux-musl)
+ ffi (1.17.1-arm-linux-gnu)
+ ffi (1.17.1-arm-linux-musl)
+ ffi (1.17.1-arm64-darwin)
+ ffi (1.17.1-x86-linux-gnu)
+ ffi (1.17.1-x86-linux-musl)
+ ffi (1.17.1-x86_64-darwin)
+ ffi (1.17.1-x86_64-linux-gnu)
+ ffi (1.17.1-x86_64-linux-musl)
fiber-annotation (0.2.0)
- fiber-local (1.0.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (1.0.0)
fuzzy_match (2.1.0)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
- image_size (3.2.0)
- io-event (1.4.1)
- json (2.7.1)
- kramdown (2.4.0)
- rexml
- listen (3.7.1)
+ image_size (3.4.0)
+ io-console (0.8.0)
+ io-endpoint (0.15.2)
+ io-event (1.14.2)
+ io-stream (0.6.1)
+ json (2.10.2)
+ kramdown (2.5.1)
+ rexml (>= 3.3.9)
+ listen (3.10.0)
+ logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- localhost (1.1.10)
- mayu-css (0.1.2-arm64-darwin)
- mayu-css (0.1.2-x86_64-darwin)
- mayu-live (0.0.6)
- async (~> 2.8.0)
- async-container (~> 0.16.12)
- async-http (~> 0.61.0)
- base64 (~> 0.2.0)
- brotli (~> 0.4.0)
- image_size (~> 3.2.0)
- kramdown (~> 2.4.0)
- listen (~> 3.7.1)
- localhost (~> 1.1.9)
- mayu-css (~> 0.1.2)
- mime-types (~> 3.4.1)
- msgpack (~> 1.6.0)
- nanoid (~> 2.0.0)
- prometheus-client (~> 4.0.0)
- protocol-http (~> 0.25.0)
- pry (~> 0.14.2)
- rack (>= 3.0.4.1, < 3.0.9.0)
- rake (~> 13.0.6)
- rbnacl (~> 7.1.1)
- rmagick (~> 5.3.0)
- rouge (~> 4.0.0)
- sorbet-runtime (~> 0.5.10634)
- source_map (~> 3.0.1)
- syntax_tree (~> 5.3.0)
- syntax_tree-haml (~> 3.0.0)
- syntax_tree-xml (~> 0.1.0)
- terminal-table (~> 3.0.2)
- toml-rb (~> 2.2.0)
- method_source (1.0.0)
- mime-types (3.4.1)
- mime-types-data (~> 3.2015)
- mime-types-data (3.2023.1205)
- msgpack (1.6.1)
- nanoid (2.0.0)
- pkg-config (1.5.6)
+ localhost (1.7.0)
+ logger (1.6.6)
+ mapping (1.1.1)
+ mayu-css (0.1.5)
+ mayu-css (0.1.5-aarch64-linux)
+ mayu-css (0.1.5-arm64-darwin)
+ mayu-css (0.1.5-x86_64-darwin)
+ mayu-css (0.1.5-x86_64-linux)
+ method_source (1.1.0)
+ metrics (0.12.2)
+ mime-types (3.7.0)
+ logger
+ mime-types-data (~> 3.2025, >= 3.2025.0507)
+ mime-types-data (3.2026.0113)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ msgpack (1.8.0)
+ observer (0.1.2)
+ oga (3.4)
+ ast
+ ruby-ll (~> 2.1)
+ parslet (2.0.0)
+ pkg-config (1.6.0)
prettier_print (1.2.1)
- prometheus-client (4.0.0)
- protocol-hpack (1.4.2)
- protocol-http (0.25.0)
- protocol-http1 (0.16.1)
- protocol-http (~> 0.22)
- protocol-http2 (0.15.1)
+ prism (1.8.0)
+ prometheus-client (4.2.4)
+ base64
+ protocol-hpack (1.5.1)
+ protocol-http (0.58.1)
+ protocol-http1 (0.37.0)
+ protocol-http (~> 0.58)
+ protocol-http2 (0.22.1)
protocol-hpack (~> 1.4)
- protocol-http (~> 0.18)
- pry (0.14.2)
+ protocol-http (~> 0.47)
+ protocol-url (0.4.0)
+ pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
- rack (3.0.8)
- rake (13.0.6)
+ rack (3.2.4)
+ rake (13.3.1)
rb-fsevent (0.11.2)
- rb-inotify (0.10.1)
+ rb-inotify (0.11.1)
ffi (~> 1.0)
- rbnacl (7.1.1)
- ffi
- rexml (3.2.6)
- rmagick (5.3.0)
+ rbnacl (7.1.2)
+ ffi (~> 1)
+ reline (0.6.0)
+ io-console (~> 0.5)
+ rexml (3.4.1)
+ rmagick (6.1.1)
+ observer (~> 0.1)
pkg-config (~> 1.4)
- rouge (4.0.1)
- sorbet-runtime (0.5.11188)
- source_map (3.0.1)
- json
- syntax_tree (5.3.0)
+ rouge (4.7.0)
+ ruby-ll (2.1.4)
+ ansi
+ ast
+ samovar (2.4.1)
+ console (~> 1.0)
+ mapping (~> 1.0)
+ syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
- syntax_tree-haml (3.0.0)
- haml (>= 5.2, != 6.0.0)
- prettier_print (>= 1.0.0)
- syntax_tree (>= 5.0.1)
+ syntax_tree-haml (4.0.3)
+ haml (>= 5.2)
+ prettier_print (>= 1.2.1)
+ syntax_tree (>= 6.0.0)
syntax_tree-xml (0.1.0)
prettier_print
syntax_tree (>= 2.0.1)
temple (0.10.3)
- terminal-table (3.0.2)
- unicode-display_width (>= 1.1.1, < 3)
- thor (1.3.0)
- tilt (2.3.0)
- timers (4.3.5)
- toml-rb (2.2.0)
- citrus (~> 3.0, > 3.0)
- traces (0.11.1)
- unicode-display_width (2.5.0)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ thor (1.3.2)
+ tilt (2.6.0)
+ toml (0.3.0)
+ parslet (>= 1.8.0, < 3.0.0)
+ traces (0.18.2)
+ tsort (0.2.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
PLATFORMS
- arm64-darwin-22
- arm64-darwin-23
- x86_64-darwin-20
+ aarch64-linux
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ ruby
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux
+ x86_64-linux-gnu
+ x86_64-linux-musl
DEPENDENCIES
fuzzy_match
- mayu-live
+ kramdown (~> 2.4)
+ mayu-live!
BUNDLED WITH
- 2.5.3
+ 2.6.2
diff --git a/example/README.md b/example/README.md
index d7e5bd84..ec9ba16a 100644
--- a/example/README.md
+++ b/example/README.md
@@ -1,23 +1,33 @@
-# example
+# [mayu.live](https://mayu.live/)
-## setup
+## Setup
- bundle install
+Install dependencies
-## dev
+```bash
+bundle install
+```
-start dev server
+## Development
- bin/mayu dev
+Start the development server
-## build
+```bash
+bin/mayu dev
+```
-builds a production bundle
+## Build
- bin/mayu build
+Builds a production bundle
-## serve
+```bash
+bin/mayu build
+```
-loads a production bundle
+## Start
- bin/mayu serve
+Loads a production bundle and starts the server in production mode.
+
+```bash
+bin/mayu start
+```
diff --git a/example/app/colors.rb b/example/app/colors.rb
deleted file mode 100644
index b6b74e16..00000000
--- a/example/app/colors.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-def format_component(c)
- format("%d%%", (c.to_i(16) / 255.0 * 100).round)
-end
-
-File.write(
- "root.css",
- File
- .read("root.css")
- .gsub(/#([[:xdigit:]]{8})/) do
- $~[1].scan(/../) => [r, g, b, a]
- [
- [r, g, b].map { format_component(_1) }.join(" "),
- format_component(a)
- ].join(" / ")
- end
- .gsub(/#([[:xdigit:]]{6})/) do
- $~[1].scan(/../).map { format_component(_1) }.join(" ")
- end
- .gsub(/#([[:xdigit:]]{3})\b/) do
- $~[1].scan(/./).map { format_component(_1 * 2) }.join(" ")
- end
- .gsub(/#([[:xdigit:]]{4})\b/) do
- $~[1].scan(/./) => [r, g, b, a]
- [
- [r, g, b].map { format_component(_1 * 2) }.join(" "),
- format_component(a * 2)
- ].join(" / ")
- end
-)
diff --git a/example/app/components/Clock.haml b/example/app/components/Clock.haml
index fb6d3461..3f87f00f 100644
--- a/example/app/components/Clock.haml
+++ b/example/app/components/Clock.haml
@@ -1,12 +1,11 @@
:ruby
- def self.get_initial_state(**)
- time = Time.now.utc.round + 0.5
- { hour: time.hour, min: time.min, sec: time.sec }
+ def initialize
+ @time = Time.now.utc.round + 0.5
end
def mount
loop do
- update(self.class.get_initial_state)
+ @time = Time.now.utc.round + 0.5
sleep 0.5
end
end
@@ -27,13 +26,13 @@
%g
- x1, y1, x2 = [0, 0, 0]
- y2 = -50
- - style = { transform: format("rotateZ(%.5fturn)", state[:hour] / 12.0) }
+ - style = { transform: format("rotateZ(%.5fturn)", @time.hour / 12.0) }
%line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="6"){style:}
- y2 = -60
- - style = { transform: format("rotateZ(%.5fturn)", state[:hour] + state[:min] / 60.0) }
+ - style = { transform: format("rotateZ(%.5fturn)", @time.hour + @time.min / 60.0) }
%line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="3"){style:}
- y2 = -80
- - style = { transform: format("rotateZ(%.5fturn)", state[:hour] * 60.0 + state[:min] + state[:sec] / 60.0) }
+ - style = { transform: format("rotateZ(%.5fturn)", @time.hour * 60.0 + @time.min + @time.sec / 60.0) }
%line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="1"){style:}
%circle(cx=0 cy=0 r=3 fill=stroke)
%g
diff --git a/example/app/components/Form/Button.haml b/example/app/components/Form/Button.haml
index 6b6c6238..75a9d941 100644
--- a/example/app/components/Form/Button.haml
+++ b/example/app/components/Form/Button.haml
@@ -1,4 +1,4 @@
-%button{style: { __button_color: props.fetch(:color, "var(--accent-color)") }, **props.except(:color)}
+%button{style: { __button_color: $color || "var(--accent-color)" }, **$*.except(:color)}
%slot
:css
diff --git a/example/app/components/Form/Checkbox.haml b/example/app/components/Form/Checkbox.haml
index 9423badb..26b88487 100644
--- a/example/app/components/Form/Checkbox.haml
+++ b/example/app/components/Form/Checkbox.haml
@@ -1,3 +1,17 @@
+:ruby
+ def initialize
+ @id = "checkbox-#{SecureRandom.alphanumeric}"
+ end
+
+%div{class: [:group, $group_class]}
+ %input(id=@id class=$class){
+ type: "checkbox",
+ placeholder: $label,
+ **$*.except(:label),
+ }
+ %label(for=@id)
+ = $label
+
:css
.group {
position: relative;
@@ -27,25 +41,3 @@
label {
}
-:ruby
- classname = [
- styles[:checkbox],
- $class,
- ].compact.join(" ")
-
- group_classname = [
- styles.group,
- $group_class,
- ].compact.join(" ")
-
- id = "checkbox-#{vnode_id}"
-
-%div(class=group_classname)
- %input(id=id){
- class: classname,
- type: "checkbox",
- placeholder: $label,
- **props.except(:label),
- }
- %label(for=id)
- = $label
diff --git a/example/app/components/Form/Input.haml b/example/app/components/Form/Input.haml
index e0f9c323..314897dc 100644
--- a/example/app/components/Form/Input.haml
+++ b/example/app/components/Form/Input.haml
@@ -1,5 +1,5 @@
.group
- %input(type="text" class=$class label=$label){**props}
+ %input(type="text" class=$class label=$label){**$*}
%label= $label
:css
diff --git a/example/app/components/Form/Select.haml b/example/app/components/Form/Select.haml
index 3393801e..3eeae07d 100644
--- a/example/app/components/Form/Select.haml
+++ b/example/app/components/Form/Select.haml
@@ -1,3 +1,8 @@
+%div
+ %select(type="text" class=$class placeholder=$label){**$*}
+ %slot
+ %label= $label
+
:css
div {
position: relative;
@@ -46,8 +51,3 @@
font-size: 0.9em;
opacity: 1;
}
-
-%div
- %select(type="text" class=$class placeholder=$label){**props}
- %slot
- %label= $label
diff --git a/example/app/components/Layout/Footer.haml b/example/app/components/Layout/Footer.haml
index 30770e00..471256e5 100644
--- a/example/app/components/Layout/Footer.haml
+++ b/example/app/components/Layout/Footer.haml
@@ -1,5 +1,4 @@
:ruby
- require "mayu/version"
Badge = import("./Footer/Badge")
BADGES = [
@@ -42,7 +41,17 @@
%dt Ruby
%dd #{::RUBY_VERSION}
%dt YJIT
- %dd #{RubyVM::YJIT.enabled? ? "enabled" : "disabled"}
+ %dd
+ = if defined?(RubyVM::YJIT)
+ = RubyVM::YJIT.enabled? ? "enabled" : "disabled"
+ = else
+ = "Unsupported"
+ %dt ZJIT
+ %dd
+ = if defined?(RubyVM::ZJIT)
+ = RubyVM::ZJIT.enabled? ? "enabled" : "disabled"
+ = else
+ = "Unsupported"
%p.badges
= BADGES.map do |badge|
diff --git a/example/app/components/Layout/FullWidthPageWithMenu.haml b/example/app/components/Layout/FullWidthPageWithMenu.haml
index f0da0263..a7e3638b 100644
--- a/example/app/components/Layout/FullWidthPageWithMenu.haml
+++ b/example/app/components/Layout/FullWidthPageWithMenu.haml
@@ -47,7 +47,7 @@
nav {
border-bottom: 0;
border-right: var(--thin-border);
- container-type: size;
+ container-type: inline-size;
}
}
diff --git a/example/app/components/Layout/Header.haml b/example/app/components/Layout/Header.haml
index 1aec6c8d..ae39459a 100644
--- a/example/app/components/Layout/Header.haml
+++ b/example/app/components/Layout/Header.haml
@@ -1,13 +1,13 @@
:ruby
- Icon = import("/app/components/UI/Icon")
- Logo = svg("./logo.svg")
- Title = svg("./title.svg")
+ Icon = import("/components/UI/Icon")
+ Logo = import("./logo.svg")
+ Title = import("./title.svg")
%header
%h1
%a.title(href="/" title="Start page")
%img.logo(src=Logo)
- %img.title-text(src=Title)
+ %img.titleText(src=Title)
%nav
%menu
%li
@@ -92,6 +92,6 @@
max-height: 2.5em;
}
- .title-text {
+ .titleText {
max-height: 2em;
}
diff --git a/example/app/components/Layout/Heading.haml b/example/app/components/Layout/Heading.haml
index 087204dd..fc7f64dd 100644
--- a/example/app/components/Layout/Heading.haml
+++ b/example/app/components/Layout/Heading.haml
@@ -4,15 +4,14 @@
:ruby
level = $level.to_i.clamp(1, 6)
tag = :"h#{level}"
- classname = styles[:h, tag, styles[:class]]
- Mayu::VDOM::H[
+ return H[
tag,
- *children,
- **mayu.merge_props(
- props.except(:level),
+ H.slot(self),
+ **self.class.merge_props(
+ $*.except(:level),
+ { class: [:h, tag] },
{ class: $class },
- { class: classname },
)
]
:css
diff --git a/example/app/components/Layout/MaxWidth.haml b/example/app/components/Layout/MaxWidth.haml
index c898ac94..3b4d0a84 100644
--- a/example/app/components/Layout/MaxWidth.haml
+++ b/example/app/components/Layout/MaxWidth.haml
@@ -1,13 +1,12 @@
+:ruby
+:ruby
+ return H[
+ ($as || $tag || :div).to_sym,
+ H.slot(self),
+ **self.class.merge_props($*, { class: :maxWidth })
+ ]
:css
.maxWidth {
width: min(100% - 4rem, var(--page-max-width));
margin-inline: auto;
}
-
-:ruby
- Mayu::VDOM::H[
- ($as || $tag || :div).to_sym,
- *children,
- **props,
- class: styles[:maxWidth, $class]
- ]
diff --git a/example/app/components/Layout/Page.haml b/example/app/components/Layout/Page.haml
index f09ea780..26be1bb0 100644
--- a/example/app/components/Layout/Page.haml
+++ b/example/app/components/Layout/Page.haml
@@ -1,6 +1,6 @@
:ruby
- MaxWidth = import("/app/components/Layout/MaxWidth")
- Heading = import("/app/components/Layout/Heading")
+ MaxWidth = import("/components/Layout/MaxWidth")
+ Heading = import("/components/Layout/Heading")
%MaxWidth(grid)
.page
diff --git a/example/app/components/Layout/logo.png b/example/app/components/Layout/logo.png
new file mode 100644
index 00000000..9fa311ab
Binary files /dev/null and b/example/app/components/Layout/logo.png differ
diff --git a/example/app/components/Markdown.haml b/example/app/components/Markdown.haml
index e8f98a87..67ede7af 100644
--- a/example/app/components/Markdown.haml
+++ b/example/app/components/Markdown.haml
@@ -16,7 +16,7 @@
# print "\e[0m" if node.has_key?(:children)
case node
in type: :root, children:
- Mayu::VDOM::H[:div, *visit_nodes(children)]
+ visit_nodes(children)
in type: :header, children:, options: { level: }
build(:header, :"h#{level}", *visit_nodes(children), level:)
in type: :text | :blank, value:
@@ -31,22 +31,39 @@
end
build(:a, :a, *visit_nodes(children), **attrs) do
- Mayu::VDOM::H[:a, *visit_nodes(children), **attrs]
+ H[:a, *visit_nodes(children), **attrs]
end
in type: :codeblock, value:
- Mayu::VDOM::H[:code, value]
+ H[:code, value]
in type: :br
- Mayu::VDOM::H[:br]
+ H[:br]
in type: :codespan, value:, options: { codespan_delimiter: "```" }
header, code = value.sub(/\A`+/, '').split(/\s/, 2).map(&:strip)
- Mayu::VDOM::H[Highlight, code, language: header]
+ H[Highlight, code, language: header]
in type: :codespan, value:, options: { codespan_delimiter: "`" }
build(:code, :code, value.strip)
+ in type: :p, children:, **rest
+ children.chunk do |child|
+ child in {
+ type: :codespan,
+ options: { codespan_delimiter: "```" }
+ }
+ end.map do |outside_p, chunk|
+ if outside_p
+ visit_nodes(chunk)
+ else
+ build(:p, :p, *visit_nodes(chunk))
+ end
+ end.flatten
in type:, children:, **rest
build(type, type, *visit_nodes(children))
in type:
Console.logger.error(self, "Unsupported node", JSON.pretty_generate(node))
- Mayu::VDOM::H[:pre, "Unsupported node:\n\n#{JSON.pretty_generate(node)}"]
+ H[
+ :pre,
+ "Unsupported node:\n\n#{JSON.pretty_generate(node)}",
+ **self.class.merge_props(class: :unsupported)
+ ]
end
end
@@ -57,29 +74,38 @@
def build(type, default, *children, **props, &block)
if elems = $elems
if elem = elems[type]
- return Mayu::VDOM::H[elem, *children, **props]
+ return H[elem, *children, **props]
end
end
return yield if block_given?
- Mayu::VDOM::H[default, *children, class: styles[default]]
+
+ H[
+ default,
+ *children,
+ **self.class.merge_props({ class: :"__#{default}" })
+ ]
end
+.markdown{ class: $class }
+ = visit_node(Kramdown::Document.new(Array(__children).join).to_hash_ast)
+
:css
+ .markdown {
+ }
+
.unsupported {
font-family: var(--font-mono), monospace;
color: red;
}
- .code {
+ code {
padding: .25em;
background: var(--material-color-blue-gray-50);
border: var(--thin-border);
border-radius: 2px;
}
- .li > .p {
+ li > p {
margin: .25em 0;
}
-
-= visit_node(Kramdown::Document.new(children.join).to_hash_ast)
diff --git a/example/app/components/UI/Details.haml b/example/app/components/UI/Details.haml
index 60023dd3..338018b8 100644
--- a/example/app/components/UI/Details.haml
+++ b/example/app/components/UI/Details.haml
@@ -13,22 +13,22 @@
Card = import("./Card")
- def self.get_initial_state(initial_open: false, lazy: false, **) = {
- open: initial_open,
- loaded: initial_open || !lazy
- }
+ def initialize
+ @open = $initial_open
+ @loaded = $initial_open || !$lazy || false
+ end
def handle_toggle(e)
- if e in { target: { open: true } }
- update(loaded: true)
- end
+ e => { target: { open: } }
+ @open = open
+ @loaded ||= open
end
%Card(class=$class)
- %details(ontoggle=handle_toggle){open: state[:open]}
+ %details(ontoggle=handle_toggle open=@open)
%summary= $summary
- .content
- = if state[:loaded]
+ %div
+ = if @loaded
%slot
= else
%p Loading…
diff --git a/example/app/components/UI/Highlight.haml b/example/app/components/UI/Highlight.haml
index dd0eca59..a6a762cc 100644
--- a/example/app/components/UI/Highlight.haml
+++ b/example/app/components/UI/Highlight.haml
@@ -2,29 +2,57 @@
::Kernel.require("rouge")
def get_tokens
- lexer&.lex(children.join)
+ lexer&.lex(Array(__children).join)
end
def lexer
return unless $language
+
case $language.to_sym
- when :haml
+ in :haml
Rouge::Lexers::Haml.new
- when :shell
+ in :shell | :sh
Rouge::Lexers::Shell.new
- when :jsx
+ in :jsx
Rouge::Lexers::JSX.new
- when :ruby
+ in :ruby | :rb
Rouge::Lexers::Ruby.new
- when :html
+ in :html
Rouge::Lexers::HTML.new
- when :css
+ in :css
Rouge::Lexers::CSS.new
- when :json
+ in :json
Rouge::Lexers::JSON.new
+ in :svg
+ Rouge::Lexers::XML.new
+ else
+ Console.logger.error(self, "No lexer found for #{language}")
+ nil
+ end
+ end
+
+ def token_class_name(token)
+ shortname = token.shortname
+
+ unless shortname.empty?
+ shortname.to_sym
end
end
+= if tokens = get_tokens
+ .container
+ .card
+ %code.highlight
+ = tokens.map do |token, str|
+ = if str == " " && !token.shortname
+
+ = else
+ %span{class: token_class_name(token)}= str
+= else
+ .container
+ .card
+ %code.highlight.text= Array(__children).join
+
:css
.container {
display: grid;
@@ -39,16 +67,6 @@
overflow-y: auto;
}
-= if tokens = get_tokens
- .container
- .card
- %code.highlight
- = tokens.map do |token, str|
- = if str == " " && !token.shortname
-
- = else
- %span{class: token.shortname.to_sym}= str
-= else
- .container
- .card
- %code.highlight= children.join
+ .text {
+ color: #fff;
+ }
diff --git a/example/app/components/UI/Icon/Icon.haml b/example/app/components/UI/Icon/Icon.haml
index de4bf3df..0f6f5315 100644
--- a/example/app/components/UI/Icon/Icon.haml
+++ b/example/app/components/UI/Icon/Icon.haml
@@ -1,48 +1,48 @@
:ruby
ICONS = {
- "arrows-rotate" => svg("./arrows-rotate-solid.svg"),
- "bars" => svg("./bars-solid.svg"),
- "cloud-arrow-down" => svg("./cloud-arrow-down-solid.svg"),
- "code" => svg("./code-solid.svg"),
- "code-compare" => svg("./code-compare-solid.svg"),
- "code-pull-request" => svg("./code-pull-request-solid.svg"),
- "dice" => svg("./dice-solid.svg"),
- "file-code" => svg("./file-code-solid.svg"),
- "file-image" => svg("./file-image-solid.svg"),
- "file-lines" => svg("./file-lines-solid.svg"),
- "file" => svg("./file-solid.svg"),
- "filter" => svg("./filter-solid.svg"),
- "fire" => svg("./fire-solid.svg"),
- "flask" => svg("./flask-solid.svg"),
- "folder-open" => svg("./folder-open-solid.svg"),
- "folder" => svg("./folder-solid.svg"),
- "forward-step" => svg("./forward-step-solid.svg"),
- "gauge-high" => svg("./gauge-high-solid.svg"),
- "gauge" => svg("./gauge-solid.svg"),
- "gem" => svg("./gem-solid.svg"),
- "github" => svg("./github.svg"),
- "globe" => svg("./globe-solid.svg"),
- "heart" => svg("./heart-solid.svg"),
- "html5" => svg("./html5.svg"),
- "keyboard" => svg("./keyboard-solid.svg"),
- "language" => svg("./language-solid.svg"),
- "laptop-code" => svg("./laptop-code-solid.svg"),
- "link" => svg("./link-solid.svg"),
- "minus" => svg("./minus-solid.svg"),
- "network-wired" => svg("./network-wired-solid.svg"),
- "pause" => svg("./pause-solid.svg"),
- "person-digging" => svg("./person-digging-solid.svg"),
- "play" => svg("./play-solid.svg"),
- "plus" => svg("./plus-solid.svg"),
- "question" => svg("./question-solid.svg"),
- "rocket" => svg("./rocket-solid.svg"),
- "seedling" => svg("./seedling-solid.svg"),
- "server" => svg("./server-solid.svg"),
- "square-github" => svg("./square-github.svg"),
- "star" => svg("./star-solid.svg"),
- "up-right-from-square" => svg("./up-right-from-square-solid.svg"),
- "wand-magic-sparkles" => svg("./wand-magic-sparkles-solid.svg"),
- "xmark" => svg("./xmark-solid.svg"),
+ "arrows-rotate" => import("./arrows-rotate-solid.svg"),
+ "bars" => import("./bars-solid.svg"),
+ "cloud-arrow-down" => import("./cloud-arrow-down-solid.svg"),
+ "code" => import("./code-solid.svg"),
+ "code-compare" => import("./code-compare-solid.svg"),
+ "code-pull-request" => import("./code-pull-request-solid.svg"),
+ "dice" => import("./dice-solid.svg"),
+ "file-code" => import("./file-code-solid.svg"),
+ "file-image" => import("./file-image-solid.svg"),
+ "file-lines" => import("./file-lines-solid.svg"),
+ "file" => import("./file-solid.svg"),
+ "filter" => import("./filter-solid.svg"),
+ "fire" => import("./fire-solid.svg"),
+ "flask" => import("./flask-solid.svg"),
+ "folder-open" => import("./folder-open-solid.svg"),
+ "folder" => import("./folder-solid.svg"),
+ "forward-step" => import("./forward-step-solid.svg"),
+ "gauge-high" => import("./gauge-high-solid.svg"),
+ "gauge" => import("./gauge-solid.svg"),
+ "gem" => import("./gem-solid.svg"),
+ "github" => import("./github.svg"),
+ "globe" => import("./globe-solid.svg"),
+ "heart" => import("./heart-solid.svg"),
+ "html5" => import("./html5.svg"),
+ "keyboard" => import("./keyboard-solid.svg"),
+ "language" => import("./language-solid.svg"),
+ "laptop-code" => import("./laptop-code-solid.svg"),
+ "link" => import("./link-solid.svg"),
+ "minus" => import("./minus-solid.svg"),
+ "network-wired" => import("./network-wired-solid.svg"),
+ "pause" => import("./pause-solid.svg"),
+ "person-digging" => import("./person-digging-solid.svg"),
+ "play" => import("./play-solid.svg"),
+ "plus" => import("./plus-solid.svg"),
+ "question" => import("./question-solid.svg"),
+ "rocket" => import("./rocket-solid.svg"),
+ "seedling" => import("./seedling-solid.svg"),
+ "server" => import("./server-solid.svg"),
+ "square-github" => import("./square-github.svg"),
+ "star" => import("./star-solid.svg"),
+ "up-right-from-square" => import("./up-right-from-square-solid.svg"),
+ "wand-magic-sparkles" => import("./wand-magic-sparkles-solid.svg"),
+ "xmark" => import("./xmark-solid.svg"),
}.freeze
:ruby
diff --git a/example/app/components/UI/Image.haml b/example/app/components/UI/Image.haml
index eb059040..50f69f94 100644
--- a/example/app/components/UI/Image.haml
+++ b/example/app/components/UI/Image.haml
@@ -1,33 +1,32 @@
:ruby
- def inline_style
- return {} if $blur == false
-
- {
- background_image: "url('#{$image.blur}')",
- background_size: "cover",
- }
- end
-
-:css
- img {
- width: 100%;
- height: auto;
- }
:ruby
- image = props.fetch(:image)
-
loading = $lazy && "lazy"
- decoding = props.fetch(:decoding, "async")
+ decoding = $decoding || "async"
- alt = props.fetch(:alt) do
+ unless $alt
Console.logger.warn(self, "Missing alt-attribute!")
end
-%img(class=$class style=inline_style loading=loading decoding=decoding alt=alt){
- src: image.src,
- sizes: image.sizes,
- srcset: image.srcset,
- width: image.original.width,
- height: image.original.height,
+ inline_style = $blur && {
+ background_image: $image.blur_url,
+ }
+
+%img(class=$class style=inline_style loading=loading decoding=decoding alt=$alt){
+ src: $image.src,
+ sizes: $image.sizes,
+ srcset: $image.srcset,
+ width: $image.width,
+ height: $image.height,
}
+
+:css
+ img {
+ max-width: 100%;
+ height: auto;
+ vertical-align: middle;
+ font-style: italic;
+ background-size: cover;
+ background-repeat: no-repeat;
+ shape-margin: 1rem;
+ }
diff --git a/example/app/components/UI/Link.haml b/example/app/components/UI/Link.haml
index 38f194e0..649131bb 100644
--- a/example/app/components/UI/Link.haml
+++ b/example/app/components/UI/Link.haml
@@ -1,3 +1,5 @@
+%a{style: $color && { __link_color: $color }, **$*.except(:color)}
+ %slot
:css
a:any-link {
color: var(--link-color);
@@ -8,7 +10,3 @@
a:hover {
text-decoration: underline;
}
-:ruby
- style = $color && { __link_color: $color }
-%a(style=style){**props.except(:color)}
- %slot
diff --git a/example/app/components/UI/Spinner/Spinner.haml b/example/app/components/UI/Spinner/Spinner.haml
index f6596066..3635c455 100644
--- a/example/app/components/UI/Spinner/Spinner.haml
+++ b/example/app/components/UI/Spinner/Spinner.haml
@@ -1,5 +1,5 @@
:ruby
- Spinner = svg("90-ring-with-bg.svg")
+ Spinner = import("./90-ring-with-bg.svg")
:css
.spinner {
aspect-ratio: 1;
diff --git a/example/app/components/UI/Tabs.haml b/example/app/components/UI/Tabs.haml
index d7b21081..c21d8296 100644
--- a/example/app/components/UI/Tabs.haml
+++ b/example/app/components/UI/Tabs.haml
@@ -1,48 +1,41 @@
:ruby
- def self.get_initial_state(children:, **)
- active = children.slots.keys.first
- { active:, enabled: [active] }
+ def initialize
+ @active = __children.slots.keys.first
+ @enabled = [@active]
+ @id = SecureRandom.alphanumeric
end
def handle_change(e)
e => { target: { value: }}
- update do |state|
- {
- active: value,
- enabled: [*state[:enabled], value].uniq
- }
- end
+ @active = value
+ @enabled = [*@enabled, value].uniq
end
def handle_enable(e)
e => { target: { value: }}
-
- update do |state|
- { enabled: [*state[:enabled], value].uniq }
- end
+ @enabled = [*@enabled, value].uniq
end
private
def id(*args)
- [:id, *args, vnode_id].flatten.join("-")
+ [:id, *args, @id].flatten.join("-")
end
:ruby
- state => active:, enabled:
- names = children.slots.keys
+ names = __children.slots.keys
.tabs
.tablist(role="tablist")
= names.map.with_index do |name, i|
- - handle_enable = enabled.include?(name) ? nil : handler(:handle_enable)
+ - handle_enable = @enabled.include?(name) ? nil : H.callback(self, :handle_enable)
%button[name](value=name onclick=handle_change role="tab" tabindex="0"){
id: id(:tab, i),
onmouseenter: handle_enable,
ontouchstart: handle_enable,
aria: {
- selected: (name == active).to_s,
+ selected: (name == @active).to_s,
controls: id(:panel, i)
}
}= name
@@ -50,13 +43,13 @@
= names.map.with_index do |name, i|
.panel(role="tabpanel" tabindex="0"){
id: id(:panel, i),
- hidden: name != active,
+ hidden: name != @active,
aria: {
labelledby: id(:tab, i),
- expanded: (name == active).to_s,
+ expanded: (name == @active).to_s,
}
}
- = if enabled.include?(name)
+ = if @enabled.include?(name)
%slot(name=name)
:css
.tabs {
diff --git a/example/app/components/UI/YouTubeVideo.haml b/example/app/components/UI/YouTubeVideo.haml
index 6a045a2c..fb459e5a 100644
--- a/example/app/components/UI/YouTubeVideo.haml
+++ b/example/app/components/UI/YouTubeVideo.haml
@@ -11,13 +11,11 @@
width: 100%;
height: 100%;
}
-:ruby
- props => video_id:
.wrapper
%iframe(allowfullscreen){
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
frameborder: 0,
loading: "lazy",
title: $title || "YouTube video player",
- src: "https://www.youtube.com/embed/#{video_id}",
+ src: "https://www.youtube.com/embed/#$video_id",
}
diff --git a/example/app/components/UnderConstruction.haml b/example/app/components/UnderConstruction.haml
index f8af9bd7..34371a79 100644
--- a/example/app/components/UnderConstruction.haml
+++ b/example/app/components/UnderConstruction.haml
@@ -1,17 +1,17 @@
:ruby
- Icon = import("/app/components/UI/Icon")
- Link = import("/app/components/UI/Link")
- Card = import("/app/components/UI/Card")
+ Icon = import("/components/UI/Icon")
+ Link = import("/components/UI/Link")
+ Card = import("/components/UI/Card")
%Card(border="var(--yellow)" background="var(--yellow-bright)")
.flex
%p
%Icon(name="person-digging" color="var(--dark)")
%span This page is under construction!
- = if props in path:
+ = if $path
%p
%Icon(name="github" color="var(--dark)")
- %Link(href="https://github.com/mayu-live/framework/blob/main/example/app/pages#{path}/page.haml" target="_blank")
+ %Link(href="https://github.com/mayu-live/framework/blob/main/example/app/pages#{$path}/page.haml" target="_blank")
Edit on GitHub
:css
Card {
diff --git a/example/app/lib/GithubModelsChat.rb b/example/app/lib/GithubModelsChat.rb
new file mode 100644
index 00000000..e81b2079
--- /dev/null
+++ b/example/app/lib/GithubModelsChat.rb
@@ -0,0 +1,143 @@
+DEFAULT_URL = "https://models.inference.ai.azure.com/chat/completions"
+DEFAULT_MODEL = "Ministral-3B"
+
+class UnauthorizedError < StandardError
+end
+
+class StreamingResponseParser < ::Protocol::HTTP::Body::Wrapper
+ # Inspired by https://github.com/socketry/async-ollama/blob/main/lib/async/ollama/wrapper.rb
+ def initialize(...)
+ super
+
+ @buffer = String.new.b
+ @offset = 0
+
+ @response = String.new
+ end
+
+ def read
+ return if @buffer.nil?
+
+ while true
+ if index = @buffer.index("\n\n", @offset)
+ line = @buffer.byteslice(@offset, index - @offset)
+ @buffer = @buffer.byteslice(index + 2, @buffer.bytesize - index - 1)
+ @offset = 0
+
+ return parse_line(line)
+ end
+
+ if chunk = super
+ @buffer << chunk
+ else
+ return nil if @buffer.empty?
+
+ line = @buffer
+ @buffer = nil
+ @offset = 0
+
+ return parse_line(line)
+ end
+ end
+ end
+
+ def each
+ super do |line|
+ case line
+ in error: { code: "unauthorized", message: }
+ raise UnauthorizedError, message
+ in choices: [{ delta: { content: } }, *]
+ @response << content
+ yield content if block_given?
+ end
+ end
+
+ @response
+ end
+
+ def join
+ self.each {}
+ @response
+ end
+
+ def parse_line(line)
+ case line.delete_prefix("data: ")
+ in "[DONE]"
+ nil
+ in json
+ ::JSON.parse(json, symbolize_names: true)
+ end
+ end
+end
+
+Message = Data.define(:role, :content)
+
+def initialize(
+ url: DEFAULT_URL,
+ model: DEFAULT_MODEL,
+ temperature: 0.8,
+ max_tokens: 2048,
+ top_p: 0.1,
+ system_message: nil
+)
+ @url = url
+
+ @model = model
+ @temperature = temperature
+ @max_tokens = max_tokens
+ @top_p = top_p
+
+ @messages = []
+
+ @messages.push(Message[:system, system_message]) if system_message
+
+ @endpoint = Async::HTTP::Endpoint.parse(url)
+ @client = Async::HTTP::Client.new(@endpoint)
+end
+
+def marshal_dump
+ [@url, @model, @messages, @temperature, @max_tokens, @top_p]
+end
+
+def marshal_load(state)
+ @url, @model, @messages, @temperature, @max_tokens, @top_p = @state
+ @endpoint = Async::HTTP::Endpoint.parse(@url)
+ @client = Async::HTTP::Client.new(@endpoint)
+end
+
+def complete(message, &)
+ @messages.push(Message["user", message])
+
+ res =
+ @client.post(
+ @endpoint.url.path,
+ {
+ "content-type": "application/json",
+ authorization: "Bearer #{github_token}"
+ },
+ JSON.generate(
+ {
+ messages: @messages.map(&:to_h),
+ model: @model,
+ temperature: @temperature,
+ max_tokens: @max_tokens,
+ top_p: @top_p,
+ stream: true
+ }
+ )
+ )
+
+ response = StreamingResponseParser.wrap(res).each(&)
+
+ @messages.push(Message["assistant", response])
+
+ response
+end
+
+private
+
+def github_token
+ ENV.fetch("GITHUB_TOKEN") do
+ raise UnauthorizedError, "GITHUB_TOKEN is not set"
+ end
+end
diff --git a/example/app/lib/ollama.rb b/example/app/lib/ollama.rb
deleted file mode 100644
index 78131e1f..00000000
--- a/example/app/lib/ollama.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-class Ollama
- DEFAULT_URL = "http://localhost:11434"
- DEFAULT_MODEL = "llama2"
-
- GenerateResponse =
- Data.define(
- :total_duration, # time spent generating the response
- :load_duration, # time spent in nanoseconds loading the model
- :sample_count, # number of samples generated
- :sample_duration, # time spent generating samples
- :prompt_eval_count, # number of tokens in the prompt
- :prompt_eval_duration, # time spent in nanoseconds evaluating the prompt
- :eval_count, # number of tokens the response
- :eval_duration, # time in nanoseconds spent generating the response
- :model,
- :created_at,
- :context # an encoding of the conversation used in this response, this can be sent in the next request to keep a conversational memory
- )
-
- def initialize(model: DEFAULT_MODEL, url: DEFAULT_URL)
- @endpoint =
- Async::HTTP::Endpoint.parse(url, protocol: Async::HTTP::Protocol::HTTP2)
- @client = Async::HTTP::Client.new(@endpoint)
- @model = model
- @context = nil
- end
-
- def generate(prompt, system: nil, template: nil, options: {})
- res =
- @client.post(
- "/api/generate",
- nil,
- JSON.generate(
- {
- model: @model,
- prompt:,
- context: @context,
- template:,
- options:,
- system:
- }
- )
- )
-
- chunks = []
-
- res.each do |chunk|
- parsed = JSON.parse(chunk, symbolize_names: true)
-
- case parsed
- in error:
- raise error.to_s
- in { done: true, context: }
- @context = context
- in response:
- chunks << response
- yield response
- end
- end
-
- chunks.join.strip
- end
-end
diff --git a/example/app/pages/404.haml b/example/app/pages/404.haml
index bd0b71de..e2ef656b 100644
--- a/example/app/pages/404.haml
+++ b/example/app/pages/404.haml
@@ -1,7 +1,7 @@
:ruby
- MaxWidth = import("/app/components/Layout/MaxWidth")
- Heading = import("/app/components/Layout/Heading")
- Link = import("/app/components/UI/Link")
+ MaxWidth = import("/components/Layout/MaxWidth")
+ Heading = import("/components/Layout/Heading")
+ Link = import("/components/UI/Link")
.center
%Heading(level=2)
diff --git a/example/app/pages/ClockSection.haml b/example/app/pages/ClockSection.haml
index 51867096..899bd9cb 100644
--- a/example/app/pages/ClockSection.haml
+++ b/example/app/pages/ClockSection.haml
@@ -1,7 +1,7 @@
:ruby
- Clock = import("/app/components/Clock")
- Link = import("/app/components/UI/Link")
- Heading = import("/app/components/Layout/Heading")
+ Clock = import("/components/Clock")
+ Link = import("/components/UI/Link")
+ Heading = import("/components/Layout/Heading")
Section = import("./Section")
%Section
diff --git a/example/app/pages/Counter.haml b/example/app/pages/Counter.haml
index b1a44891..37857a71 100644
--- a/example/app/pages/Counter.haml
+++ b/example/app/pages/Counter.haml
@@ -1,30 +1,23 @@
:ruby
- Card = import("/app/components/UI/Card")
+ Card = import("/components/UI/Card")
- def self.get_initial_state(initial_count: 0, **) = {
- count: initial_count
- }
+ def initialize
+ @count = $initial_count
+ end
- def decrement_disabled = state[:count].zero?
+ def decrement_disabled =
+ @count.zero?
- def handle_decrement
- update do |state|
- count = [0, state[:count] - 1].max
- { count: }
- end
- end
+ def handle_decrement =
+ @count = (@count - 1).clamp(0, Float::INFINITY)
- def handle_increment
- update do |state|
- count = state[:count] + 1
- { count: }
- end
- end
+ def handle_increment =
+ @count += 1
%Card
%article
%button(title="Decrement" onclick=handle_decrement disabled=decrement_disabled) -
- %output= state[:count]
+ %output= @count
%button(title="Increment" onclick=handle_increment) +
:css
diff --git a/example/app/pages/Feature.haml b/example/app/pages/Feature.haml
index 05605e5f..ff7b02ea 100644
--- a/example/app/pages/Feature.haml
+++ b/example/app/pages/Feature.haml
@@ -9,23 +9,20 @@
"… and much more!",
]
- def self.get_initial_state(**props) = {
- index: 0
- }
+ def initialize
+ @index = 0
+ end
def mount
loop do
sleep 2.2
-
- update do |state|
- { index: state[:index].succ % ITEMS.size }
- end
+ @index = @index.succ % ITEMS.size
end
end
%ul
= ITEMS.map.with_index do |item, index|
- %li{class: { active: index == state[:index] }}= item
+ %li{class: { active: index == @index }}= item
:css
ul {}
diff --git a/example/app/pages/FeatureSection.haml b/example/app/pages/FeatureSection.haml
index d8c06760..c8f21fb3 100644
--- a/example/app/pages/FeatureSection.haml
+++ b/example/app/pages/FeatureSection.haml
@@ -1,7 +1,7 @@
:ruby
- Icon = import("/app/components/UI/Icon")
- Link = import("/app/components/UI/Link")
- Heading = import("/app/components/Layout/Heading")
+ Icon = import("/components/UI/Icon")
+ Link = import("/components/UI/Link")
+ Heading = import("/components/Layout/Heading")
Section = import("./Section")
Feature = import("./Feature")
%Section
diff --git a/example/app/pages/Highlight.haml b/example/app/pages/Highlight.haml
index 621e0c83..9d82e18f 100644
--- a/example/app/pages/Highlight.haml
+++ b/example/app/pages/Highlight.haml
@@ -1,5 +1,12 @@
:ruby
- Icon = import("/app/components/UI/Icon")
+ Icon = import("/components/UI/Icon")
+
+%article
+ %Icon(name=$icon color=$icon_color)
+ .content
+ %h3= $title
+ %slot
+
:css
article {
display: grid;
@@ -21,10 +28,3 @@
.content {
font-size: .9em;
}
-:ruby
- props => icon:, title:
-%article
- %Icon(name=icon color=$icon_color)
- .content
- %h3= title
- %slot
diff --git a/example/app/pages/HighlightsSection.haml b/example/app/pages/HighlightsSection.haml
index ed8b91fb..d0624dd6 100644
--- a/example/app/pages/HighlightsSection.haml
+++ b/example/app/pages/HighlightsSection.haml
@@ -1,5 +1,5 @@
:ruby
- Link = import("/app/components/UI/Link")
+ Link = import("/components/UI/Link")
Section = import("./Section")
Highlight = import("./Highlight")
@@ -20,7 +20,7 @@
%li
DOM-patches are streamed to the browser.
%Highlight(icon="wand-magic-sparkles" icon-color="linear-gradient(var(--material-color-purple-300),var(--material-color-purple-900))" title="How does it work?")
- %ul.highlight-list
+ %ul
%li
Mayu implements a
%Link(href="https://en.wikipedia.org/wiki/Virtual_DOM" target="_blank")<>
@@ -32,7 +32,7 @@
%li
Event handlers run on the server.
%Highlight(icon="rocket" icon-color="linear-gradient(var(--material-color-blue-gray-200),var(--material-color-blue-gray-900))" title="Efficient")
- %ul.highlight-list
+ %ul
%li
HTTP/2 Streams with
%Link(href="https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API" target="_blank")<>
@@ -51,7 +51,7 @@
%Link(href="https://prometheus.io/" target="_blank") Prometheus
\-endpoint for real-time metrics.
%Highlight(icon="code" icon-color="linear-gradient(var(--material-color-teal-200),var(--material-color-green-700))" title="Developer friendly")
- %ul.highlight-list
+ %ul
%li
Hot reloading updates the page as soon as you save.
%li
diff --git a/example/app/pages/Interactivity.haml b/example/app/pages/Interactivity.haml
index 3c7744f4..62b2bf7c 100644
--- a/example/app/pages/Interactivity.haml
+++ b/example/app/pages/Interactivity.haml
@@ -1,26 +1,25 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Link = import("/app/components/UI/Link")
- Details = import("/app/components/UI/Details")
- Highlight = import("/app/components/UI/Highlight")
+ Heading = import("/components/Layout/Heading")
+ Link = import("/components/UI/Link")
+ Details = import("/components/UI/Details")
+ Highlight = import("/components/UI/Highlight")
Counter = import("./Counter")
Section = import("./Section")
%Section
- .article.example
- %Heading#interactivity(level=2)
- Interactivity
- %p This text was rendered on the server at #{Time.now.utc.to_s}.
- %p
- The counter below was also rendered on the server.
- The only JavaScript required to make it interactive is a small
- runtime that makes a connection to the server and updates
- the DOM whenever the state changes on the server.
+ %Heading#interactivity(level=2)
+ Interactivity
+ %p This text was rendered on the server at #{Time.now.utc.to_s}.
+ %p
+ The counter below was also rendered on the server.
+ The only JavaScript required to make it interactive is a small
+ runtime that makes a connection to the server and updates
+ the DOM whenever the state changes on the server.
- %Counter(initial_count=7)
+ %Counter(initial_count=7)
- %Details(summary="Show source" lazy)
- %Highlight(language="haml")= File.read('app/pages/Counter.haml')
+ %Details(summary="Show source" lazy)
+ %Highlight(language="haml")= File.read('app/pages/Counter.haml')
:css
Section {
diff --git a/example/app/pages/Intro.css b/example/app/pages/Intro.css
index da0347ca..4ed151af 100644
--- a/example/app/pages/Intro.css
+++ b/example/app/pages/Intro.css
@@ -19,6 +19,13 @@ section {
transparent
),
var(--blue-background);
+
+ background: radial-gradient(
+ ellipse at bottom in oklch,
+ color-mix(in oklab, var(--pink) 60%, var(--blue-background)),
+ transparent
+ ),
+ var(--blue-background);
}
.background {
@@ -27,6 +34,7 @@ section {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at bottom, #0002, transparent);
+ mask-image: url(./hexagons.svg);
mask-position: 50% 50%;
mask-size: 2rem auto;
}
diff --git a/example/app/pages/Intro.haml b/example/app/pages/Intro.haml
index a8fed299..9b9b5628 100644
--- a/example/app/pages/Intro.haml
+++ b/example/app/pages/Intro.haml
@@ -1,38 +1,35 @@
:ruby
- MaxWidth = import("/app/components/Layout/MaxWidth")
- Text = svg("./mayu.svg")
- Background = svg("./hexagons.svg")
-
- def self.get_initial_state(**props) = {
- angle: rand,
- word: nil
- }
+ MaxWidth = import("/components/Layout/MaxWidth")
+ Text = import("./mayu.svg")
WORDS = %w[Mayu is a live updating server-side rendering web framework written in Ruby]
+ def initialize
+ @angle = rand
+ @word = nil
+ end
+
def mount
loop do
sleep 3
- update do |state|
- { angle: state[:angle] + (rand - 0.5) * 0.5 }
- end
+ @angle = (rand - 0.5) * 0.5
WORDS.length.times do |i|
- update(word: i)
+ @word = i
sleep 0.15
end
- update(word: nil)
+ @word = nil
end
end
-%section{style: { __angle: "#{state[:angle]}turn" }, data: { intro: true }}
- .background{style: { mask_image: "url(#{Background})" }}
+%section{style: { __angle: "#{@angle}turn" }, data: { intro: true }}
+ .background
%MaxWidth
%img(src=Text)
%h1
Reactive web pages in Ruby
%h2
= WORDS.map.with_index do |word, i|
- %span.word[i]{class: { active: i == state[:word] }}= word
+ %span.word[i]{class: { active: i == @word }}= word
diff --git a/example/app/pages/Section.haml b/example/app/pages/Section.haml
index fe4e156e..b8463c37 100644
--- a/example/app/pages/Section.haml
+++ b/example/app/pages/Section.haml
@@ -1,5 +1,5 @@
:ruby
- MaxWidth = import("/app/components/Layout/MaxWidth")
+ MaxWidth = import("/components/Layout/MaxWidth")
:css
section {
box-shadow: rgba(50, 50, 93, 0.25) 0px 30px 60px -12px inset,
diff --git a/example/app/pages/demos/ButtonGame.haml b/example/app/pages/demos/ButtonGame.haml
index 8d1c0190..4d2bf815 100644
--- a/example/app/pages/demos/ButtonGame.haml
+++ b/example/app/pages/demos/ButtonGame.haml
@@ -1,5 +1,5 @@
:ruby
- Button = import("/app/components/Form/Button")
+ Button = import("/components/Form/Button")
MESSAGES = [
"Click me!",
@@ -9,17 +9,18 @@
"LOL",
]
- def self.get_initial_state(**) =
- { x: 0, y: 0, count: 0 }
+ def initialize
+ @x = 0
+ @y = 0
+ @count = 0
+ end
def handle_click
radius = rand * 200
- update(
- count: state[:count].succ,
- x: Math.cos(Math::PI * 2 * rand) * radius,
- y: Math.sin(Math::PI * 2 * rand) * radius,
- )
+ @count += 1
+ @x = Math.cos(Math::PI * 2 * rand) * radius
+ @y = Math.sin(Math::PI * 2 * rand) * radius
end
:css
@@ -27,10 +28,10 @@
transition: transform 0.2s;
}
-:ruby
- state => { count:, x:, y: }
- transform = "translate(#{x}px, #{y}px)"
- style = { transform:, transition: "all .2s" }
-
-%Button(style=style onclick=handle_click)
- = MESSAGES[state[:count] % MESSAGES.size]
+%Button(onclick=handle_click){
+ style: {
+ transform: "translate(#{@x}px, #{@y}px)",
+ transition: "all 200ms"
+ }
+}
+ = MESSAGES[@count % MESSAGES.size]
diff --git a/example/app/pages/demos/context/Consumer.haml b/example/app/pages/demos/context/Consumer.haml
new file mode 100644
index 00000000..01c00b23
--- /dev/null
+++ b/example/app/pages/demos/context/Consumer.haml
@@ -0,0 +1,12 @@
+%pre{ class: @@theme.to_sym }= @@theme.inspect
+
+:css
+ .dark {
+ background: #000;
+ color: #fff;
+ }
+
+ .light {
+ background: #fff;
+ color: #000;
+ }
diff --git a/example/app/pages/demos/context/Hello.haml b/example/app/pages/demos/context/Hello.haml
new file mode 100644
index 00000000..39d7603b
--- /dev/null
+++ b/example/app/pages/demos/context/Hello.haml
@@ -0,0 +1,6 @@
+:ruby
+ Consumer = import("./Consumer")
+
+%div
+ %p This is Hello.haml which renders the consumer:
+ %Consumer
diff --git a/example/app/pages/demos/context/page.haml b/example/app/pages/demos/context/page.haml
new file mode 100644
index 00000000..6a436831
--- /dev/null
+++ b/example/app/pages/demos/context/page.haml
@@ -0,0 +1,43 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ Consumer = import("./Consumer")
+ Hello = import("./Hello")
+
+ THEMES = %w[light dark]
+
+ def initialize
+ @theme = THEMES.first
+ end
+
+ def handle_set_theme(e)
+ @theme = e.dig(:currentTarget, :value)
+ end
+
+%article
+ %Heading(level=2) Context
+
+ %p This demo shows how to use context in a component.
+
+ %p This is what the tree looks like:
+
+ %ul
+ %li
+ page.haml
+ %ul
+ %li Consumer.haml
+ %li
+ Hello.haml
+ %ul
+ %li Consumer.haml
+
+ %div
+ Theme:
+ = THEMES.map do |theme|
+ %label[theme]
+ %input(type="radio" name="theme" value=theme onchange=handle_set_theme){checked: @theme == theme}
+ = theme.to_s.capitalize
+
+ = H.context(theme: @theme) do
+ %Consumer
+ %Hello
diff --git a/example/app/pages/demos/custom-elements/CustomElement.js b/example/app/pages/demos/custom-elements/CustomElement.js
new file mode 100644
index 00000000..725b93cb
--- /dev/null
+++ b/example/app/pages/demos/custom-elements/CustomElement.js
@@ -0,0 +1,49 @@
+const template = document.createElement("template");
+
+template.innerHTML = `
+
+
+
Hello world from a custom element with a custom background color.
+
Current color:
+
+`;
+
+export default class CustomElement extends HTMLElement {
+ static observedAttributes = ["color"];
+
+ constructor() {
+ super();
+
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: "open" });
+ }
+
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ this.shadowRoot.querySelector("#color").textContent =
+ this.getAttribute("color");
+ }
+
+ connectedCallback() {
+ console.log(this.attributes);
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ console.log(`Attribute ${name} has changed.`, oldValue, newValue);
+
+ if (name === "color") {
+ this.shadowRoot.querySelector("#color").textContent = newValue;
+ this.shadowRoot
+ .querySelector("#root")
+ .style.setProperty("background-color", newValue);
+ }
+ }
+}
diff --git a/example/app/pages/demos/custom-elements/page.haml b/example/app/pages/demos/custom-elements/page.haml
new file mode 100644
index 00000000..9696a4e5
--- /dev/null
+++ b/example/app/pages/demos/custom-elements/page.haml
@@ -0,0 +1,43 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ CustomElement = import("./CustomElement.js")
+
+ COLORS = %w[red blue green purple teal]
+
+ def initialize
+ @color = COLORS.first
+ end
+
+ def set_color(e)
+ @color = e.dig(:currentTarget, :value).to_s
+ end
+
+%article
+ %Heading(level=2)
+ Custom elements
+
+ %p
+ This is a basic example demonstrating how to use
+ %a(href="https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements")< custom elements
+ \. Some details need to be figured out before this can be considered usable.
+ See
+ %a(href="https://github.com/mayu-live/framework/issues/5")<> this GitHub issue
+ for more details.
+
+ %CustomElement(color=@color)
+
+ .buttons
+ = COLORS.map do |color|
+ %button[color](type="button" onclick=set_color value=color){
+ class: { active: @color == color }
+ }= color
+
+:css
+ .buttons {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, 5em);
+ }
+
+ .active {
+ font-weight: bold;
+ }
diff --git a/example/app/pages/demos/events/page.haml b/example/app/pages/demos/events/page.haml
index 54b17500..2c570bcd 100644
--- a/example/app/pages/demos/events/page.haml
+++ b/example/app/pages/demos/events/page.haml
@@ -1,20 +1,18 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Highlight = import("/app/components/UI/Highlight")
- Card = import("/app/components/UI/Card")
- Details = import("/app/components/UI/Details")
- Button = import("/app/components/Form/Button")
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Details = import("/components/UI/Details")
+ Button = import("/components/Form/Button")
- def self.get_initial_state(**) = {
- count: 0
- }
+ def initialize
+ @count = 0
+ end
def handle_alert
- helpers.alert("Hello world\nCount: #{state[:count]}")
+ helpers.alert("Hello world\nCount: #{@count}")
- update do |state|
- { count: state[:count] + 1 }
- end
+ @count += 1
end
%article
diff --git a/example/app/pages/demos/exceptions/page.haml b/example/app/pages/demos/exceptions/page.haml
index a3ef7801..4d312b54 100644
--- a/example/app/pages/demos/exceptions/page.haml
+++ b/example/app/pages/demos/exceptions/page.haml
@@ -1,22 +1,25 @@
:ruby
- Button = import("/app/components/Form/Button")
- Heading = import("/app/components/Layout/Heading")
- Highlight = import("/app/components/UI/Highlight")
- Card = import("/app/components/UI/Card")
- Details = import("/app/components/UI/Details")
- Link = import("/app/components/UI/Link")
+ Button = import("/components/Form/Button")
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Details = import("/components/UI/Details")
+ Link = import("/components/UI/Link")
- def self.get_initial_state(**) = {
- count: 0
- }
+ def initialize =
+ @count = 0
def handle_click(e) =
case e
in { target: { name: "increment" } }
- update do |state|
- { count: state[:count] + 1 }
- end
+ @count += 1
+ end
+
+ def raise_if_over(x)
+ if @count >= x
+ raise "#{@count} => #{x}"
end
+ end
%article
%Heading(level=2) Exceptions
@@ -26,13 +29,14 @@
Issue for implementing error boundaries
%p
Count:
- %output<= state[:count]
+ %output<= @count
.buttons
%Button(onclick=handle_click name="increment") Increment
%Button(onclick=handle_click name="error") Error
%Details(summary="Show source")
%Highlight(language="haml")
= File.read("app/pages/demos/exceptions/page.haml")
+ - raise_if_over(5)
:css
.buttons {
display: flex;
diff --git a/example/app/pages/demos/fonts/RobotoCondensed-VariableFont_wght.ttf b/example/app/pages/demos/fonts/RobotoCondensed-VariableFont_wght.ttf
new file mode 100644
index 00000000..a5f645b6
Binary files /dev/null and b/example/app/pages/demos/fonts/RobotoCondensed-VariableFont_wght.ttf differ
diff --git a/example/app/pages/demos/fonts/page.haml b/example/app/pages/demos/fonts/page.haml
new file mode 100644
index 00000000..509f61ef
--- /dev/null
+++ b/example/app/pages/demos/fonts/page.haml
@@ -0,0 +1,73 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+
+ def initialize
+ @font_size = 16
+ @font_weight = 300
+ end
+
+ def handle_font_size(e)
+ @font_size = e.dig(:target, :value).to_i
+ end
+
+ def handle_font_weight(e)
+ @font_weight = e.dig(:target, :value).to_i
+ end
+
+%article
+ %Heading(level=2) Fonts
+
+ %p This page loads the Roboto Condensed font from a local file.
+
+ .display
+ %p.roboto-condensed{
+ style: { font_weight: @font_weight, font_size: @font_size }
+ }
+ Roboto Condensed
+
+ .fields
+ %label Font size
+ %input(min=8 max=300 value=@font_size oninput=handle_font_size){
+ type: "range"
+ }/
+ %output= @font_size
+
+ %label Font weight
+ %input(min=100 max=900 value=@font_weight oninput=handle_font_weight){
+ type: "range"
+ }/
+
+ %output= @font_weight
+
+:css
+ @font-face {
+ font-family: "Roboto Condensed";
+ font-style: normal;
+ font-weight: 100 900;
+ src: url("./RobotoCondensed-VariableFont_wght.ttf");
+ }
+
+ .roboto-condensed {
+ font-family: "Roboto Condensed";
+ margin: 0;
+ line-height: 1.2em
+ }
+
+ .display {
+ display: grid;
+ align-content: center;
+ justify-content: center;
+ text-align: center;
+ aspect-ratio: 2 / 1;
+ border: 1px solid #000;
+ background: #333;
+ color: #ccc;
+ contain: strict;
+ }
+
+ .fields {
+ display: grid;
+ grid-template-columns: auto 1fr 3rem;
+ gap: 1rem;
+ margin: 1rem 0;
+ }
diff --git a/example/app/pages/demos/form/Elements.haml b/example/app/pages/demos/form/Elements.haml
index 0e85142d..a9cf7147 100644
--- a/example/app/pages/demos/form/Elements.haml
+++ b/example/app/pages/demos/form/Elements.haml
@@ -1,9 +1,9 @@
:ruby
- Fieldset = import("/app/components/Form/Fieldset")
- Button = import("/app/components/Form/Button")
- Input = import("/app/components/Form/Input")
- Select = import("/app/components/Form/Select")
- Checkbox = import("/app/components/Form/Checkbox")
+ Fieldset = import("/components/Form/Fieldset")
+ Button = import("/components/Form/Button")
+ Input = import("/components/Form/Input")
+ Select = import("/components/Form/Select")
+ Checkbox = import("/components/Form/Checkbox")
%Fieldset
%legend Hello world
diff --git a/example/app/pages/demos/form/LogInForm.haml b/example/app/pages/demos/form/LogInForm.haml
index a779c6cb..b90befbc 100644
--- a/example/app/pages/demos/form/LogInForm.haml
+++ b/example/app/pages/demos/form/LogInForm.haml
@@ -1,31 +1,31 @@
:ruby
- Fieldset = import("/app/components/Form/Fieldset")
- Details = import("/app/components/UI/Details")
- YouTubeVideo = import("/app/components/UI/YouTubeVideo")
- Link = import("/app/components/UI/Link")
+ Fieldset = import("/components/Form/Fieldset")
+ Details = import("/components/UI/Details")
+ YouTubeVideo = import("/components/UI/YouTubeVideo")
+ Link = import("/components/UI/Link")
- def self.get_initial_state(**props) = {
- user: nil
- }
+ User = Data.define(:email, :password)
+
+ def initialize
+ @user = nil
+ end
def handle_submit(e)
e => { target: { formData: { email:, password: } } }
- update(user: { email:, password: })
+ @user = User[email, password]
end
def handle_log_out
- update(user: nil)
+ @user = nil
end
-- state => user:
-
-- return if user
+- return if @user
%div
%p
- Logged in as #{user[:email]}.
+ Logged in as #{@user.email}.
%div
- %button{on_click: handler(:handle_log_out)}
+ %button(onclick=handle_log_out)
Log out
%div
@@ -42,7 +42,7 @@
This is a fake form. Don't enter any real credentials.
Any combination of email and password will work.
- %form.form{on_submit: handler(:handle_submit)}
+ %form.form(onsubmit=handle_submit)
.formGroup
%label.label
%span Email
diff --git a/example/app/pages/demos/form/TransferList.haml b/example/app/pages/demos/form/TransferList.haml
index 623b670b..a4e39d78 100644
--- a/example/app/pages/demos/form/TransferList.haml
+++ b/example/app/pages/demos/form/TransferList.haml
@@ -1,16 +1,16 @@
:ruby
- Fieldset = import("/app/components/Form/Fieldset")
- Button = import("/app/components/Form/Button")
- Input = import("/app/components/Form/Input")
- Select = import("/app/components/Form/Select")
- Checkbox = import("/app/components/Form/Checkbox")
+ Fieldset = import("/components/Form/Fieldset")
+ Button = import("/components/Form/Button")
+ Input = import("/components/Form/Input")
+ Select = import("/components/Form/Select")
+ Checkbox = import("/components/Form/Checkbox")
ITEMS = %w[ dream friendly chief potato irritate creature pastoral selective lyrical fire seal righteous ]
- def self.get_initial_state(**) = {
- left: ITEMS.size.times.to_a,
- right: [],
- }
+ def initialize
+ @left = ITEMS.size.times.to_a
+ @right = []
+ end
def get_selected(form_data, prefix)
form_data
@@ -20,42 +20,38 @@
end
def handle_submit(e)
- update do |state|
- case e
- in { submitter: { name: "action", value: "move_all_left" } }
- { left: state[:left] + state[:right], right: [] }
- in { submitter: { name: "action", value: "move_all_right" } }
- { left: [], right: state[:right] + state[:left] }
- in { submitter: { name: "action", value: "move_selected_left" }, target: { formData: } }
- selected = get_selected(formData, "right-")
+ case e
+ in { submitter: { name: "action", value: "move_all_left" } }
+ @left += @right
+ @right = []
+ in { submitter: { name: "action", value: "move_all_right" } }
+ @right += @left
+ @left = []
+ in { submitter: { name: "action", value: "move_selected_left" }, target: { formData: } }
+ selected = get_selected(formData, "right-")
- {
- left: state[:left].union(selected),
- right: state[:right].difference(selected),
- }
- in { submitter: { name: "action", value: "move_selected_right" }, target: { formData: } }
- selected = get_selected(formData, "left-")
+ @left |= selected
+ @right -= selected
+ in { submitter: { name: "action", value: "move_selected_right" }, target: { formData: } }
+ selected = get_selected(formData, "left-")
- {
- left: state[:left].difference(selected),
- right: state[:right].union(selected),
- }
- else
- {}
- end
+ @right |= selected
+ @left -= selected
+ else
+ {}
end
end
%Fieldset
%legend Transfer list
- %form{on_submit: handler(:handle_submit)}
+ %form(onsubmit=handle_submit)
%ul
- = state[:left].map do |item|
+ = @left.map do |item|
%li[item]
%Checkbox{
label: ITEMS[item],
name: "left-#{item}",
- group_class: styles[:group],
+ group_class: Styles[:group],
}
.buttons
@@ -65,10 +61,10 @@
%button(type="submit" name="action" value="move_selected_right" title="Move selected right") ⟩
%ul
- = state[:right].map do |item|
+ = @right.map do |item|
%li[item]
%Checkbox{
label: ITEMS[item],
name: "right-#{item}",
- group_class: styles[:group],
+ group_class: Styles[:group],
}
diff --git a/example/app/pages/demos/form/page.haml b/example/app/pages/demos/form/page.haml
index 8d093d9b..0204ef17 100644
--- a/example/app/pages/demos/form/page.haml
+++ b/example/app/pages/demos/form/page.haml
@@ -1,7 +1,7 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Hr = import("/app/components/UI/Hr")
- Tabs = import("/app/components/UI/Tabs")
+ Heading = import("/components/Layout/Heading")
+ Hr = import("/components/UI/Hr")
+ Tabs = import("/components/UI/Tabs")
LogInForm = import("./LogInForm")
Elements = import("./Elements")
TransferList = import("./TransferList")
diff --git a/example/app/pages/demos/gc/page.haml b/example/app/pages/demos/gc/page.haml
index 056f890a..1e713e7b 100644
--- a/example/app/pages/demos/gc/page.haml
+++ b/example/app/pages/demos/gc/page.haml
@@ -1,16 +1,16 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Button = import("/app/components/Form/Button")
+ Heading = import("/components/Layout/Heading")
+ Button = import("/components/Form/Button")
Ellipsis = import("./Ellipsis")
- Link = import("/app/components/UI/Link")
+ Link = import("/components/UI/Link")
- def self.get_initial_state(**) = {
- stats: GC.stat,
- objcount: ObjectSpace.count_objects,
- }
+ def initialize
+ @stats = GC.stat
+ @objcount = ObjectSpace.count_objects
+ end
def handle_refresh =
- update(Self.get_initial_state)
+ initialize
# def mount =
# loop do
@@ -27,8 +27,7 @@
%Link(href="https://docs.ruby-lang.org/en/master/GC.html#method-c-stat" target="_blank")
Documentation for these values
.flex
- - state => { stats:, objcount: }
- = stats.each_slice(stats.size.succ / 3).with_index.map do |slice, i|
+ = @stats.each_slice(@stats.size.succ / 3).with_index.map do |slice, i|
%table[slice, i]
%tbody
= slice.map do |key, value|
@@ -38,7 +37,7 @@
%td= value.to_s
%table
%tbody
- = objcount.map do |key, value|
+ = @objcount.map do |key, value|
%tr[key]
%th= key.to_s
%td= value.to_s
diff --git a/example/app/pages/demos/i18n/page.haml b/example/app/pages/demos/i18n/page.haml
index f30b4ae6..4f2e63c4 100644
--- a/example/app/pages/demos/i18n/page.haml
+++ b/example/app/pages/demos/i18n/page.haml
@@ -1,5 +1,5 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
+ Heading = import("/components/Layout/Heading")
def t
"TODO: Implement me"
diff --git a/example/app/pages/demos/images/colombia-map-flag.png b/example/app/pages/demos/images/colombia-map-flag.png
deleted file mode 100644
index 5002c6dd..00000000
Binary files a/example/app/pages/demos/images/colombia-map-flag.png and /dev/null differ
diff --git a/example/app/pages/demos/images/comuna-13.jpeg b/example/app/pages/demos/images/comuna-13.jpeg
deleted file mode 100644
index 00e61b0f..00000000
Binary files a/example/app/pages/demos/images/comuna-13.jpeg and /dev/null differ
diff --git a/example/app/pages/demos/images/mayu-dolphin.jpeg b/example/app/pages/demos/images/mayu-dolphin.jpeg
new file mode 100644
index 00000000..4de2d75b
Binary files /dev/null and b/example/app/pages/demos/images/mayu-dolphin.jpeg differ
diff --git a/example/app/pages/demos/images/mayu-dolphin2.jpeg b/example/app/pages/demos/images/mayu-dolphin2.jpeg
new file mode 100644
index 00000000..3cbbe65b
Binary files /dev/null and b/example/app/pages/demos/images/mayu-dolphin2.jpeg differ
diff --git a/example/app/pages/demos/images/page.haml b/example/app/pages/demos/images/page.haml
index 6afaffac..73eed0fa 100644
--- a/example/app/pages/demos/images/page.haml
+++ b/example/app/pages/demos/images/page.haml
@@ -1,32 +1,37 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Image = import("/app/components/UI/Image")
- Link = import("/app/components/UI/Link")
- Comuna13 = image("./comuna-13.jpeg")
- ColombiaMap = image("./colombia-map-flag.png")
+ Heading = import("/components/Layout/Heading")
+ Image = import("/components/UI/Image")
+ Link = import("/components/UI/Link")
+ MayuDolphin = import("./mayu-dolphin.jpeg")
+ MayuDolphin2 = import("./mayu-dolphin2.jpeg")
+
+%article
+ %Heading(level=2) Images
+ %p
+ This demo shows some images.
+ .grid
+ %figure
+ %Image(blur image=MayuDolphin alt="The Mayu dolphin")
+ %figcaption
+ The Mayu dolphin reimagined in the jungle.
+ = " (#{MayuDolphin.width}x#{MayuDolphin.height})"
+ %figure
+ %Image(blur image=MayuDolphin2 alt="The Mayu dolphin")
+ %figcaption
+ The Mayu dolphin reimagined in a retro landscape.
+ = " (#{MayuDolphin2.width}x#{MayuDolphin2.height})"
:css
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
+ gap: 2rem;
+ }
+
figure {
- margin: 2em 0;
+ margin: 0;
}
.green-bg {
background: var(--green);
}
-
-%article
- %Heading(level=2) Images
- %p
- This demo shows some images.
- %figure
- %Image(image=Comuna13 alt="Comuna 13, Medellín, Colombia")
- %figcaption
- Comuna 13, Medellín, Colombia.
- %Link(href="https://www.google.com/maps?q=loc:6.2487861,-75.62455" target="_blank")<
- Google Maps
- %figure
- %Image.green-bg(false image=ColombiaMap alt="Colombia Flag Map from openclipart.org" blur=false)
- %figcaption
- Colombia Flag Map, found on
- %Link(href="https://openclipart.org/detail/223690/colombia-map-flag" target="_blank")<
- openclipart.com
diff --git a/example/app/pages/demos/json/data.json b/example/app/pages/demos/json/data.json
new file mode 100644
index 00000000..0e385e3f
--- /dev/null
+++ b/example/app/pages/demos/json/data.json
@@ -0,0 +1,37 @@
+{
+ "squadName": "Super hero squad",
+ "homeTown": "Metro City",
+ "formed": 2016,
+ "secretBase": "Super tower",
+ "active": true,
+ "members": [
+ {
+ "name": "Molecule Man",
+ "age": 29,
+ "secretIdentity": "Dan Jukes",
+ "powers": ["Radiation resistance", "Turning tiny", "Radiation blast"]
+ },
+ {
+ "name": "Madame Uppercut",
+ "age": 39,
+ "secretIdentity": "Jane Wilson",
+ "powers": [
+ "Million tonne punch",
+ "Damage resistance",
+ "Superhuman reflexes"
+ ]
+ },
+ {
+ "name": "Eternal Flame",
+ "age": 1000000,
+ "secretIdentity": "Unknown",
+ "powers": [
+ "Immortality",
+ "Heat Immunity",
+ "Inferno",
+ "Teleportation",
+ "Interdimensional travel"
+ ]
+ }
+ ]
+}
diff --git a/example/app/pages/demos/json/page.haml b/example/app/pages/demos/json/page.haml
new file mode 100644
index 00000000..44e80e19
--- /dev/null
+++ b/example/app/pages/demos/json/page.haml
@@ -0,0 +1,17 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ ImportedJSON = import("./data.json")
+
+%article
+ %Heading(level=2) Importing JSON
+
+ %p This page imports a JSON file into Ruby.
+
+ %p As Ruby:
+ %Highlight(language="ruby")
+ = ImportedJSON.pretty_inspect
+
+ %p As JSON:
+ %Highlight(language="json")
+ = JSON.pretty_generate(ImportedJSON)
diff --git a/example/app/pages/demos/layout.haml b/example/app/pages/demos/layout.haml
index 7934b1c0..18a672f1 100644
--- a/example/app/pages/demos/layout.haml
+++ b/example/app/pages/demos/layout.haml
@@ -1,13 +1,13 @@
:ruby
- PageLayout = import("/app/components/Layout/FullWidthPageWithMenu")
- Menu = import("/app/components/Layout/Menu")
- MenuItem = import("/app/components/Layout/MenuItem")
- Breadcrumbs = import("/app/components/UI/Breadcrumbs")
+ PageLayout = import("/components/Layout/FullWidthPageWithMenu")
+ Menu = import("/components/Layout/Menu")
+ MenuItem = import("/components/Layout/MenuItem")
+ Breadcrumbs = import("/components/UI/Breadcrumbs")
LINKS = {
"/demos" => "Demos",
"/demos/pokemon" => "Pokémon",
- "/demos/ollama" => "Ollama chat",
+ "/demos/llm-chat" => "LLM chat",
"/demos/tree" => "App tree",
"/demos/form" => "Form elements",
"/demos/images" => "Images",
@@ -15,15 +15,19 @@
"/demos/todo" => "Todo app",
"/demos/exceptions" => "Exceptions",
"/demos/life" => "Game of life",
+ "/demos/snake" => "Snake",
"/demos/events" => "Events",
"/demos/sorting" => "Sorting",
+ "/demos/custom-elements" => "Custom elements",
+ "/demos/fonts" => "Fonts",
+ "/demos/view-transitions" => "View transitions",
+ "/demos/json" => "Importing JSON",
+ "/demos/context" => "Context",
"/demos/gc" => "GC stats",
}
def breadcrumb_links
- props => { request: { path: } }
-
- splat = split_path(path)
+ splat = split_path($path)
LINKS.select {
s = split_path(_1)
diff --git a/example/app/pages/demos/life/Cell.haml b/example/app/pages/demos/life/Cell.haml
index eea9052c..a0435383 100644
--- a/example/app/pages/demos/life/Cell.haml
+++ b/example/app/pages/demos/life/Cell.haml
@@ -12,7 +12,7 @@
end
end
-- props => x:, y:, alive:, ondraw:, **rest
+- $* => x:, y:, alive:, ondraw:, **rest
- value = "#{x} #{y}"
%button{
@@ -26,10 +26,9 @@
:css
@keyframes alive {
- 0% { background-color: hsl(0deg 50% 50%); }
- 33% { background-color: hsl(120deg 50% 50%); }
- 66% { background-color: hsl(240deg 50% 60%); }
- 100% { background-color: hsl(360deg 50% 50%); }
+ 0% { background-color: #0c0; }
+ 1% { background-color: #000; }
+ 100% { background-color: #999; }
}
button {
@@ -43,10 +42,11 @@
.alive {
background-color: #000;
- animation-duration: 2s;
+ animation-duration: 5s;
animation-name: alive;
- animation-iteration-count: infinite;
- animation-timing-function: linear;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ animation-timing-function: ease-out;
}
.alive:hover {
diff --git a/example/app/pages/demos/life/GameGrid.haml b/example/app/pages/demos/life/GameGrid.haml
index 33e73b5f..7ba3857c 100644
--- a/example/app/pages/demos/life/GameGrid.haml
+++ b/example/app/pages/demos/life/GameGrid.haml
@@ -19,11 +19,8 @@
gap: 1px;
}
-:ruby
- props => grid:, ondraw:
-
.grid
= $grid.map.with_index do |row, y|
.row[y]
= row.map.with_index do |alive, x|
- %Cell["#{x}.#{y}"]{ondraw:, alive:, x:, y:}
+ %Cell["#{x}.#{y}"](ondraw=$ondraw){alive:, x:, y:}
diff --git a/example/app/pages/demos/life/page.haml b/example/app/pages/demos/life/page.haml
index 330a7866..ccb4231b 100644
--- a/example/app/pages/demos/life/page.haml
+++ b/example/app/pages/demos/life/page.haml
@@ -1,9 +1,10 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Fieldset = import("/app/components/Form/Fieldset")
- Input = import("/app/components/Form/Input")
- Button = import("/app/components/Form/Button")
- Link = import("/app/components/UI/Link")
+ Heading = import("/components/Layout/Heading")
+ Fieldset = import("/components/Form/Fieldset")
+ Input = import("/components/Form/Input")
+ Button = import("/components/Form/Button")
+ Details = import("/components/UI/Details")
+ Link = import("/components/UI/Link")
GameGrid = import("./GameGrid")
# b = born
@@ -13,39 +14,37 @@
# RULES = { b: 3..3, s: 2..5 }
# RULES = { b: 3..4, s: 3..5 }
- MIN_SIZE = 5
- MAX_SIZE = 20
- DEFAULT_SIZE = MAX_SIZE
+ MIN_SIZE = 8
+ MAX_SIZE = 48
+ DEFAULT_SIZE = 24
MIN_FPS = 1
- MAX_FPS = 10
+ MAX_FPS = 20
DEFAULT_FPS = 4
- def self.init_grid(width, height, &block)
- Array.new(height) do |y|
- Array.new(width) do |x|
- if block_given?
- yield x, y
- else
- false
- end
- end
+ def self.init_grid(width, height)
+ Array.new(width * height) do |i|
+ next false unless block_given?
+ x = i % width
+ y = i / width
+ yield x, y
end
end
- def self.get_initial_state(initial_size: DEFAULT_SIZE, **) = {
- width: initial_size,
- height: initial_size,
- grid: init_grid(initial_size, initial_size),
- running: false,
- fps: DEFAULT_FPS,
- }
+ def initialize
+ size = $initial_size || DEFAULT_SIZE
+ @width = size
+ @height = size
+ @grid = self.class.init_grid(size, size)
+ @running = false
+ @fps = DEFAULT_FPS
+ end
def mount
loop do
- if state[:running]
+ if @running
handle_step
- sleep 1.0 / state[:fps]
+ sleep 1.0 / @fps
else
sleep 0.5
end
@@ -72,103 +71,89 @@
end
def handle_reset
- update do |width:, height:|
- { grid: self.class.init_grid(width, height) }
- end
+ @grid = self.class.init_grid(@width, @height)
end
def handle_randomize(event)
- update do |width:, height:|
- { grid: self.class.init_grid(width, height) { rand(2).zero? } }
- end
+ @grid = self.class.init_grid(@width, @height) { rand(2).zero? }
end
def set_cell_value(x, y, value)
- update do |state|
- {
- grid: state[:grid].dup.tap do |grid|
- grid[y] = grid[y].dup.tap { |row| row[x] = value }
- end
- }
- end
+ idx = y * @width + x
+ @grid = @grid.dup.tap { |grid| grid[idx] = value }
end
def handle_change_fps(e)
e => { target: { value: } }
- update(fps: value.to_i.clamp(MIN_FPS, MAX_FPS))
+ @fps = value.to_i.clamp(MIN_FPS, MAX_FPS)
end
def handle_change_width(e)
e => { target: { value: } }
- width = value.to_i.clamp(MIN_SIZE, MAX_SIZE)
- update do |grid:|
- { grid:
- grid.map { |row| row.slice(0...width).fill(false, (row.size)...width) }
- }
- end
+ @width = value.to_i.clamp(MIN_SIZE, MAX_SIZE)
handle_reset
end
def handle_change_height(e)
e => { target: { value: } }
- height = value.to_i.clamp(MIN_SIZE, MAX_SIZE)
- update do |grid:, width:|
- { grid:
- if grid.size > height
- grid.slice(0..height)
- else
- grid + this.class.init_grid(width, height - grid.size)
- end
- }
- end
+ @height = value.to_i.clamp(MIN_SIZE, MAX_SIZE)
handle_reset
end
def handle_change_size(e)
e => { target: { value: } }
size = value.to_i.clamp(MIN_SIZE, MAX_SIZE)
- update(width: size, height: size)
+ @width = size
+ @height = size
handle_reset
end
def handle_step
- update do |grid:|
- { grid: step_grid(grid) }
- end
+ @grid = step_grid(@grid)
end
def handle_toggle_running
- update do |running:|
- { running: !running }
- end
+ @running = !@running
end
private
def step_grid(grid)
- grid.map.with_index do |row, y|
- row.map.with_index do |alive, x|
- RULES[alive ? :s : :b].include?(count_neighbors(grid, x, y))
+ width = @width
+ height = @height
+ next_grid = Array.new(width * height, false)
+
+ height.times do |y|
+ width.times do |x|
+ alive = grid[y * width + x]
+ neighbors = count_neighbors(grid, x, y, width, height)
+ next_grid[y * width + x] =
+ RULES[alive ? :s : :b].include?(neighbors)
end
end
- end
- def count_neighbors(grid, x, y)
- each_neighbor(grid, x, y).count { _1[:value] }
+ next_grid
end
- def each_neighbor(grid, x, y)
- Enumerator.new do |enum|
- -1.upto(1) do |yoff|
- row = grid[(y + yoff) % grid.size]
+ def count_neighbors(grid, x, y, width, height)
+ count = 0
+
+ (-1..1).each do |yoff|
+ ny = y + yoff
+ ny += height if ny < 0
+ ny -= height if ny >= height
- -1.upto(1) do |xoff|
- next if yoff.zero? && xoff.zero?
- value = row[(x + xoff) % row.size]
- enum.yield(x:, y:, value:)
- end
+ (-1..1).each do |xoff|
+ next if yoff.zero? && xoff.zero?
+ nx = x + xoff
+ nx += width if nx < 0
+ nx -= width if nx >= width
+
+ count += 1 if grid[ny * width + nx]
end
end
+
+ count
end
:css
@@ -177,41 +162,46 @@
gap: 1em;
}
-:ruby
- state => grid:, running:
-
%article
%Heading(level=2) Game of life
%p
This is an implementation of
- %Link(href="https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life" target="_blank")< Conway's Game of Life
+ %Link{
+ href: "https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life",
+ target: "_blank"
+ }<
+ Conway's Game of Life
\.
- %p Use the primary mouse button to draw and the secondary mouse button to erase.
+ %p
+ Use the primary mouse button to draw and the secondary mouse button to erase.
%Fieldset
%legend Controls
.buttons
%Button(onclick=handle_reset) Reset
%Button(onclick=handle_randomize) Randomize
- %Button(onclick=handle_step disabled=running) Step
+ %Button(onclick=handle_step disabled=@running) Step
%Button(onclick=handle_toggle_running){
- color: running ? "var(--red)" : "var(--green)"
- }= running ? "Stop" : "Start"
- -#
+ color: @running ? "var(--red)" : "var(--green)"
+ }
+ = @running ? "Stop" : "Start"
+
+ %Details(summary="Advanced controls")
%div
%label(for="grid-width") Grid width
%br
- %input(id="grid-width" type="range" min=MIN_SIZE max=MAX_SIZE step=1 oninput=handle_change_width){value: state[:width]}
- %output= state[:width]
+ %input(id="grid-width" type="range" min=MIN_SIZE max=MAX_SIZE step=1 oninput=handle_change_width){value: @width}
+ %output= @width
%div
%label(for="grid-height") Grid height
%br
- %input(id="grid-height" type="range" min=MIN_SIZE max=MAX_SIZE step=1 oninput=handle_change_height){value: state[:height]}
- %output= state[:height]
+ %input(id="grid-height" type="range" min=MIN_SIZE max=MAX_SIZE step=1 oninput=handle_change_height){value: @height}
+ %output= @height
%div
%label(for="game-fps") Frames per second
%br
- %input(id="game-fps" type="range" min=MIN_FPS max=MAX_FPS step=1 oninput=handle_change_fps){value: state[:fps]}
- %output= state[:fps]
- %GameGrid(grid=grid ondraw=handle_draw)
+ %input(id="game-fps" type="range" min=MIN_FPS max=MAX_FPS step=1 oninput=handle_change_fps){value: @fps}
+ %output= @fps
+
+ %GameGrid(ondraw=handle_draw){grid: @grid.each_slice(@width).to_a}
diff --git a/example/app/pages/demos/llm-chat/Message.haml b/example/app/pages/demos/llm-chat/Message.haml
new file mode 100644
index 00000000..12a06c62
--- /dev/null
+++ b/example/app/pages/demos/llm-chat/Message.haml
@@ -0,0 +1,38 @@
+:ruby
+ Markdown = import("/components/Markdown")
+
+%li{class: $role}
+ %strong= $role
+ %Markdown= $text
+
+:css
+ li {
+ display: flex;
+ gap: .5em;
+ padding: .5em;
+ margin: 0;
+ }
+
+ strong {
+ &::after {
+ content: ": ";
+ }
+ }
+
+ .user {
+ background: #0063;
+ }
+
+ .assistant {
+ background: #0603;
+ }
+
+ Markdown {
+ & > :first-child {
+ margin-top: 0;
+ }
+
+ & > :last-child {
+ margin-bottom: 0;
+ }
+ }
diff --git a/example/app/pages/demos/llm-chat/page.haml b/example/app/pages/demos/llm-chat/page.haml
new file mode 100644
index 00000000..bccf90ff
--- /dev/null
+++ b/example/app/pages/demos/llm-chat/page.haml
@@ -0,0 +1,160 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Button = import("/components/Form/Button")
+ Link = import("/components/UI/Link")
+ Message = import("./Message")
+
+ GithubModelsChat = import("/lib/GithubModelsChat")
+
+ MODEL = "Ministral-3B"
+ SYSTEM_MESSAGE = <<~TEXT.strip
+ You are an assistant at https://mayu.live/.
+ Mayu Live is a web framework for making reactive web pages in Ruby.
+ Mayu is inspired by React.
+ Everything runs on the server, except for a tiny little runtime that deals with the connection to the server and updates the DOM.
+ The Github repository can be found at https://github.com/mayu-live/framework.
+ Keep your responses short and concise.
+ Instead of saying how to do something, please refer to the official documentation at https://mayu.live/docs.
+ Documentation for getting started can be found at https://mayu.live/docs/getting-started
+ TEXT
+
+ def initialize
+ @key = 0
+ @error = nil
+ @messages = []
+ @words = []
+ @loading = false
+ @chat = GithubModelsChat.new(model: MODEL, system_message: SYSTEM_MESSAGE)
+ end
+
+ def handle_submit(e)
+ return if @loading
+
+ begin
+ text = e.dig(:currentTarget, :formData, :message)
+
+ @key += 1
+ @loading = true
+
+ @messages = [
+ *@messages,
+ { id: SecureRandom.alphanumeric, role: :user, text: }
+ ]
+
+ chunks = []
+
+ response = @chat.complete(text) do |word|
+ @words = [*@words, word]
+ end
+
+ @words = []
+
+ @messages = [
+ *@messages,
+ {
+ id: SecureRandom.alphanumeric,
+ role: :assistant,
+ text: response
+ }
+ ]
+ rescue GithubModelsChat::UnauthorizedError => e
+ @error = e.message
+ rescue => e
+ Console.logger.error(self, e)
+ @error = "Unknown error"
+ ensure
+ @loading = false
+ end
+ end
+
+%section
+ %Heading(level=2)
+ %span LLM chat
+
+ = if @error
+ %p.error= @error
+ = else
+ .scroller
+ = if @messages.empty?
+ %div.type-your-message
+ %p
+ %strong Type a message to chat with #{MODEL}
+ %p
+ This is using the free tier of
+ %Link(href="https://github.com/marketplace/models")< Github Models
+ \.
+ %ul
+ = unless @words.empty?
+ %Message[:temp]{
+ role: :assistant,
+ text: @words.join.strip
+ }
+ = @messages.reverse.map do |message|
+ %Message[message[:id]]{
+ role: message[:role],
+ text: message[:text],
+ }
+ %form(onsubmit=handle_submit)
+ %input[@key]{
+ autofocus: true,
+ type: "text",
+ name: "message",
+ autocomplete: "off",
+ placeholder: "Type your message here…"
+ }
+ %Button(type="submit"){disabled: @loading} Send
+
+:css
+ section {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ min-height: 20em;
+ height: 100%;
+ gap: 1em;
+ }
+
+ Heading {
+ margin-bottom: 0;
+ }
+
+ .scroller {
+ position: relative;
+ }
+
+ ul {
+ position: absolute;
+ inset: 0;
+ overflow-y: scroll;
+ font-family: "Roboto Mono";
+ border: 1px solid #0003;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column-reverse;
+ margin: 0;
+ padding: 0;
+ }
+
+ form {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 1em;
+ }
+
+ input {
+ padding: .5em;
+ }
+
+ .type-your-message {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ }
+
+ .error {
+ background-color: var(--material-color-red-50);
+ border: 1px solid var(--material-color-red-500);
+ border-radius: 3px;
+ padding: 1em;
+ }
diff --git a/example/app/pages/demos/ollama/Message.haml b/example/app/pages/demos/ollama/Message.haml
deleted file mode 100644
index e1729904..00000000
--- a/example/app/pages/demos/ollama/Message.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-%li{class: $role.to_sym}
- %strong= $role
- %span= $text
-
-:css
- li {
- display: block;
- padding: .5em;
- margin: 0;
- }
-
- strong {
- &::after {
- content: ": ";
- }
- }
-
- .user {
- background: #0063;
- }
-
- .model {
- background: #0603;
- }
diff --git a/example/app/pages/demos/ollama/page.haml b/example/app/pages/demos/ollama/page.haml
deleted file mode 100644
index 3caad2e9..00000000
--- a/example/app/pages/demos/ollama/page.haml
+++ /dev/null
@@ -1,141 +0,0 @@
-:ruby
- Heading = import("/app/components/Layout/Heading")
- Button = import("/app/components/Form/Button")
- Message = import("./Message")
-
- MODEL = "llama2"
-
- def self.get_initial_state(**) = {
- key: 0,
- messages: [],
- words: [],
- loading: false,
- ollama: Ollama.new(model: MODEL, url: ENV["OLLAMA_URL"])
- }
-
- def handle_submit(e)
- return if state[:loading]
-
- text = e.dig(:currentTarget, :formData, :message)
-
- update do |state|
- {
- **state,
- key: state[:key].succ,
- loading: true,
- messages: [
- *state[:messages],
- { id: SecureRandom.alphanumeric, role: "user", text: }
- ]
- }
- end
-
- chunks = []
-
- begin
- state[:ollama].generate(text) do |word|
- chunks.push(word)
-
- update do |state|
- {
- **state,
- words: [*state[:words], word]
- }
- end
- end
- rescue => e
- pp e
- end
-
- update do |state|
- {
- **state,
- words: [],
- messages: [
- *state[:messages],
- {
- id: SecureRandom.alphanumeric,
- role: "model",
- text: chunks.join.strip
- }
- ]
- }
- end
- ensure
- update(loading: false)
- end
-
-%section
- %Heading(level=2) Ollama chat
-
- .scroller
- = if state[:messages].empty?
- %p.type-your-message Type a message to chat with #{MODEL}
- %ul
- = unless state[:words].empty?
- %Message.model[:temp]{
- role: "model",
- text: state[:words].join.strip
- }
- = state[:messages].reverse.map do |message|
- %Message.model[message[:id]]{
- role: message[:role],
- text: message[:text],
- }
- %form(onsubmit=handle_submit)
- %input[state[:key]]{
- autofocus: true,
- type: "text",
- name: "message",
- autocomplete: "off",
- placeholder: "Type your message here…"
- }
- %Button(type="submit"){disabled: state[:loading]} Send
-
-:css
- section {
- display: grid;
- grid-template-rows: auto 1fr auto;
- min-height: 20em;
- gap: 1em;
- }
-
- Heading {
- margin-bottom: 0;
- }
-
- .scroller {
- position: relative;
- }
-
- ul {
- position: absolute;
- inset: 0;
- overflow-y: scroll;
- font-family: "Roboto Mono";
- white-space: pre-wrap;
- border: 1px solid #0003;
- border-radius: 3px;
- display: flex;
- flex-direction: column-reverse;
- margin: 0;
- padding: 0;
- }
-
- form {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 1em;
- }
-
- input {
- padding: .5em;
- }
-
- .type-your-message {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- font-weight: bold;
- }
diff --git a/example/app/pages/demos/page.haml b/example/app/pages/demos/page.haml
index e410dd64..e1dd45a7 100644
--- a/example/app/pages/demos/page.haml
+++ b/example/app/pages/demos/page.haml
@@ -1,5 +1,5 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
+ Heading = import("/components/Layout/Heading")
ButtonGame = import("./ButtonGame")
%article
diff --git a/example/app/pages/demos/pokemon/:id/page.haml b/example/app/pages/demos/pokemon/:id/page.haml
index 806b15bc..d963dee1 100644
--- a/example/app/pages/demos/pokemon/:id/page.haml
+++ b/example/app/pages/demos/pokemon/:id/page.haml
@@ -1,29 +1,21 @@
:ruby
- def self.get_initial_state(props) = {
- result: nil,
- error: nil,
- }
def mount
- props => { request: { params: { id: /\A\d+\z/ => id } } }
- res = helpers.fetch("https://pokeapi.co/api/v2/pokemon/#{id}/")
- result = res.json(symbolize_names: true)
- update(result:)
+ $params => { id: /\A\d+\z/ => id }
+ res = fetch("https://pokeapi.co/api/v2/pokemon/#{id}/")
+ @pokemon = res.json(symbolize_names: true)
rescue => e
- update(error: e.message)
+ @error = e.message
end
-:ruby
- pokemon = state[:result]
-
-- return unless pokemon
+- return unless @pokemon
%p Loading pokémon
%article
- %h1= pokemon[:name]
- %img{src: pokemon.dig(:sprites, :front_default)}
+ %h1= @pokemon[:name]
+ %img{src: @pokemon.dig(:sprites, :front_default)}
%dl
%dt Weight
- %dd= pokemon[:weight]
+ %dd= @pokemon[:weight]
%dt Base experience
- %dd= pokemon[:base_experience]
+ %dd= @pokemon[:base_experience]
diff --git a/example/app/pages/demos/pokemon/Filter.haml b/example/app/pages/demos/pokemon/Filter.haml
index e251f357..99d719b9 100644
--- a/example/app/pages/demos/pokemon/Filter.haml
+++ b/example/app/pages/demos/pokemon/Filter.haml
@@ -1,40 +1,36 @@
:ruby
require "fuzzy_match"
- Fieldset = import("/app/components/Form/Fieldset")
- Input = import("/app/components/Form/Input")
- Button = import("/app/components/Form/Button")
- Icon = import("/app/components/UI/Icon")
-
- def self.get_initial_state(**) = {
- open: false,
- value: "",
- suggestions: [],
- }
+ Fieldset = import("/components/Form/Fieldset")
+ Input = import("/components/Form/Input")
+ Button = import("/components/Form/Button")
+ Icon = import("/components/UI/Icon")
+
+ def initialize
+ @open = false
+ @value = ""
+ @suggestions = []
+ @fuzzymatch = FuzzyMatch.new($results, read: :name)
+ end
def handle_open =
- update(open: true)
+ @open = true
def handle_close =
- update(open: false)
-
- def fuzzymatch =
- @fuzzymatch ||= FuzzyMatch.new($results, read: :name)
+ @open = false
def handle_change(e)
e => { target: { value: } }
- update(
- value:,
- suggestions: fuzzymatch.find_all(value).first(10)
- )
+ @value = value
+ @suggestions = @fuzzymatch.find_all(value).first(10)
end
%div
%Button(onclick=handle_open)
%Icon(name="filter")>
Filter
- %dialog(onclose=handle_close){open: state[:open]}
+ %dialog(onclose=handle_close open=@open)
%form(method="dialog")
%Fieldset
%legend Filter
@@ -42,7 +38,7 @@
%Button Close
.results
%ul
- = state[:suggestions].map do |result|
+ = @suggestions.map do |result|
- id = result[:url].split("/").last.to_i
%li
%a(href="/demos/pokemon/#{id}")
diff --git a/example/app/pages/demos/pokemon/Pagination.css b/example/app/pages/demos/pokemon/Pagination.css
index 254d6a81..41b8cce4 100644
--- a/example/app/pages/demos/pokemon/Pagination.css
+++ b/example/app/pages/demos/pokemon/Pagination.css
@@ -2,7 +2,7 @@
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: 1em;
+ gap: 2em;
padding: 1em;
margin: 1em;
background: #fff;
@@ -11,24 +11,24 @@
background: var(--material-color-light-blue-50);
}
-.buttons {
+nav {
display: flex;
- flex-direction: row;
gap: 0.5em;
align-items: center;
- align-content: space-between;
+ justify-content: space-between;
flex: 1;
border-radius: 2px;
}
-.button[href]:any-link {
- text-decoration: none;
- color: var(--blue);
-}
+.button[href] {
+ &:any-link {
+ text-decoration: none;
+ color: var(--blue);
+ }
-.button[href]:active,
-.button[href]:hover {
- text-decoration: underline;
+ &:is(:active, :hover) {
+ text-decoration: underline;
+ }
}
.per-page {
@@ -37,7 +37,6 @@
}
.pages {
- flex: 1;
list-style-type: none;
display: flex;
padding: 0;
@@ -52,24 +51,24 @@
display: inline-block;
width: 2em;
text-align: center;
-}
+ background: transparent;
+ transition: background 500ms;
-.page:link,
-.page:visited {
- text-decoration: none;
- color: var(--blue);
-}
+ &:any-link {
+ text-decoration: none;
+ color: var(--blue);
+ }
-.page:active,
-.page:hover {
- color: var(--blue);
- font-weight: bold;
- text-decoration: underline;
-}
+ &:is(:active, :hover) {
+ color: var(--blue);
+ font-weight: bold;
+ text-decoration: underline;
+ }
-.page[aria-current="page"] {
- background: var(--blue);
- color: var(--bright);
- font-weight: bold;
- border-radius: 2px;
+ &[aria-current="page"] {
+ background: var(--blue);
+ color: var(--bright);
+ font-weight: bold;
+ border-radius: 2px;
+ }
}
diff --git a/example/app/pages/demos/pokemon/Pagination.haml b/example/app/pages/demos/pokemon/Pagination.haml
index 47fc8ab6..992d74b9 100644
--- a/example/app/pages/demos/pokemon/Pagination.haml
+++ b/example/app/pages/demos/pokemon/Pagination.haml
@@ -1,6 +1,6 @@
:ruby
- Fieldset = import("/app/components/Form/Fieldset")
- Button = import("/app/components/Form/Button")
+ Fieldset = import("/components/Form/Fieldset")
+ Button = import("/components/Form/Button")
def pagination_window(current_page:, total_pages:, window_size: 5)
half_window_size = (window_size - 1) / 2
@@ -33,7 +33,7 @@
Page #{$page} of #{$total_pages.succ}, showing #{$per_page} per page
.wrap
- %nav.buttons(aria-label="pagination")
+ %nav(aria-label="pagination")
%a.button(href=prev_page_link rel="prev")
Previous page
diff --git a/example/app/pages/demos/pokemon/layout.haml b/example/app/pages/demos/pokemon/layout.haml
index 65d0bf08..cac32ff6 100644
--- a/example/app/pages/demos/pokemon/layout.haml
+++ b/example/app/pages/demos/pokemon/layout.haml
@@ -1,5 +1,5 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
+ Heading = import("/components/Layout/Heading")
%div
%Heading(level=2) Pokémon
diff --git a/example/app/pages/demos/pokemon/page.haml b/example/app/pages/demos/pokemon/page.haml
index 80b72a98..2404f3f1 100644
--- a/example/app/pages/demos/pokemon/page.haml
+++ b/example/app/pages/demos/pokemon/page.haml
@@ -1,29 +1,29 @@
:ruby
- Spinner = import("/app/components/UI/Spinner")
- Link = import("/app/components/UI/Link")
+ Spinner = import("/components/UI/Spinner")
+ Link = import("/components/UI/Link")
Filter = import("./Filter")
Pagination = import("./Pagination")
- def self.get_initial_state(**props) = {
- result: nil,
- error: nil,
- page: props.dig(:request, :query, :page).to_i,
- per_page: props.dig(:per_page).to_i.nonzero? || 20,
- }
+ def initialize
+ @result = nil
+ @error = nil
+ @page = $query[:page].to_i
+ @per_page = $per_page.to_i.nonzero? || 20
+ end
def mount
sleep 1
- res = helpers.fetch("https://pokeapi.co/api/v2/pokemon?limit=100000&offset=0")
- result = res.json(symbolize_names: true)
- update(result:)
+ res = fetch("https://pokeapi.co/api/v2/pokemon?limit=100000&offset=0")
+ @result = res.json(symbolize_names: true)
rescue => e
- update(error: e.message)
+ @error = e.message
end
def handle_set_per_page(e)
e => { target: { value: } }
- update(page: 0, per_page: value.to_i)
+ @page = 0
+ @per_page = value.to_i
end
:css
@@ -36,13 +36,10 @@
margin: 0.5em 0;
}
-:ruby
- state => result:, error:
-
-- return if error
- %p Error: #{error}
+- return if @error
+ %p Error: #{@error}
-- return unless result
+- return unless @result
%div
%p
Loading Pokémon from
@@ -51,20 +48,20 @@
%Spinner
:ruby
- result => results:
+ @result => results:
- per_page = state[:per_page]
- total_pages = (results.length / per_page).floor
- page = props.dig(:request, :query, :page).to_i.clamp(1, total_pages.succ)
- results_on_this_page = results.slice(page.pred * per_page, per_page) || []
+ total_pages = (results.length / @per_page).floor
+ page = $query[:page].to_i.clamp(1, total_pages.succ)
+ results_on_this_page = results.slice(page.pred * @per_page, @per_page) || []
%article
%Filter{results:}
%Pagination(on-change-per-page=handle_set_per_page){
page:,
- per_page:,
+ per_page: @per_page,
total_pages:,
+ window_size: 7,
}
%ul
@@ -78,6 +75,7 @@
%Pagination(on-change-per-page=handle_set_per_page){
page:,
- per_page:,
+ per_page: @per_page,
total_pages:,
+ window_size: 7
}
diff --git a/example/app/pages/demos/snake/page.haml b/example/app/pages/demos/snake/page.haml
new file mode 100644
index 00000000..0a787440
--- /dev/null
+++ b/example/app/pages/demos/snake/page.haml
@@ -0,0 +1,251 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Button = import("/components/Form/Button")
+
+ GRID_WIDTH = 32
+ GRID_HEIGHT = 16
+
+ DEFAULT_FPS = 8
+ MIN_FPS = 2
+ MAX_FPS = 20
+
+ def initialize
+ reset_game
+ @fps = DEFAULT_FPS
+ end
+
+ def mount
+ loop do
+ if @running && !@game_over
+ handle_tick
+ sleep 1.0 / @fps
+ else
+ sleep 0.1
+ end
+ end
+ end
+
+ def reset_game
+ mid_x = GRID_WIDTH / 2
+ mid_y = GRID_HEIGHT / 2
+
+ @snake = [[mid_x, mid_y], [mid_x - 1, mid_y], [mid_x - 2, mid_y]]
+ @direction = [1, 0]
+ @queued_direction = [1, 0]
+ @food = random_free_cell(@snake)
+ @score = 0
+ @running = false
+ @game_over = false
+ end
+
+ def random_free_cell(snake)
+ occupied = snake.each_with_object({}) { |(x, y), memo| memo["#{x},#{y}"] = true }
+ free = []
+
+ GRID_HEIGHT.times do |y|
+ GRID_WIDTH.times do |x|
+ free << [x, y] unless occupied["#{x},#{y}"]
+ end
+ end
+
+ free.sample
+ end
+
+ def handle_change_fps(event)
+ event => { target: { value: } }
+ @fps = value.to_i.clamp(MIN_FPS, MAX_FPS)
+ end
+
+ def handle_toggle_running
+ return if @game_over
+ @running = !@running
+ end
+
+ def handle_reset
+ reset_game
+ end
+
+ def handle_turn_up
+ queue_direction(0, -1)
+ end
+
+ def handle_turn_down
+ queue_direction(0, 1)
+ end
+
+ def handle_turn_left
+ queue_direction(-1, 0)
+ end
+
+ def handle_turn_right
+ queue_direction(1, 0)
+ end
+
+ def handle_keydown(event)
+ event => { key: }
+
+ case key
+ when "ArrowUp", "w", "W"
+ handle_turn_up
+ when "ArrowDown", "s", "S"
+ handle_turn_down
+ when "ArrowLeft", "a", "A"
+ handle_turn_left
+ when "ArrowRight", "d", "D"
+ handle_turn_right
+ when " "
+ handle_toggle_running
+ end
+ end
+
+ def queue_direction(dx, dy)
+ return if @game_over
+
+ cur_dx, cur_dy = @queued_direction
+ return if cur_dx == -dx && cur_dy == -dy
+
+ @queued_direction = [dx, dy]
+ @running = true unless @running
+ end
+
+ def handle_tick
+ @direction = @queued_direction
+ dx, dy = @direction
+ head_x, head_y = @snake.first
+
+ next_head = [head_x + dx, head_y + dy]
+ next_x, next_y = next_head
+
+ if next_x < 0 || next_x >= GRID_WIDTH || next_y < 0 || next_y >= GRID_HEIGHT
+ @running = false
+ @game_over = true
+ return
+ end
+
+ growing = next_head == @food
+ collision_body = growing ? @snake : @snake[0...-1]
+
+ if collision_body.include?(next_head)
+ @running = false
+ @game_over = true
+ return
+ end
+
+ new_snake = [next_head, *@snake]
+ new_snake.pop unless growing
+
+ if growing
+ @score += 1
+ @food = random_free_cell(new_snake)
+
+ if @food.nil?
+ @running = false
+ @game_over = true
+ end
+ end
+
+ @snake = new_snake
+ end
+
+%article
+ %Heading(level=2) Snake
+ %p
+ Arrow keys or WASD to steer. Space toggles pause. Click the grid first to focus keyboard controls.
+
+ .controls
+ %Button(onclick=handle_toggle_running disabled=@game_over){
+ color: @running ? "var(--red)" : "var(--green)"
+ }
+ = @running ? "Pause" : "Start"
+ %Button(onclick=handle_reset) Reset
+ .fps
+ %label(for="snake-fps") Speed
+ %input(id="snake-fps" type="range" min=MIN_FPS max=MAX_FPS step=1 oninput=handle_change_fps){value: @fps}
+ %output= @fps
+
+ %p
+ Score:
+ %strong<= @score
+
+ - snake_map = @snake.each_with_object({}) { |(x, y), memo| memo["#{x},#{y}"] = true }
+ - head_x, head_y = @snake.first
+ - food_x, food_y = @food || [-1, -1]
+
+ .grid(tabindex=0 onkeydown=handle_keydown){
+ style: { __grid_cols: GRID_WIDTH, __grid_rows: GRID_HEIGHT }
+ }
+ = GRID_HEIGHT.times.map do |y|
+ = GRID_WIDTH.times.map do |x|
+ - key = "#{x}.#{y}"
+ - is_head = (x == head_x && y == head_y)
+ - is_snake = snake_map["#{x},#{y}"]
+ - is_food = (x == food_x && y == food_y)
+ .cell[key]{class: { snake: is_snake, head: is_head, food: is_food }}
+ = if @game_over
+ %p.game-over Game over
+
+:css
+ article {
+ max-width: 42rem;
+ }
+
+ .controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ margin-block: 1rem;
+ }
+
+ .fps {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .game-over {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ translate: -50% -50%;
+ color: #ff5f7a;
+ font-weight: 700;
+ font-size: 4em;
+ margin: 0;
+ filter: drop-shadow(0 0 .2em #000);
+ }
+
+ .grid {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(var(--grid-cols), 1fr);
+ grid-template-rows: repeat(var(--grid-rows), 1fr);
+ gap: 1px;
+ background: #0d0f14;
+ border: 1px solid #0d0f14;
+ width: 100%;
+ outline: none;
+ padding: 0;
+ user-select: none;
+ border-radius: 2px;
+ contain: paint;
+
+ &:focus {
+ outline: 2px solid AccentColor;
+ }
+ }
+
+ .cell {
+ aspect-ratio: 1;
+ background: #f0f2f7;
+
+ &.snake {
+ background: #3fdb86;
+ }
+ &.head {
+ background: #22b86a;
+ }
+ &.food {
+ background: #ff5f7a;
+ }
+ }
diff --git a/example/app/pages/demos/sorting/page.haml b/example/app/pages/demos/sorting/page.haml
index 89e6a1e7..2185265a 100644
--- a/example/app/pages/demos/sorting/page.haml
+++ b/example/app/pages/demos/sorting/page.haml
@@ -1,64 +1,45 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Highlight = import("/app/components/UI/Highlight")
- Card = import("/app/components/UI/Card")
- Details = import("/app/components/UI/Details")
- Button = import("/app/components/Form/Button")
- Fieldset = import("/app/components/Form/Fieldset")
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Details = import("/components/UI/Details")
+ Button = import("/components/Form/Button")
+ Fieldset = import("/components/Form/Fieldset")
Item = Data.define(:id, :color) do
def self.create
new(
- id: Nanoid.generate(size: 10),
+ id: SecureRandom.alphanumeric(10),
color: rand * 360
)
end
end
- def self.get_initial_state(**) = {
- items: Array.new(5) { Item.create }
- }
+ def initialize =
+ @items = Array.new(5) { Item.create }
- def handle_add
- update do |state|
- { items: [*state[:items], *Array.new(5) { Item.create }] }
- end
- end
+ def handle_add =
+ @items = [*@items, *Array.new(5) { Item.create }]
- def handle_remove
- update do |state|
- { items: state[:items].reject { rand(3).zero? } }
- end
- end
+ def handle_remove =
+ @items = @items.reject { rand(3).zero? }
- def handle_shuffle
- update do |state|
- { items: state[:items].shuffle }
- end
- end
+ def handle_shuffle =
+ @items = @items.shuffle
- def handle_shuffle_slices
- update do |state|
- { items: state[:items].each_slice(5).to_a.shuffle.flatten }
- end
- end
+ def handle_shuffle_slices =
+ @items = @items.each_slice(5).to_a.shuffle.flatten
- def handle_sort
- update do |state|
- { items: state[:items].sort_by(&:id) }
- end
- end
+ def handle_sort =
+ @items = @items.sort_by(&:id)
- def handle_sort_by_color
- update do |state|
- { items: state[:items].sort_by(&:color) }
- end
- end
+ def handle_sort_by_color =
+ @items = @items.sort_by(&:color)
%article
%Heading(level=2)
Sorting
- %span< (#{state[:items].size} items)
+ %span< (#{@items.size} items)
.flex
%Button(onclick=handle_add) Add items
%Button(onclick=handle_remove) Remove items
@@ -67,29 +48,32 @@
%Button(onclick=handle_sort) Sort
%Button(onclick=handle_sort_by_color) Sort by color
.flex
- %pre= state[:items].map(&:id).join("\n")
+ %pre= @items.map(&:id).join("\n")
%ul
- = state[:items].map do |item|
+ = @items.map do |item|
%li[item.id]{style: { __color: item.color }}
#{item.id} (#{item.color.to_i})
:css
.flex {
display: flex;
- gap: .25em;
+ gap: 1em;
flex-wrap: wrap;
+ margin: 1em 0;
}
pre {
- line-height: 2em;
+ margin: 0;
+ font-size: 1rem;
+ line-height: 2rem;
}
ul {
list-style-type: none;
+ margin: 0;
padding: 0;
flex: 1 1 5em;
gap: 1px;
font-family: monospace;
- line-height: 2em;
border-radius: 3px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 24%) 0px 3px 8px;
@@ -98,9 +82,9 @@
li {
margin: 0;
padding: 0 .5em;
- background: hsl(var(--color), 50%, 70%);
+ background: hsl(var(--color), 90%, 70%);
background-image: linear-gradient(0.5turn, #0006 0%, #0003 25%, #0000 100%);
color: #000;
- font-size: 1em;
- line-height: 2em;
+ font-size: 1rem;
+ line-height: 2rem;
}
diff --git a/example/app/pages/demos/svg/page.haml b/example/app/pages/demos/svg/page.haml
index 0d554b03..75471309 100644
--- a/example/app/pages/demos/svg/page.haml
+++ b/example/app/pages/demos/svg/page.haml
@@ -1,6 +1,6 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Clock = import("/app/components/Clock")
+ Heading = import("/components/Layout/Heading")
+ Clock = import("/components/Clock")
PATHS = [
"M 10 10 C 20 20, 40 20, 50 10",
diff --git a/example/app/pages/demos/todo/page.haml b/example/app/pages/demos/todo/page.haml
index 7d065ff0..aa32a948 100644
--- a/example/app/pages/demos/todo/page.haml
+++ b/example/app/pages/demos/todo/page.haml
@@ -1,117 +1,110 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Card = import("/app/components/UI/Card")
- Link = import("/app/components/UI/Link")
-
- def self.get_initial_state(**) = {
- items: [],
- editing: nil,
- filter: "all",
- form_key: "form-0",
- }
+ Heading = import("/components/Layout/Heading")
+ Card = import("/components/UI/Card")
+ Link = import("/components/UI/Link")
+
+ Item = Data.define(:id, :description, :completed) do
+ def self.create(description)
+ new(id: SecureRandom.alphanumeric(10), description:, completed: false)
+ end
+
+ alias :completed? :completed
+ end
+
+ def initialize
+ @items = []
+ @editing = nil
+ @filter = "all"
+ @form_key = "form-0"
+ end
def handle_submit(e)
e => { target: { formData: { new_todo: String => description } } }
- update do |items:, form_key:|
- {
- items: [
- { id: Nanoid.generate(), description: },
- *items,
- ],
- form_key: form_key.succ,
- editing: nil,
- }
- end
+ @items = [
+ Item.create(description),
+ *@items
+ ]
+
+ @form_key = @form_key.succ
+ @editing = nil
end
def handle_set_filter(e)
e => { target: { name: } }
- update(filter: name, editing: nil)
+ @filter = name
+ @editing = nil
end
- def handle_dblclick(e)
+ def handle_start_edit(e)
e => { currentTarget: { id: } }
- update(editing: id)
+ @editing = id
end
def handle_update(e)
e => { currentTarget: { id:, formData: { description: } } }
- update do |items:|
- {
- editing: nil,
- items: items.map { |item|
- if item[:id] == id
- { **item, description:}
- else
- item
- end
- }
- }
+ description = description.strip
+
+ @editing = nil
+
+ if description.empty?
+ @items = @items.reject { |item| item.id == id }
+ else
+ @items = @items.map do |item|
+ if item.id == id
+ item.with(description:)
+ else
+ item
+ end
+ end
end
end
def handle_check(e)
e => { currentTarget: { name: id, checked: completed } }
- update do |items:|
- {
- editing: nil,
- items: items.map { |item|
- if item[:id] == id
- { **item, completed: }
- else
- item
- end
- }
- }
+ @editing = nil
+ @items = @items.map do |item|
+ if item.id == id
+ item.with(completed:)
+ else
+ item
+ end
end
end
def handle_delete(e)
e => { currentTarget: { name: id } }
- update do |items:|
- {
- editing: nil,
- items: items.reject { |item| item[:id] == id }
- }
- end
+ @items = @items.reject { |item| item.id == id }
end
def handle_toggle_all(e)
e => { target: { checked: completed } }
- update do |items:|
- {
- editing: nil,
- items: items.map do |item|
- { **item, completed: }
- end
- }
+ @items = @items.map do |item|
+ item.with(completed:)
end
+
+ @editing = nil
end
def handle_clear_completed
- update do |items:|
- {
- editing: nil,
- items: items.reject { _1[:completed] }
- }
- end
+ @editing = nil
+ @items = @items.reject(&:completed?)
end
private
- def get_filtered_items(filter = state[:filter])
+ def get_filtered_items(filter = @filter)
case filter
in "all"
- state[:items]
+ @items
in "active"
- state[:items].select { !_1[:completed] }
+ @items.select { !_1.completed? }
in "completed"
- state[:items].select { _1[:completed] }
+ @items.select { _1.completed? }
end
end
@@ -128,22 +121,73 @@
end
def toggle_checkbox_state
- completed_count = state[:items].count { _1[:completed] }
- remaining_count = state[:items].count { !_1[:completed] }
-
- if completed_count == 0
- { checked: false, indeterminate: false }
+ if @items.empty?
+ { disabled: true }
else
- if remaining_count == 0
- { checked: true, indeterminate: false }
+ completed_count = @items.count { _1.completed? }
+ remaining_count = @items.size - completed_count
+
+ case
+ when completed_count == 0
+ {}
+ when remaining_count == 0
+ { checked: true }
else
- { checked: false, indeterminate: true }
+ { indeterminate: true }
end
end
end
+%article
+ %Heading(level=2) Todo App
+ %p
+ This is an implementation of the classic
+ %Link(href="https://todomvc.com/" target="_blank")< TodoMVC app
+ \.
+ Double-click to edit a todo.
+ %Card
+ %header
+ %form.add-todo-form(onsubmit=handle_submit autocomplete="off")[@form_key]
+ %input(type="checkbox" onchange=handle_toggle_all){**toggle_checkbox_state}
+ %input(type="text" required autofocus name="new_todo" placeholder="What needs to be done?" value="")
+ %ul
+ = get_filtered_items.map do |item|
+ %li[item.id]
+ = if @editing == item.id
+ %input(type="checkbox" disabled){
+ name: item.id,
+ checked: item.completed?,
+ }
+ %form.edit-todo-form(onsubmit=handle_update autocomplete="off"){id: item.id}
+ %input(type="text" name="description" autofocus onfocus="event.target.select()" required){
+ value: item.description
+ }
+ %button.delete-button{name: item.id}
+ = else
+ %input(type="checkbox" onchange=handle_check){
+ name: item.id,
+ checked: item.completed?,
+ }
+ %p.description(ondblclick=handle_start_edit){
+ id: item.id,
+ class: { completed: item.completed? },
+ }= item.description
+ %button.delete-button(onclick=handle_delete){name: item.id}
+ %footer
+ .footer-grid
+ .items-left #{pluralize(get_filtered_items("active").size, "item", "items")} left
+ .filter
+ = %w[all active completed].map do |name|
+ %button.filter-button(onclick=handle_set_filter name=name title="Show #{name} items"){
+ aria: { current: (@filter == name).to_s }
+ }= name.capitalize
+ .clear-completed
+ - completed = get_filtered_items("completed")
+ = if completed.length.nonzero?
+ %button.clear-button(onclick=handle_clear_completed) Clear completed
+
:css
- .header {
+ header {
border-bottom: 1px solid #0003;
padding: 1em;
}
@@ -154,28 +198,34 @@
margin: 0;
}
- .footer {
+ footer {
padding: 1em;
background: #fefefe;
box-shadow: inset 0 0 15px -10px #000;
}
.footer-grid {
- font-size: .8em;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
+ font-size: .8em;
}
ul {
padding: 0;
margin: 0;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
}
li {
+ display: grid;
+ grid-template-columns: subgrid;
+ grid-column: 1 / -1;
+ align-items: center;
+ gap: 1em;
padding: 1em;
margin: 0;
- display: block;
background: linear-gradient(0turn, #0001 0%, #0000 100%);
border-bottom: 1px solid #0003;
}
@@ -189,51 +239,55 @@
transition: opacity 200ms;
cursor: pointer;
position: relative;
- }
-
- .delete-button::after {
- content: "×";
- display: block;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%) scale(2.0);
- }
- li:hover .delete-button,
- li:focus-within .delete-button {
- opacity: 1;
+ &::after {
+ content: "×";
+ display: block;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ color: var(--material-color-red-500);
+ transform: translate(-50%, -50%) scale(2.0);
+ }
+
+ li:is(:hover, :focus-within) & {
+ opacity: 1;
+ }
+
+ &:not([onclick]) {
+ visibility: hidden;
+ }
}
- .row {
- padding: 0;
- margin: 0;
- display: flex;
- align-items: center;
- gap: .5em;
- }
-
- .form {
- composes: row;
+ .add-todo-form {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 1em;
}
- .edit {
- composes: row;
- padding-left: 3em;
+ .edit-todo-form {
}
.description {
margin: 0;
- flex: 1;
}
- .checkbox {
- width: 2em;
- aspect-ratio: 1;
- }
+ input {
+ &[type="checkbox"] {
+ margin: 0;
+ width: 2em;
+ aspect-ratio: 1;
+
+ &:not([onchange]) {
+ visibility: hidden;
+ }
+ }
- .toggle-all {
- composes: checkbox;
+ &[type="text"] {
+ display: block;
+ width: 100%;
+ height: 2em;
+ }
}
.completed {
@@ -241,12 +295,6 @@
opacity: .5;
}
- .input {
- display: block;
- flex: 1;
- height: 2em;
- }
-
.filter {
display: flex;
gap: .5em;
@@ -289,46 +337,6 @@
text-align: right;
}
-%article
- %Heading(level=2) Todo App
- %p
- This is an implementation of the classic
- %Link(href="https://todomvc.com/" target="_blank")< TodoMVC app
- \.
- %Card
- .header
- %form.form(onsubmit=handle_submit autocomplete="off")[state[:form_key]]
- %input.toggle-all(type="checkbox" onchange=handle_toggle_all){**toggle_checkbox_state}
- %input.input(required autofocus name="new_todo" placeholder="What needs to be done?" value="")
- .main
- %ul
- = get_filtered_items.map do |item|
- %li[item[:id]]
- = if state[:editing] == item[:id]
- %form.edit(onsubmit=handle_update autocomplete="off"){id: item[:id]}
- %input.input(type="text" name="description" autofocus onfocus="event.target.select()" required){
- initial_value: item[:description]
- }
- = else
- .row
- %input.checkbox(type="checkbox" onchange=handle_check){
- name: item[:id],
- checked: item[:completed],
- }
- %p.description(ondblclick=handle_dblclick){
- id: item[:id],
- class: { completed: item[:completed] },
- }= item[:description]
- %button.delete-button(onclick=handle_delete){name: item[:id]}
- .footer
- .footer-grid
- .items-left #{pluralize(get_filtered_items("active").size, "item", "items")} left
- .filter
- = %w[all active completed].map do |name|
- %button.filter-button(onclick=handle_set_filter name=name title="Show #{name} items"){
- aria: { current: (state[:filter] == name).to_s }
- }= name.capitalize
- .clear-completed
- - completed = get_filtered_items("completed")
- = if completed.length.nonzero?
- %button.clear-button(onclick=handle_clear_completed) Clear completed
+ .items-left {
+ opacity: .8;
+ }
diff --git a/example/app/pages/demos/tree/::paths/page.haml b/example/app/pages/demos/tree/::paths/page.haml
new file mode 100644
index 00000000..4fd9d27c
--- /dev/null
+++ b/example/app/pages/demos/tree/::paths/page.haml
@@ -0,0 +1,51 @@
+:ruby
+ Highlight = import("/components/UI/Highlight")
+
+ ALLOWED_EXTENSIONS = %w[.rb .css .haml .txt .svg]
+
+- full_path = File.expand_path(Array($params[:paths]).join("/"), "/")
+- root = File.expand_path(".")
+
+- return unless full_path.start_with?("/app/")
+ %p #{full_path} is not valid
+
+- absolute_path = File.join(root, full_path)
+- basename = File.basename(full_path)
+
+- return unless File.file?(absolute_path)
+ %p #{basename} is not a file
+
+- extname = File.extname(basename)
+- return unless ALLOWED_EXTENSIONS.include?(extname)
+ %p
+ %code>= full_path
+ is not a
+ %code<= ALLOWED_EXTENSIONS.join("/")
+ \-file
+
+- source = File.read(absolute_path)
+- language = extname == ".txt" ? nil : extname.delete_prefix(".").to_sym
+
+%article.article
+ %h3.path= full_path
+ %Highlight(language=language)= source
+
+:css
+ .article {
+ }
+
+ .path {
+ font-family: monospace;
+ font-size: 1em;
+ margin: 0;
+ }
+
+ .pre {
+ white-space: pre-wrap;
+ font-size: 1em;
+ background: var(--blue-bright);
+ border: 1px solid var(--blue);
+ border-radius: 2px;
+ padding: 1em;
+ }
+
diff --git a/example/app/pages/demos/tree/Directory.haml b/example/app/pages/demos/tree/Directory.haml
index 91abe407..38923aba 100644
--- a/example/app/pages/demos/tree/Directory.haml
+++ b/example/app/pages/demos/tree/Directory.haml
@@ -1,16 +1,16 @@
:ruby
- Icon = import("/app/components/UI/Icon")
+ Icon = import("/components/UI/Icon")
Name = import("./Name")
FileEntry = import("./FileEntry")
- def self.get_initial_state(initial_open: false, **) = {
- open: initial_open,
- entries: nil,
- }
+ def initialize
+ @open = $initial_open || false
+ @entries = @open && load_entries
+ end
def mount
- if state[:open]
- update(entries: load_entries)
+ if @open
+ @entries = load_entries
end
end
@@ -19,31 +19,45 @@
end
def handle_toggle
- update do |state|
- open = !state[:open]
- entries = state[:entries] || (open && load_entries)
- { open:, entries: }
- end
+ @open = !@open
+ @entries ||= @open && load_entries
end
private
def load_entries
- props => { root:, path: }
-
entries =
Dir
- .entries(File.join(root, path))
+ .entries(File.join($root, $path))
.difference(%w[. ..])
.sort
- .map { File.join(path, _1) }
- .group_by { File.directory?(File.join(root, _1)) }
+ .map { File.join($path, _1) }
+ .group_by { File.directory?(File.join($root, _1)) }
{
directories: entries[true] || [],
files: entries[false] || [],
}
end
+
+:ruby
+ icon = @open ? "folder-open" : "folder"
+
+%div
+ %Name(icon=icon color="var(--blue)")
+ %button(type="button" onclick=handle_toggle)
+ #{File.basename($path)}/
+
+ = if @open
+ = if @entries
+ %ul
+ = @entries[:directories]&.map do |path|
+ %li[path]
+ %Self(root=$root path=path)
+ = @entries[:files]&.map do |path|
+ %li[path]
+ %FileEntry(root=$root path=path)
+
:css
li {
margin: 0;
@@ -65,20 +79,3 @@
text-decoration: underline;
cursor: pointer;
}
-:ruby
- icon = state[:open] ? "folder-open" : "folder"
-
-%div
- %Name(icon=icon color="var(--blue)")
- %button(type="button" onclick=handle_toggle)
- #{File.basename($path)}/
-
- = if state[:open]
- = if entries = state[:entries]
- %ul
- = entries[:directories]&.map do |path|
- %li[path]
- %Self(root=$root path=path)
- = entries[:files]&.map do |path|
- %li[path]
- %FileEntry(root=$root path=path)
diff --git a/example/app/pages/demos/tree/FileContents.haml b/example/app/pages/demos/tree/FileContents.haml
index 1878db21..8b18db57 100644
--- a/example/app/pages/demos/tree/FileContents.haml
+++ b/example/app/pages/demos/tree/FileContents.haml
@@ -1,5 +1,5 @@
:ruby
- Highlight = import("/app/components/UI/Highlight")
+ Highlight = import("/components/UI/Highlight")
ALLOWED_EXTENSIONS = %w[.rb .css .haml]
@@ -22,31 +22,28 @@
padding: 1em;
}
-:ruby
-- props => { root:, path: }
-
-- return unless path
+- return unless $path
%p Please choose a file
-- path = File.expand_path(path, "/")
+- full_path = File.expand_path($path, "/")
-- return unless path.start_with?("/app/")
- %p #{path} is not valid
+- return unless full_path.start_with?("/app/")
+ %p #{full_path} is not valid
-- absolute_path = File.join(root, path)
-- basename = File.basename(path)
+- absolute_path = File.join($root, full_path)
+- basename = File.basename(full_path)
- return unless File.file?(absolute_path)
%p #{basename} is not a file
- extname = File.extname(basename)
- return unless ALLOWED_EXTENSIONS.include?(extname)
- %p #{path} is not a #{ALLOWED_EXTENSIONS.join("/")}-file
+ %p #{full_path} is not a #{ALLOWED_EXTENSIONS.join("/")}-file
:ruby
source = File.read(absolute_path)
language = extname.delete_prefix(".").to_sym
%article.article
- %h3.path= path
+ %h3.path= full_path
%Highlight(language=language)= source
diff --git a/example/app/pages/demos/tree/FileEntry.haml b/example/app/pages/demos/tree/FileEntry.haml
index aa7e28c2..a1bb43f7 100644
--- a/example/app/pages/demos/tree/FileEntry.haml
+++ b/example/app/pages/demos/tree/FileEntry.haml
@@ -1,6 +1,6 @@
:ruby
- Icon = import("/app/components/UI/Icon")
- Link = import("/app/components/UI/Link")
+ Icon = import("/components/UI/Icon")
+ Link = import("/components/UI/Link")
Name = import("./Name")
def icon
@@ -25,9 +25,6 @@
gap: .5em;
}
-:ruby
- props => path:
- href = "?file=#{path}"
-
%Name(icon=icon)
- %Link(href=href)= File.basename(path)
+ %Link{href: File.join("/demos/tree", $path)}
+ = File.basename($path)
diff --git a/example/app/pages/demos/tree/Name.haml b/example/app/pages/demos/tree/Name.haml
index 3b411019..680a9f0a 100644
--- a/example/app/pages/demos/tree/Name.haml
+++ b/example/app/pages/demos/tree/Name.haml
@@ -1,5 +1,5 @@
:ruby
- Icon = import("/app/components/UI/Icon")
+ Icon = import("/components/UI/Icon")
:css
label {
display: inline-flex;
@@ -9,7 +9,7 @@
gap: .5em;
}
:ruby
- props => { icon: }
+ $* => { icon:, **props }
color = $color || "var(--dark)"
%label{**props}
%Icon(name=icon color=color)
diff --git a/example/app/pages/demos/tree/layout.haml b/example/app/pages/demos/tree/layout.haml
new file mode 100644
index 00000000..08fd9d84
--- /dev/null
+++ b/example/app/pages/demos/tree/layout.haml
@@ -0,0 +1,72 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Card = import("/components/UI/Card")
+ FileContents = import("./FileContents")
+ Directory = import("./Directory")
+
+ def initialize
+ @selected_path = nil
+ end
+
+- root = File.expand_path(".")
+
+%article
+ %header
+ %Heading(level=2) App tree
+
+ %p
+ This page shows the file structure of the example app.
+ %a(href="https://github.com/mayu-live/framework/tree/main/example/app")<
+ .grid
+ .column
+ .scroll.tree
+ %Directory(initial_open root=root){path: "app"}
+ .column
+ .scroll
+ %slot
+ %p Please choose a file
+
+:css
+ article {
+ display: flex;
+ flex-direction: column;
+ container-type: inline-size;
+ min-height: 100%;
+ }
+
+ .grid {
+ flex: 1;
+ display: grid;
+ gap: 1em;
+ }
+
+ .tree {
+ padding: 2em;
+ background: var(--material-color-blue-gray-50);
+ box-shadow: 0 0 1em var(--material-color-blue-gray-200);
+ border-radius: 2px;
+ list-style-type: none;
+ font-size: .8em;
+ user-select: none;
+ overflow: auto;
+ }
+
+ .column {
+ position: relative;
+ }
+
+ .scroll {
+ position: absolute;
+ inset: 0;
+ overflow-y: auto;
+ }
+
+ @container (width > 50em) {
+ .grid {
+ grid-template-columns: 16em auto;
+ }
+
+ .column {
+ min-height: 10em;
+ }
+ }
diff --git a/example/app/pages/demos/tree/page.haml b/example/app/pages/demos/tree/page.haml
index e65e76db..e03e2df7 100644
--- a/example/app/pages/demos/tree/page.haml
+++ b/example/app/pages/demos/tree/page.haml
@@ -1,70 +1 @@
-:ruby
- Heading = import("/app/components/Layout/Heading")
- Card = import("/app/components/UI/Card")
- FileContents = import("./FileContents")
- Directory = import("./Directory")
-
- def self.get_initial_state(**props) = {
- selected_path: nil
- }
-
-:css
- article {
- display: flex;
- flex-direction: column;
- container-type: inline-size;
- }
-
- .grid {
- flex: 1;
- display: grid;
- gap: 1em;
- }
-
- .tree {
- padding: 2em;
- background: var(--material-color-blue-gray-50);
- box-shadow: 0 0 1em var(--material-color-blue-gray-200);
- border-radius: 2px;
- list-style-type: none;
- font-size: .8em;
- user-select: none;
- overflow: auto;
- }
-
- .column {
- position: relative;
- }
-
- .scroll {
- position: absolute;
- inset: 0;
- overflow-y: auto;
- }
-
- @container (width > 50em) {
- .grid {
- grid-template-columns: 16em auto;
- }
-
- .column {
- min-height: 10em;
- }
- }
-
-- root = File.expand_path(".")
-
-%article
- .header
- %Heading(level=2) App tree
-
- %p
- This page shows the file structure of the example app.
- %a(href="https://github.com/mayu-live/framework/tree/main/example/app")<
- .grid
- .column
- .scroll.tree
- %Directory(initial_open root=root){path: "app"}
- .column
- .scroll
- %FileContents(root=root){path: props.dig(:request, :query, :file)}
+%p Please choose a file
diff --git a/example/app/pages/demos/view-transitions/page.haml b/example/app/pages/demos/view-transitions/page.haml
new file mode 100644
index 00000000..62f55c7f
--- /dev/null
+++ b/example/app/pages/demos/view-transitions/page.haml
@@ -0,0 +1,30 @@
+:ruby
+ Heading = import("/components/Layout/Heading")
+ Highlight = import("/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Details = import("/components/UI/Details")
+ Button = import("/components/Form/Button")
+
+ FONTS = %w[Arial Verdana Tahoma Times\ New\ Roman Georgia Courier\ New Impact]
+ COLORS = %w[#000 #f0f #09c #fc0]
+
+ def initialize
+ @count = 0
+ end
+
+ def handle_click
+ view_transition do
+ @count += 1
+ end
+ end
+
+%article
+ %Heading(level=2) View transitions
+
+ %button(onclick=handle_click) Increment
+
+ %pre{
+ style: {
+ font_family: FONTS[@count % FONTS.size],
+ color: COLORS[@count % COLORS.size], font_size: 5.em
+ }}= @count
diff --git a/example/app/pages/docs/404.haml b/example/app/pages/docs/404.haml
index f5b27670..52e3d499 100644
--- a/example/app/pages/docs/404.haml
+++ b/example/app/pages/docs/404.haml
@@ -1,5 +1,5 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
+ Heading = import("/components/Layout/Heading")
%article
%Heading(level=2) This page does not exist!
diff --git a/example/app/pages/docs/CurrentFlyRegionLink.haml b/example/app/pages/docs/CurrentFlyRegionLink.haml
index d88494ed..049fbba8 100644
--- a/example/app/pages/docs/CurrentFlyRegionLink.haml
+++ b/example/app/pages/docs/CurrentFlyRegionLink.haml
@@ -1,5 +1,5 @@
:ruby
- Link = import("/app/components/UI/Link")
+ Link = import("/components/UI/Link")
FLY_REGIONS = {
"ams" => "Amsterdam, Netherlands",
diff --git a/example/app/pages/docs/Markdown.haml b/example/app/pages/docs/Markdown.haml
index c27eb8d1..b08010be 100644
--- a/example/app/pages/docs/Markdown.haml
+++ b/example/app/pages/docs/Markdown.haml
@@ -1,6 +1,6 @@
:ruby
- Markdown = import("/app/components/Markdown")
- Heading = import("/app/components/Layout/Heading")
- Link = import("/app/components/UI/Link")
+ Markdown = import("/components/Markdown")
+ Heading = import("/components/Layout/Heading")
+ Link = import("/components/UI/Link")
%Markdown{elems: { header: Heading, a: Link }}
%slot
diff --git a/example/app/pages/docs/components/page.haml b/example/app/pages/docs/components/page.haml
index 1685d9cb..f23dc43b 100644
--- a/example/app/pages/docs/components/page.haml
+++ b/example/app/pages/docs/components/page.haml
@@ -1,5 +1,9 @@
:ruby
Markdown = import("../Markdown")
+
+%head
+ %title Components
+
%article
%Markdown
:plain
diff --git a/example/app/pages/docs/concepts/page.haml b/example/app/pages/docs/concepts/page.haml
index 9e1560d7..7a0769f0 100644
--- a/example/app/pages/docs/concepts/page.haml
+++ b/example/app/pages/docs/concepts/page.haml
@@ -1,32 +1,20 @@
:ruby
- Card = import("/app/components/UI/Card")
- Image = import("/app/components/UI/Image")
- Link = import("/app/components/UI/Link")
- YouTubeVideo = import("/app/components/UI/YouTubeVideo")
+ Card = import("/components/UI/Card")
+ Image = import("/components/UI/Image")
+ Link = import("/components/UI/Link")
+ YouTubeVideo = import("/components/UI/YouTubeVideo")
Markdown = import("../Markdown")
- NoCache = image("./no-cache-fs8.png")
- DiskCache = image("./disk-cache-fs8.png")
- MemoryCache = image("./memory-cache-fs8.png")
- Metrics = image("./metrics-fs8.png")
- HotReload = image("./hot-reload-fs8.png")
- GlobalScale = image("./global-scale-fs8.png")
- Haml = image("./haml-fs8.png")
-:css
- Card {
- margin: 2em;
- }
-
- figure {
- margin: 0;
- }
+ NoCache = import("./no-cache-fs8.png")
+ DiskCache = import("./disk-cache-fs8.png")
+ MemoryCache = import("./memory-cache-fs8.png")
+ Metrics = import("./metrics-fs8.png")
+ HotReload = import("./hot-reload-fs8.png")
+ GlobalScale = import("./global-scale-fs8.png")
+ Haml = import("./haml-fs8.png")
- figcaption {
- margin: .5em;
- }
+%head
+ %title Concepts
- Image {
- display: block;
- }
%article
%Markdown
:plain
@@ -60,7 +48,7 @@
With Mayu, you would just increase the number of instances
in those regions. New instances start within seconds.
- %Image(lazy image=GlobalScale)
+ %Image(lazy image=GlobalScale alt="A map of the world")
%Markdown
:plain
@@ -105,15 +93,15 @@
%Card
%figure
- %Image(lazy image=NoCache)
+ %Image(lazy image=NoCache alt="")
%figcaption First full page load, no cache.
%Card
%figure
- %Image(lazy image=DiskCache)
+ %Image(lazy image=DiskCache alt="")
%figcaption Second full page load, disk cache.
%Card
%figure
- %Image(lazy image=MemoryCache)
+ %Image(lazy image=MemoryCache alt="")
%figcaption Third full page load, utilizing memory cache.
%Markdown
@@ -125,10 +113,10 @@
%Card
%figure
- %Image(lazy image=Metrics)
+ %Image(lazy image=Metrics alt="")
%figcaption
Screenshot from
- %Link(href="https://grafana.com/" target="_blanImagek")<> Grafana
+ %Link(href="https://grafana.com/" target="_blank")<> Grafana
showing some built-in metrics.
%Markdown
@@ -144,7 +132,7 @@
%Card
%figure
- %Image(lazy image=Haml)
+ %Image(lazy image=Haml alt="")
%figcaption Haml is the markup language that powers Mayu.
%Markdown
@@ -153,12 +141,11 @@
Mayu was designed with hot reloading in mind from the start.
This is a popular feature in many JavaScript build tools like
[Webpack](https://webpack.js.org/) and [Vite](https://vitejs.dev/),
- and it makes web development really fun because of the fast
- feedback loop.
+ and it makes web development fun because of the fast feedback loop.
%Card
%figure
- %Image(lazy image=HotReload)
+ %Image(lazy image=HotReload alt="")
%figcaption Hot reloading makes development super fast.
%Card
@@ -170,24 +157,21 @@
:plain
## Built for the future
- Mayu is using some new browser features and is not designed to
- work with current browsers, but for the browsers of next year.
+ Mayu uses the latest browser features and does not support older browsers.
- [DecompressionStream](https://developer.mozilla.org/en-US/docs/Web/API/DecompressionStream)
- is used to compress the event stream. Not supported everywhere,
- although [polyfilled](https://github.com/mayu-live/framework/blob/main/lib/mayu/client/src/DecompressionStreamPolyfill.ts)
- using the very small [fflate](https://github.com/101arrowz/fflate) library.
+:css
+ Card {
+ margin: 2em;
+ }
- This website uses the [`has()`](https://developer.mozilla.org/en-US/docs/Web/CSS/:has)-selector,
- which can be used to do things you would normally have to use JavaScript for.
- [Check out this YouTube video](https://www.youtube.com/watch?v=OGJvhpoE8b4 "I never thought this would be possible with CSS")
- where [Kevin Powell](https://www.youtube.com/kepowob) shows how to make
- some really cool validations with only HTML and CSS.
+ figure {
+ margin: 0;
+ }
- The [Navigation API](https://developer.chrome.com/docs/web-platform/navigation-api/)
- will be very useful once supported everywhere.
- [There is an issue on GitHub](https://github.com/mayu-live/framework/issues/12).
+ figcaption {
+ margin: .5em;
+ }
- [CSS Module Scripts](https://web.dev/css-module-scripts/)
- should probably be used for loading CSS as soon as
- [Firefox ships support for Import Assertions](https://bugzilla.mozilla.org/show_bug.cgi?id=1777526)
+ Image {
+ display: block;
+ }
diff --git a/example/app/pages/docs/data-fetching/Pokemon.haml b/example/app/pages/docs/data-fetching/Pokemon.haml
index b9f51a40..74974619 100644
--- a/example/app/pages/docs/data-fetching/Pokemon.haml
+++ b/example/app/pages/docs/data-fetching/Pokemon.haml
@@ -1,22 +1,21 @@
:ruby
- def self.get_initial_state(**) = {
- result: nil,
- error: nil,
- }
+ def initialize
+ @result = nil
+ @error = nil
+ end
def mount
- result = helpers
- .fetch("https://pokeapi.co/api/v2/pokemon/#{$id}")
+ @result =
+ fetch("https://pokeapi.co/api/v2/pokemon/#{$id}")
.json(symbolize_names: true)
- update(result:)
rescue => e
- update(error: e.message)
+ @error = e.message
end
%div
- = if error = state[:error]
- %p.error= error
- = elsif result = state[:result]
- %pre= JSON.pretty_generate(result)
+ = if @error
+ %p.error= @error
+ = elsif @result
+ %pre= SyntaxTree.format(@result.inspect)
= else
%p Loading Pokémon with id #{$id}…
diff --git a/example/app/pages/docs/data-fetching/page.haml b/example/app/pages/docs/data-fetching/page.haml
index c21b3133..40781f2c 100644
--- a/example/app/pages/docs/data-fetching/page.haml
+++ b/example/app/pages/docs/data-fetching/page.haml
@@ -1,14 +1,12 @@
:ruby
- Details = import("/app/components/UI/Details")
- Highlight = import("/app/components/UI/Highlight")
+ Details = import("/components/UI/Details")
+ Highlight = import("/components/UI/Highlight")
Markdown = import("../Markdown")
Pokemon = import("./Pokemon")
-:css
- .grid { display: grid; }
- .scroll {
- overflow: auto;
- max-height: 30em;
- }
+
+%head
+ %title Data fetching
+
%article
%Markdown
:plain
@@ -34,3 +32,10 @@
.grid
.scroll
%Pokemon(id=123)
+
+:css
+ .grid { display: grid; }
+ .scroll {
+ overflow: auto;
+ max-height: 30em;
+ }
diff --git a/example/app/pages/docs/deployment/page.haml b/example/app/pages/docs/deployment/page.haml
index 0a5e9e2d..8c22eaaa 100644
--- a/example/app/pages/docs/deployment/page.haml
+++ b/example/app/pages/docs/deployment/page.haml
@@ -1,5 +1,5 @@
:ruby
- YouTubeVideo = import("/app/components/UI/YouTubeVideo")
+ YouTubeVideo = import("/components/UI/YouTubeVideo")
Markdown = import("../Markdown")
CurrentFlyRegionLink = import("../CurrentFlyRegionLink")
%article
diff --git a/example/app/pages/docs/faq/page.haml b/example/app/pages/docs/faq/page.haml
index 9e73f16f..3c70f634 100644
--- a/example/app/pages/docs/faq/page.haml
+++ b/example/app/pages/docs/faq/page.haml
@@ -1,18 +1,14 @@
:ruby
- Heading = import("/app/components/Layout/Heading")
- Details = import("/app/components/UI/Details")
- Link = import("/app/components/UI/Link")
+ Heading = import("/components/Layout/Heading")
+ Details = import("/components/UI/Details")
+ Link = import("/components/UI/Link")
-:css
- Details {
- margin: 2em 1em;
- }
%article
%Heading(level=2) Frequently asked questions
%p Here are some questions people have asked.
- %Details.details(summary="Will not latency be a problem?")
+ %Details(summary="Will not latency be a problem?")
%p
It depends. For a regular web page, users would probably not
notice any more latency than with any other framework, but
@@ -37,7 +33,7 @@
%Link(href="https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements")< Custom elements
, but no work has been done on this yet.
- %Details.details(summary="What happens to sessions during deploy?")
+ %Details(summary="What happens to sessions during deploy?")
%p
Clients will be transferred automatically to a new server.
%p
@@ -48,7 +44,7 @@
server which will decrypt and verify the data before deserializing
and resuming the session.
- %Details.details(summary="What if the user loses their connection?")
+ %Details(summary="What if the user loses their connection?")
%p
Usually they will just reconnect and the session will be resumed.
I don't know how this works if an app is deployed to different regions.
@@ -61,3 +57,8 @@
could remove the need to reload the page by serializing the current
browser DOM and deserializing it on the server and then use that as
a reference for patching.
+
+:css
+ Details {
+ margin: 2em 1em;
+ }
diff --git a/example/app/pages/docs/haml-transform/page.haml b/example/app/pages/docs/haml-transform/page.haml
index cb07f16b..3cd1ed28 100644
--- a/example/app/pages/docs/haml-transform/page.haml
+++ b/example/app/pages/docs/haml-transform/page.haml
@@ -1,6 +1,6 @@
:ruby
- Details = import("/app/components/UI/Details")
- Highlight = import("/app/components/UI/Highlight")
+ Details = import("/components/UI/Details")
+ Highlight = import("/components/UI/Highlight")
EXAMPLES_ROOT =
File.join(
diff --git a/example/app/pages/docs/images/page.haml b/example/app/pages/docs/images/page.haml
index 2f4efb74..36df8b58 100644
--- a/example/app/pages/docs/images/page.haml
+++ b/example/app/pages/docs/images/page.haml
@@ -5,12 +5,11 @@
:plain
# Images
- To load an image, you use the `image()`-method which
- validates that the resource is an image.
+ To load an image, you just use `import()`.
```haml
:ruby
- Image = image("./río_amazonas.jpeg")
+ Image = import("./río_amazonas.jpeg")
%img{src: Image.src}
```
@@ -18,7 +17,7 @@
```haml
:ruby
- Image = image("./río_amazonas.jpeg")
+ Image = import("./río_amazonas.jpeg")
%img{
src: Image.src,
sizes: Image.sizes,
@@ -40,9 +39,6 @@
## SVGs
- Scalable Vector Graphics are fantastic,
- and they are really easy to use.
-
- You can load them using `svg()`.
+ Scalable Vector Graphics are fantastic, and they are really easy to use.
[Source code for the Icon component on this site](https://github.com/mayu-live/framework/blob/main/example/app/components/UI/Icon/Icon.haml)
diff --git a/example/app/pages/docs/layout.haml b/example/app/pages/docs/layout.haml
index ed3fe0f2..9802f68d 100644
--- a/example/app/pages/docs/layout.haml
+++ b/example/app/pages/docs/layout.haml
@@ -1,11 +1,11 @@
:ruby
- PageLayout = import("/app/components/Layout/FullWidthPageWithMenu")
- Heading = import("/app/components/Layout/Heading")
- Menu = import("/app/components/Layout/Menu")
- MenuItem = import("/app/components/Layout/MenuItem")
+ PageLayout = import("/components/Layout/FullWidthPageWithMenu")
+ Heading = import("/components/Layout/Heading")
+ Menu = import("/components/Layout/Menu")
+ MenuItem = import("/components/Layout/MenuItem")
Details = import("./Details")
- Breadcrumbs = import("/app/components/UI/Breadcrumbs")
- UnderConstruction = import("/app/components/UnderConstruction")
+ Breadcrumbs = import("/components/UI/Breadcrumbs")
+ UnderConstruction = import("/components/UnderConstruction")
LINKS = {
"/docs" => "Documentation",
@@ -35,9 +35,7 @@
private
def breadcrumb_links
- props => { request: { path: } }
-
- splat = split_path(path)
+ splat = split_path($path)
LINKS.select {
s = split_path(_1)
@@ -49,12 +47,9 @@
path.split("/").reject(&:empty?)
end
-:ruby
- props => request: { path: }
%PageLayout
- #page
- %UnderConstruction(slot="after_heading" path=path)
- %slot
+ %UnderConstruction(slot="after_heading" path=$path)
+ %slot
%Breadcrumbs(slot="breadcrumbs" links=breadcrumb_links)
diff --git a/example/app/pages/docs/lifecycle-methods/Example.haml b/example/app/pages/docs/lifecycle-methods/Example.haml
index 62b97b2a..e24ab4a1 100644
--- a/example/app/pages/docs/lifecycle-methods/Example.haml
+++ b/example/app/pages/docs/lifecycle-methods/Example.haml
@@ -1,16 +1,13 @@
:ruby
- def self.get_initial_state(**) = {
- count: 0
- }
+ def initialize
+ @count = 0
+ end
def mount
loop do
sleep 1
-
- update do |state|
- { count: state[:count] + 1 }
- end
+ @count += 1
end
end
-%span Count: #{state[:count]}
+%span Count: #{@count}
diff --git a/example/app/pages/docs/lifecycle-methods/page.haml b/example/app/pages/docs/lifecycle-methods/page.haml
index 80c0fe15..1b643112 100644
--- a/example/app/pages/docs/lifecycle-methods/page.haml
+++ b/example/app/pages/docs/lifecycle-methods/page.haml
@@ -1,8 +1,12 @@
:ruby
- Card = import("/app/components/UI/Card")
- Highlight = import("/app/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Highlight = import("/components/UI/Highlight")
Markdown = import("../Markdown")
Example = import("./Example")
+
+%head
+ %title Lifecycle methods
+
%article
%Markdown
:plain
@@ -35,11 +39,11 @@
:ruby
def mount
loop do
- update(time: Time.now.to_s)
+ @time = Time.now.to_s
sleep 1
end
end
- %p= state[:time]
+ %p= @time
```
This component will update the current time every second.
diff --git a/example/app/pages/docs/not_found.haml b/example/app/pages/docs/not_found.haml
new file mode 100644
index 00000000..da794449
--- /dev/null
+++ b/example/app/pages/docs/not_found.haml
@@ -0,0 +1,2 @@
+%article
+ Documentation page not found
diff --git a/example/app/pages/docs/page.haml b/example/app/pages/docs/page.haml
index 3173a5ca..c440c5a5 100644
--- a/example/app/pages/docs/page.haml
+++ b/example/app/pages/docs/page.haml
@@ -2,6 +2,9 @@
Markdown = import("./Markdown")
CurrentFlyRegionLink = import("./CurrentFlyRegionLink")
+%head
+ %title Documentation
+
%article
%Markdown
:plain
diff --git a/example/app/pages/docs/reusing-components/page.haml b/example/app/pages/docs/reusing-components/page.haml
index 7b889128..cbf81cae 100644
--- a/example/app/pages/docs/reusing-components/page.haml
+++ b/example/app/pages/docs/reusing-components/page.haml
@@ -32,7 +32,7 @@
```haml
:ruby
- Button = import("/app/components/Button")
+ Button = import("/components/Button")
%Button Click here!
```
@@ -62,7 +62,7 @@
```haml
:ruby
- Wrapper = import("/app/components/Wrapper")
+ Wrapper = import("/components/Wrapper")
%Wrapper(title="Hello world")
%h2 Hello world
%p Hello to the world and welcome to my webpage.
diff --git a/example/app/pages/docs/state/Example.haml b/example/app/pages/docs/state/Example.haml
index 76c605b7..f19cfb51 100644
--- a/example/app/pages/docs/state/Example.haml
+++ b/example/app/pages/docs/state/Example.haml
@@ -1,26 +1,22 @@
:ruby
- def self.get_initial_state(initial_count: 0, **) = {
- count: initial_count
- }
+ def initialize
+ @count = $initial_count || 0
+ end
- def handle_reset(_event)
- update(count: 0)
+ def handle_reset
+ @count = 0
end
def handle_increment(_event)
- update do |count:|
- { count: count + 1 }
- end
+ @count += 1
end
def handle_decrement(_event)
- update do |count:|
- { count: count - 1 }
- end
+ @count -= 1
end
%div
- %p Count: #{state[:count]}
+ %p Count: #{@count}
%button(onclick=handle_decrement) Decrement
%button(onclick=handle_reset) Reset
%button(onclick=handle_increment) Increment
diff --git a/example/app/pages/docs/state/page.haml b/example/app/pages/docs/state/page.haml
index 9dc8349b..f856d5e0 100644
--- a/example/app/pages/docs/state/page.haml
+++ b/example/app/pages/docs/state/page.haml
@@ -1,6 +1,6 @@
:ruby
- Card = import("/app/components/UI/Card")
- Highlight = import("/app/components/UI/Highlight")
+ Card = import("/components/UI/Card")
+ Highlight = import("/components/UI/Highlight")
Markdown = import("../Markdown")
Example = import("./Example")
@@ -9,52 +9,20 @@
:plain
# State
- Set up state with `self.get_initial_state`,
- read state with `state`,
- and update state with `update`,
+ Mayu uses instance variables for state.
+ Any time you update an instance variable inside a component,
+ the component will re-render.
+
+ Set up state in `def initialize`.
%Highlight(language="haml")
= File.read("app/pages/docs/state/Example.haml")
%p This will result in the following component:
- %Card.card
+ %Card
%Example
- %Markdown
- :plain
- # Different ways to update
-
- If you don't need to read state when updating, you can do this:
-
- ```ruby
- update(count: 0)
- ```
-
- If you need to read state, you can get the entire state object:
-
- ```ruby
- update do |state|
- { count: state[:count] + 1 }
- end
- ```
-
- Or you could extract just the keys you are interested in:
-
- ```ruby
- update do |count:|
- { count: count + 1 }
- end
- ```
-
- You can also use default values:
-
- ```ruby
- update do |count: 0|
- { count: count + 1 }
- end
- ```
-
:css
Card {
margin: 1em;
diff --git a/example/app/pages/docs/stylesheets/page.haml b/example/app/pages/docs/stylesheets/page.haml
index 0163caf8..a9410867 100644
--- a/example/app/pages/docs/stylesheets/page.haml
+++ b/example/app/pages/docs/stylesheets/page.haml
@@ -60,7 +60,7 @@
```haml
:ruby
- Button = import("/app/components/Button")
+ Button = import("/components/Button")
%article
%Button Click me
:css
diff --git a/example/app/pages/docs/syntax/page.haml b/example/app/pages/docs/syntax/page.haml
index 34421812..635738f4 100644
--- a/example/app/pages/docs/syntax/page.haml
+++ b/example/app/pages/docs/syntax/page.haml
@@ -1,5 +1,5 @@
:ruby
- Highlight = import("/app/components/UI/Highlight")
+ Highlight = import("/components/UI/Highlight")
Markdown = import("../Markdown")
%article
%Markdown
@@ -8,9 +8,7 @@
## Props
- To read props passed to a component, you can use `props[:foobar]`
- as well as `$foobar`. The latter will transform into the former,
- for convenience.
+ To read props passed to a component, you can use `$foobar`.
## Self / recursion
@@ -78,8 +76,8 @@
```haml
%ul
- = todos.map do |todo|
- %li[todo.id]= todo.text
+ = todos.map do |todo|
+ %li[todo.id]= todo.text
```
## Pattern matching
@@ -98,21 +96,18 @@
```haml
:ruby
- def self.get_initial_state(initial_count: 0, **) = {
- count: initial_count
- }
+ def initialize =
+ @count = $initial_count || 0
def handle_click(event) =
case event
in { target: { name: "increment" } }
- update { |state| { count: state[:count] + 1 } }
+ @count += 1
in { target: { name: "decrement" } }
- update { |state| { count: state[:count] - 1 } }
+ @count -= 1
end
- - state => count:
- %div
- %output= count
- %button(onclick=handle_click name="increment")
- %button(onclick=handle_click name="decrement")
+ %output= @count
+ %button(onclick=handle_click name="increment")
+ %button(onclick=handle_click name="decrement")
```
## Early returns
@@ -123,13 +118,13 @@
```haml
:ruby
- def self.get_initial_state(**) = {
- clicked: false
- }
+ def initialize
+ @clicked = false
+ end
def handle_click(event)
- update(clicked: true)
+ @clicked = true
end
- = return if state[:clicked]
+ = return if @clicked
%p You clicked the button.
%button(onclick=handle_click) Click me
```
diff --git a/example/app/pages/layout.haml b/example/app/pages/layout.haml
index 00f581d0..ba2e3b6b 100644
--- a/example/app/pages/layout.haml
+++ b/example/app/pages/layout.haml
@@ -2,22 +2,12 @@
Header = import('../components/Layout/Header')
Footer = import('../components/Layout/Footer')
-.grid
- %Header
- %main
- %slot
- %Footer
+%Header
+%main
+ %slot
+%Footer
:css
- .grid {
- height: 100%;
- min-height: 100svh;
- display: grid;
- grid-template-rows: auto 1fr auto;
- grid-template-columns: 1fr;
- z-index: 0;
- }
-
main {
grid-column: 1;
diff --git a/example/app/pages/not_found.haml b/example/app/pages/not_found.haml
new file mode 100644
index 00000000..ecc2549b
--- /dev/null
+++ b/example/app/pages/not_found.haml
@@ -0,0 +1 @@
+%p Page not found
diff --git a/example/app/root.css b/example/app/root.css
index 31dbfce5..ecab1f40 100644
--- a/example/app/root.css
+++ b/example/app/root.css
@@ -369,3 +369,7 @@ body {
:is(pre, code) {
font-family: var(--font-mono), monospace;
}
+
+::view-transition-group(root) {
+ animation-duration: 200ms;
+}
diff --git a/example/app/root.haml b/example/app/root.haml
index 0c83450e..2d6daee0 100644
--- a/example/app/root.haml
+++ b/example/app/root.haml
@@ -7,14 +7,23 @@
font.tr(" ", "+").prepend("family=")
end.join("&")
-%html
- %head
- %meta(name="charset" value="utf-8")
- %meta(name="generator" value="https://github.com/mayu-live/framework/releases/tag/v#{Mayu::VERSION}")
- %meta(name="viewport" content="width=device-width, initial-scale=1")
- %link(crossorigin=true href="https://fonts.googleapis.com" rel="preconnect")
- %link(href="https://fonts.googleapis.com/css2?#{FONTS}&display=swap" rel="stylesheet" referrerpolicy="no-referrer")
- %title
- Mayu Live
- %body
- %slot
+%head
+ %meta(name="charset" value="utf-8")
+ %meta(name="generator" value="https://github.com/mayu-live/framework/releases/tag/v#{Mayu::VERSION}")
+ %meta(name="viewport" content="width=device-width, initial-scale=1")
+ %link(crossorigin=true href="https://fonts.googleapis.com" rel="preconnect")
+ %link(href="https://fonts.googleapis.com/css2?#{FONTS}&display=swap" rel="stylesheet" referrerpolicy="no-referrer")
+ %title
+ Mayu Live
+%body
+ %slot
+
+:css
+ body {
+ height: 100%;
+ min-height: 100svh;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ grid-template-columns: 1fr;
+ z-index: 0;
+ }
diff --git a/example/bin/mayu b/example/bin/mayu
index 84aff037..b087f3a4 100755
--- a/example/bin/mayu
+++ b/example/bin/mayu
@@ -4,6 +4,4 @@
require "rubygems"
require "bundler/setup"
-require_relative "../app/lib/ollama"
-
load Gem.bin_path("mayu-live", "mayu")
diff --git a/example/mayu.toml b/example/mayu.toml
index 4d952287..4d47294b 100644
--- a/example/mayu.toml
+++ b/example/mayu.toml
@@ -1,57 +1,41 @@
-[dev]
+[development]
secret_key = "dev"
- [dev.server]
- scheme = "https"
- host = "localhost"
- port = 9292
+ [development.server]
+ listen = "https://localhost:9292"
- count = 1
- hot_swap = true
+ hmr = true
render_exceptions = true
self_signed_cert = true
generate_assets = true
- [dev.metrics]
- enabled = true
-
-[devbundle]
- secret_key = "dev"
- use_bundle = true
+ session_timeout_seconds = 15
+ transfer_timeout_seconds = 10
+ cookie_timeout_seconds = 60
- [devbundle.server]
- scheme = "https"
- host = "localhost"
- port = 9292
- render_exceptions = true
- self_signed_cert = true
-
- hot_swap = false
+ [development.metrics]
+ enabled = true
+ listen = "http://localhost:9091"
- count = 4
- forks = 2
+[production]
+ secret_key = "$MAYU_SECRET_KEY"
- [devbundle.metrics]
- enabled = true
- port = 9091
- host = "0.0.0.0"
+ [production.server]
+ listen = "https://0.0.0.0:3333"
-[prod]
- use_bundle = true
+ hmr = false
- [prod.server]
- scheme = "http"
- host = "0.0.0.0"
- port = 3000
+ render_exceptions = false
+ self_signed_cert = true
- hot_swap = false
+ generate_assets = false
- count = 2
- forks = 1
+ session_timeout_seconds = 15
+ transfer_timeout_seconds = 10
+ cookie_timeout_seconds = 60
- [prod.metrics]
+ [production.metrics]
enabled = true
- port = 9091
- host = "0.0.0.0"
+ listen = "http://0.0.0.0:9091"
diff --git a/exe/mayu b/exe/mayu
index f0423070..d82dbd49 100755
--- a/exe/mayu
+++ b/exe/mayu
@@ -1,30 +1,9 @@
#!/usr/bin/env ruby
-require "sorbet-runtime"
-
MAYU_ROOT = File.join(File.dirname(__FILE__), "..")
$LOAD_PATH.unshift(File.join(MAYU_ROOT, "..", "..", "lib"))
-if ARGV.include?("--disable-sorbet")
- puts "\e[1mDisabling sorbet\e[0m"
- require "mayu/disable_sorbet"
- Mayu::DisableSorbet.disable_sorbet!
-else
- puts "\e[2mDisable sorbet with --disable-sorbet\e[0m"
-end
-
-if RubyVM.const_defined?(:YJIT)
- if RubyVM::YJIT.enabled?
- puts "\e[1mYJIT is enabled!\e[0m"
- else
- puts "\e[2mYJIT is disabled!\e[0m"
- end
-else
- puts "\e[2mYJIT is not supported!\e[0m"
-end
-
require "mayu/version"
-require "mayu/banner"
require "mayu/commands"
Mayu::Commands.call(ARGV)
diff --git a/lib/mayu.rb b/lib/mayu.rb
index d1b7a5c1..d6d7eb31 100644
--- a/lib/mayu.rb
+++ b/lib/mayu.rb
@@ -1,8 +1,9 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
-require "sorbet-runtime"
require_relative "mayu/version"
-require_relative "mayu/banner"
module Mayu
end
diff --git a/lib/mayu/__test__/configuration/test.toml b/lib/mayu/__test__/configuration/test.toml
new file mode 100644
index 00000000..c599a0d5
--- /dev/null
+++ b/lib/mayu/__test__/configuration/test.toml
@@ -0,0 +1,39 @@
+[development]
+ secret_key = "dev"
+
+ [development.server]
+ listen = "https://localhost:9292"
+
+ hmr = true
+
+ render_exceptions = true
+ self_signed_cert = true
+
+ generate_assets = true
+ session_timeout_seconds = 11
+ transfer_timeout_seconds = 12
+ cookie_timeout_seconds = 13
+
+ [development.metrics]
+ enabled = true
+ listen = "http://localhost:9293"
+
+[production]
+ secret_key = "$SECRET_KEY"
+
+ [production.server]
+ listen = "http://localhost:3000"
+
+ hmr = false
+
+ render_exceptions = false
+ self_signed_cert = false
+
+ generate_assets = false
+ session_timeout_seconds = 21
+ transfer_timeout_seconds = 22
+ cookie_timeout_seconds = 23
+
+ [production.metrics]
+ enabled = true
+ listen = "http://localhost:9091"
diff --git a/lib/mayu/__test__/routes/layout.haml b/lib/mayu/__test__/routes/layout.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/not_found.haml b/lib/mayu/__test__/routes/not_found.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/page.haml b/lib/mayu/__test__/routes/page.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/params/:id/page.haml b/lib/mayu/__test__/routes/params/:id/page.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/subpage/page.haml b/lib/mayu/__test__/routes/subpage/page.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/subpage2/hello/page.haml b/lib/mayu/__test__/routes/subpage2/hello/page.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/subpage2/layout.haml b/lib/mayu/__test__/routes/subpage2/layout.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/subpage2/not_found.haml b/lib/mayu/__test__/routes/subpage2/not_found.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/__test__/routes/subpage2/page.haml b/lib/mayu/__test__/routes/subpage2/page.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/mayu/app_metrics.rb b/lib/mayu/app_metrics.rb
deleted file mode 100644
index 1ae3de4f..00000000
--- a/lib/mayu/app_metrics.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-# typed: strict
-
-module Mayu
- class AppMetrics < T::Struct
- extend T::Sig
-
- const :error_count, Prometheus::Client::Counter
- const :session_init_count, Prometheus::Client::Counter
- const :session_timeout_count, Prometheus::Client::Counter
- const :session_ping_count, Prometheus::Client::Counter
- const :session_callback_count, Prometheus::Client::Counter
- const :session_navigate_count, Prometheus::Client::Counter
- const :session_count, Prometheus::Client::Gauge
- const :vnode_patch_times, Prometheus::Client::Summary
-
- sig do
- params(
- registry: Prometheus::Client::Registry,
- preset_labels: String
- ).returns(T.attached_class)
- end
- def self.setup(registry, **preset_labels)
- store_settings =
- if Prometheus::Client.config.data_store.is_a?(
- Prometheus::Client::DataStores::Synchronized
- )
- {}
- else
- { aggregation: :sum }
- end
-
- new(
- session_init_count:
- registry.counter(
- :mayu_session_init_count,
- docstring: "Total number of inits",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- session_ping_count:
- registry.counter(
- :mayu_session_ping_count,
- docstring: "Total number of pings",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- session_callback_count:
- registry.counter(
- :mayu_session_callback_count,
- docstring: "Total number of callbacks",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- session_navigate_count:
- registry.counter(
- :mayu_session_navigate_count,
- docstring: "Total number of navigates",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- session_timeout_count:
- registry.counter(
- :mayu_session_timeout_count,
- docstring: "Total number of timeouts",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- error_count:
- registry.counter(
- :mayu_error_count,
- docstring: "Total number errors",
- labels: [*preset_labels.keys],
- preset_labels:
- ),
- session_count:
- registry.gauge(
- :mayu_session_count,
- docstring: "Number of active sessions",
- labels: [*preset_labels.keys],
- preset_labels:,
- store_settings:
- ),
- vnode_patch_times:
- registry.summary(
- :mayu_vnode_patch_times,
- docstring: "VNode patch times",
- labels: [:vnode_type, *preset_labels.keys],
- preset_labels:
- )
- )
- end
- end
-end
diff --git a/lib/mayu/assets.rb b/lib/mayu/assets.rb
new file mode 100644
index 00000000..a1f9680e
--- /dev/null
+++ b/lib/mayu/assets.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "mime/types"
+
+MIME::Types["application/json"].first.add_extensions(%w[map])
+
+require_relative "assets/asset"
+require_relative "assets/encoded_content"
+require_relative "assets/file_content"
+require_relative "assets/generators"
+require_relative "assets/storage"
+
+module Mayu
+ module Modules
+ module Assets
+ end
+ end
+end
diff --git a/lib/mayu/assets/asset.rb b/lib/mayu/assets/asset.rb
new file mode 100644
index 00000000..89ea9f8b
--- /dev/null
+++ b/lib/mayu/assets/asset.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ Asset = Data.define(:filename, :headers, :encoded_content)
+ end
+end
diff --git a/lib/mayu/assets/encoded_content.rb b/lib/mayu/assets/encoded_content.rb
new file mode 100644
index 00000000..20dc41a3
--- /dev/null
+++ b/lib/mayu/assets/encoded_content.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "brotli"
+
+module Mayu
+ module Assets
+ EncodedContent =
+ Data.define(:encoding, :content) do
+ def self.for_mime_type_and_content(mime_type, content) =
+ if mime_type.media_type == "text"
+ brotli(content)
+ else
+ none(content)
+ end
+
+ def self.none(content) = new(nil, content)
+
+ def self.brotli(content) = new(:br, Brotli.deflate(content))
+
+ def headers
+ encoding ? { "content-encoding": encoding.to_s } : {}
+ end
+ end
+ end
+end
diff --git a/lib/mayu/assets/file_content.rb b/lib/mayu/assets/file_content.rb
new file mode 100644
index 00000000..c680eaf0
--- /dev/null
+++ b/lib/mayu/assets/file_content.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ FileContent = Data.define
+ end
+end
diff --git a/lib/mayu/assets/generators.rb b/lib/mayu/assets/generators.rb
new file mode 100644
index 00000000..66137ce6
--- /dev/null
+++ b/lib/mayu/assets/generators.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ module Generators
+ autoload :Image, File.join(__dir__, "generators", "image")
+ autoload :Text, File.join(__dir__, "generators", "text")
+ autoload :WriteFile, File.join(__dir__, "generators", "write_file")
+ end
+ end
+end
diff --git a/lib/mayu/assets/generators/image.rb b/lib/mayu/assets/generators/image.rb
new file mode 100644
index 00000000..5de2c1ad
--- /dev/null
+++ b/lib/mayu/assets/generators/image.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ module Generators
+ Image =
+ Data.define(:filename, :source_path, :width) do
+ def process(out_dir)
+ target_path = File.join(out_dir, filename)
+
+ unless File.exist?(target_path)
+ require "rmagick"
+
+ Console.logger.info(
+ self,
+ "Generating #{target_path} from #{source_path}"
+ )
+
+ Ractor
+ .new(self, target_path) do |generator, target_path|
+ Magick::Image
+ .read(generator.source_path)
+ .first
+ .resize_to_fit!(generator.width)
+ .write(target_path) { |options| options.quality = 80 }
+ .destroy!
+ nil
+ end
+ .join
+ .value
+ end
+
+ build_asset(filename)
+ rescue => e
+ Console.logger.error(self, e)
+ raise
+ end
+
+ private
+
+ def build_asset(filename)
+ filename_without_hash = filename.sub(/\?[^?]*$/, "")
+ MIME::Types.type_for(filename_without_hash).first =>
+ MIME::Type => mime_type
+
+ headers = { "content-type": mime_type.to_s }
+
+ Assets::Asset[
+ filename:,
+ headers:,
+ encoded_content: Assets::FileContent.new
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/assets/generators/text.rb b/lib/mayu/assets/generators/text.rb
new file mode 100644
index 00000000..cba33036
--- /dev/null
+++ b/lib/mayu/assets/generators/text.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ module Generators
+ Text =
+ Data.define(:filename, :content) do
+ def process(assets_path)
+ filename_without_hash = filename.sub(/\?[^?]*$/, "")
+ MIME::Types.type_for(filename_without_hash).first =>
+ MIME::Type => mime_type
+
+ encoded_content =
+ Assets::EncodedContent.for_mime_type_and_content(
+ mime_type,
+ content
+ )
+ content_hash = Digest::SHA256.hexdigest(encoded_content.content)
+
+ headers = {
+ etag: Digest::SHA256.hexdigest(encoded_content.content),
+ "content-type": mime_type.to_s,
+ "content-length": encoded_content.content.bytesize,
+ **encoded_content.headers
+ }
+
+ Assets::Asset[filename:, headers:, encoded_content:]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/assets/generators/write_file.rb b/lib/mayu/assets/generators/write_file.rb
new file mode 100644
index 00000000..f9e44a22
--- /dev/null
+++ b/lib/mayu/assets/generators/write_file.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Assets
+ module Generators
+ WriteFile =
+ Data.define(:filename, :source_path) do
+ def process(assets_path)
+ target_path = File.join(assets_path, filename)
+
+ unless File.exist?(target_path)
+ Console.logger.info(
+ self,
+ "Copying #{target_path} from #{source_path}"
+ )
+
+ FileUtils.mkdir_p(File.dirname(target_path))
+ FileUtils.cp(source_path, target_path)
+ end
+
+ build_asset(filename)
+ end
+
+ private
+
+ def build_asset(filename)
+ filename_without_hash = filename.sub(/\?[^?]*$/, "")
+
+ MIME::Types.type_for(filename_without_hash).first =>
+ MIME::Type => mime_type
+
+ headers = {
+ etag: Digest::SHA256.file(source_path).hexdigest,
+ "content-type": mime_type.to_s,
+ "content-length": File.size(source_path)
+ }
+
+ Assets::Asset[
+ filename:,
+ headers:,
+ encoded_content: Assets::FileContent.new
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/assets/storage.rb b/lib/mayu/assets/storage.rb
new file mode 100644
index 00000000..797e2b70
--- /dev/null
+++ b/lib/mayu/assets/storage.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "mime/types"
+require "digest/sha2"
+require "async/queue"
+require "async/variable"
+require "async/semaphore"
+
+module Mayu
+ module Assets
+ class Storage
+ Static =
+ Data.define(:assets) do
+ def get(filename)
+ assets[filename]
+ end
+
+ def wait_for(filename)
+ get(filename)
+ end
+
+ def enqueue(_generator)
+ end
+ end
+
+ def initialize
+ @assets = {}
+ @results = {}
+ @queue = Async::Queue.new
+ end
+
+ def get(filename)
+ @assets[filename]
+ end
+
+ def wait_for(filename)
+ @assets.fetch(filename) do
+ (@results[filename] ||= Async::Variable.new).wait
+ end
+ end
+
+ def enqueue(generator)
+ @queue.enqueue(generator)
+ end
+
+ def all_processed?
+ @queue.empty?
+ end
+
+ def run(
+ assets_dir,
+ forever: false,
+ concurrency: 1,
+ task: Async::Task.current
+ )
+ task.async do
+ semaphore = Async::Semaphore.new(concurrency)
+
+ while forever || !@queue.empty?
+ generator = @queue.dequeue
+ semaphore.async { process(generator, assets_dir) }
+ end
+ end
+ end
+
+ def _dump(_level)
+ Marshal.dump(@assets)
+ end
+
+ def self._load(data)
+ Static[Marshal.load(data)]
+ end
+
+ private
+
+ def process(generator, assets_dir)
+ if asset = generator.process(assets_dir)
+ @assets.store(asset.filename, asset)
+ var = (@results[asset.filename] ||= Async::Variable.new)
+ var.resolve(asset) unless var.resolved?
+ @results.delete(asset.filename)
+ end
+ rescue => e
+ Console.logger.error(self, e)
+ raise e
+ end
+ end
+ end
+end
diff --git a/lib/mayu/banner.rb b/lib/mayu/banner.rb
deleted file mode 100644
index 8bedfbc9..00000000
--- a/lib/mayu/banner.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- BANNER = T.let(<<~EOF.chomp.freeze, String)
- • ▌ ▄ ·. ▄▄▄· ▄· ▄▌▄• ▄▌ ▄▄▌ ▪ ▌ ▐·▄▄▄ .
- ·██ ▐███▪▐█ ▀█ ▐█▪██▌█▪██▌ ██• ██ ▪█·█▌▀▄.▀·
- ▐█ ▌▐▌▐█·▄█▀▀█ ▐█▌▐█▪█▌▐█▌ ██▪ ▐█·▐█▐█•▐▀▀▪▄
- ██ ██▌▐█▌▐█ ▪▐▌ ▐█▀·.▐█▄█▌ ▐█▌▐▌▐█▌ ███ ▐█▄▄▌
- ▀▀ █▪▀▀▀ ▀ ▀ ▀ • ▀▀▀ .▀▀▀ ▀▀▀. ▀ ▀▀▀
- EOF
-end
diff --git a/lib/mayu/client/.dockerignore b/lib/mayu/client/.dockerignore
deleted file mode 100644
index 3c3629e6..00000000
--- a/lib/mayu/client/.dockerignore
+++ /dev/null
@@ -1 +0,0 @@
-node_modules
diff --git a/lib/mayu/client/.gitignore b/lib/mayu/client/.gitignore
index 2771441e..849ddff3 100644
--- a/lib/mayu/client/.gitignore
+++ b/lib/mayu/client/.gitignore
@@ -1,3 +1 @@
-dist
-node_modules
-stats.html
+dist/
diff --git a/lib/mayu/client/README.md b/lib/mayu/client/README.md
deleted file mode 100644
index 6334e1f6..00000000
--- a/lib/mayu/client/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# `client/`
-
-## Description
-
-This entire thing is a mess.
-Things have just been added without any sort of plan, and things
-have changed without anything being renamed.
-
-## Building
-
-Development:
-
- npm run watch
-
-Production:
-
- npm run build:production
diff --git a/lib/mayu/client/package.json b/lib/mayu/client/package.json
index 4c86560d..69abd5ef 100644
--- a/lib/mayu/client/package.json
+++ b/lib/mayu/client/package.json
@@ -2,6 +2,7 @@
"name": "@mayu-live/client",
"version": "0.0.0",
"private": true,
+ "author": "Andreas Alin ",
"license": "AGPL-3.0",
"type": "module",
"repository": {
@@ -16,24 +17,25 @@
"watch": "npm run build -- --watch",
"build": "rollup -c rollup.config.js",
"build:production": "npm run build -- --compact",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
},
"devDependencies": {
- "@msgpack/msgpack": "^2.8.0",
- "@rollup/plugin-commonjs": "^25.0.0",
- "@rollup/plugin-node-resolve": "^15.1.0",
- "@rollup/plugin-typescript": "^11.1.1",
- "@types/serviceworker": "^0.0.67",
- "esbuild": "^0.18.0",
- "fflate": "^0.8.0",
+ "@msgpack/msgpack": "^3.1.1",
+ "@rollup/plugin-commonjs": "^28.0.3",
+ "@rollup/plugin-node-resolve": "^16.0.1",
+ "@rollup/plugin-terser": "^0.4.4",
+ "@rollup/plugin-typescript": "^12.1.2",
+ "@types/serviceworker": "^0.0.126",
"html-minifier-terser": "^7.2.0",
- "rollup": "^3.24.0",
- "rollup-plugin-delete": "^2.0.0",
- "rollup-plugin-visualizer": "^5.9.0",
- "tslib": "^2.5.3",
- "typescript": "^5.1.3"
- },
- "dependencies": {
- "@rollup/plugin-terser": "^0.4.3"
+ "morphdom": "^2.7.4",
+ "msgpackr": "^1.11.2",
+ "rollup": "^4.36.0",
+ "rollup-plugin-delete": "^3.0.1",
+ "typescript": "^5.8.2",
+ "vitest": "^3.2.0",
+ "jsdom": "^26.0.0"
}
}
diff --git a/lib/mayu/client/rollup.config.js b/lib/mayu/client/rollup.config.js
index bdeeddf3..3fb04509 100644
--- a/lib/mayu/client/rollup.config.js
+++ b/lib/mayu/client/rollup.config.js
@@ -3,13 +3,14 @@ import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import terser from "@rollup/plugin-terser";
import del from "rollup-plugin-delete";
-import { visualizer } from "rollup-plugin-visualizer";
+// import { visualizer } from "rollup-plugin-visualizer";
import { minify } from "html-minifier-terser";
+import { nodeResolve } from "@rollup/plugin-node-resolve";
function entriesJSON() {
return {
name: "entriesJSON",
- generateBundle(outputOptions, bundle) {
+ generateBundle(_outputOptions, bundle) {
const data = {};
for (const chunk of Object.values(bundle)) {
@@ -61,6 +62,7 @@ export default {
plugins: [
del({ targets: "dist/*" }),
typescript(),
+ nodeResolve(),
commonjs(),
resolve(),
minifyHTML({
@@ -73,9 +75,9 @@ export default {
}),
terser(),
entriesJSON(),
- visualizer({
- gzipSize: true,
- brotliSize: true,
- }),
+ // visualizer({
+ // gzipSize: true,
+ // brotliSize: true,
+ // }),
],
};
diff --git a/lib/mayu/client/src/DecompressionStream.ts b/lib/mayu/client/src/DecompressionStream.ts
deleted file mode 100644
index 0d5341f3..00000000
--- a/lib/mayu/client/src/DecompressionStream.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import logger from "./logger";
-
-const DecompressionStreamPromise = new Promise(
- async (resolve) => {
- if (typeof DecompressionStream !== "undefined") {
- logger.success("Using standard DecompressionStream");
- return resolve(DecompressionStream);
- }
-
- logger.warn("Using DecompressionStream polyfill");
- resolve((await import("./DecompressionStreamPolyfill")).default);
- }
-);
-
-export default await DecompressionStreamPromise;
diff --git a/lib/mayu/client/src/DecompressionStreamPolyfill.ts b/lib/mayu/client/src/DecompressionStreamPolyfill.ts
deleted file mode 100644
index 6188cd03..00000000
--- a/lib/mayu/client/src/DecompressionStreamPolyfill.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { AsyncInflate } from "fflate";
-
-class DecompressionStreamPolyfill extends TransformStream<
- Uint8Array,
- Uint8Array
-> {
- constructor(_format: "deflate-raw") {
- let decompressor: AsyncInflate;
-
- super({
- start(controller) {
- decompressor = new AsyncInflate();
-
- decompressor.ondata = (err, chunk: Uint8Array, final: boolean) => {
- if (err) {
- controller.error(err);
- return;
- }
-
- if (final) {
- controller.terminate();
- } else {
- controller.enqueue(chunk);
- }
- };
- },
- transform(chunk, controller) {
- try {
- decompressor.push(chunk, false);
- } catch (e) {
- controller.error(
- new Error(`DecompressionStreamPolyfill inflation failure: ${e}`)
- );
- }
- },
- flush() {
- decompressor.push(new Uint8Array(), true);
- },
- });
- }
-}
-
-export default DecompressionStreamPolyfill as typeof DecompressionStream;
diff --git a/lib/mayu/client/src/MimeTypes.ts b/lib/mayu/client/src/MimeTypes.ts
deleted file mode 100644
index 33bcc907..00000000
--- a/lib/mayu/client/src/MimeTypes.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const enum MimeTypes {
- MAYU_SESSION = "application/vnd.mayu.session",
- MAYU_STREAM = "application/vnd.mayu.eventstream",
-}
diff --git a/lib/mayu/client/src/NodeTree.ts b/lib/mayu/client/src/NodeTree.ts
deleted file mode 100644
index e7db0c6c..00000000
--- a/lib/mayu/client/src/NodeTree.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import { createLogger, createSilentLogger } from "./logger";
-
-const SILENT = true;
-const logger = SILENT ? createSilentLogger() : createLogger("mayu/NodeTree/");
-
-export type IdNode = { id: number; ch?: [IdNode]; type: string };
-type CacheEntry = { node: Node; childIds: Set };
-
-type InsertPatch = {
- type: "insert";
- parent: number;
- before?: number;
- after?: number;
- html: string;
- ids: any;
-};
-
-type RemovePatch = { type: "remove"; id: number };
-
-type MovePatch = {
- type: "move";
- parent: number;
- id: number;
- before?: number;
- after?: number;
-};
-
-type StylePatch = { type: "css"; id: number; attr: string; value?: string };
-type StylesheetPatch = { type: "stylesheet"; paths: string[] };
-
-type AddTextPatch = { type: "text"; id: number; text: string };
-type AppendTextPatch = { type: "text"; id: number; append: string };
-type TextPatch = AddTextPatch | AppendTextPatch;
-
-type AttributePatch = {
- type: "attr";
- id: number;
- name: string;
- value?: string;
-};
-
-export type Patch =
- | InsertPatch
- | MovePatch
- | RemovePatch
- | TextPatch
- | AttributePatch
- | StylePatch
- | StylesheetPatch;
-
-function cloneScriptElement(element: HTMLScriptElement) {
- const script = document.createElement("script");
- script.text = element.innerHTML;
- for (const attr of element.attributes) {
- // console.log("Setting attribute", attr.name, "to", attr.value);
- script.setAttribute(attr.name, attr.value);
- }
- return script;
-}
-
-function replaceScriptNodes(parent: Node, node: Node) {
- if ((node as Element).tagName === "SCRIPT") {
- parent.replaceChild(cloneScriptElement(node as HTMLScriptElement), node);
- return;
- }
-
- for (const child of node.childNodes) {
- replaceScriptNodes(node, child);
- }
-}
-
-function handleAutofocus(node: Node) {
- if (node instanceof HTMLInputElement) {
- if (node.autofocus) {
- node.focus();
- return;
- }
- }
-
- for (const child of node.childNodes) {
- handleAutofocus(child);
- }
-}
-
-class NodeTree {
- #cache = new Map();
-
- constructor(root: IdNode, element = document.documentElement) {
- this.updateCache(element, root);
- //console.log(JSON.stringify(root, null, 2))
- }
-
- apply(patches: Patch[]) {
- for (const patch of patches) {
- this.applyPatch(patch);
- }
- }
-
- applyPatch(patch: Patch) {
- switch (patch.type) {
- case "insert": {
- this.insert(patch);
- return;
- }
- case "move": {
- this.move(patch);
- break;
- }
- case "remove": {
- this.remove(patch.id);
- break;
- }
- case "css": {
- const element = this.#getEntry(patch.id).node as HTMLElement;
-
- if (patch.value) {
- element.style.setProperty(patch.attr, patch.value);
- } else {
- element.style.removeProperty(patch.attr);
- }
- }
- case "text": {
- if ("text" in patch) {
- this.updateText(patch.id, patch.text);
- }
-
- if ("append" in patch) {
- this.appendText(patch.id, patch.append);
- }
- break;
- }
- case "attr": {
- if (patch.value !== undefined) {
- this.setAttribute(patch.id, patch.name, patch.value);
- } else {
- this.removeAttribute(patch.id, patch.name);
- }
- break;
- }
- case "stylesheet": {
- for (const href of patch.paths) {
- // TODO: This should be possible in Chrome, but not yet in Firefox.
- // const stylesheet = await import(path, { assert: { type: 'css' } });
- // document.adoptedStyleSheets.push(stylesheet)
- if (document.querySelector(`link[href="${href}"]`)) {
- continue;
- }
-
- const link = document.createElement("link");
- link.setAttribute("rel", "stylesheet");
- link.setAttribute("href", href);
- document.head.insertAdjacentElement("beforeend", link);
- }
-
- break;
- }
- default: {
- console.error("Unknown patch", patch);
- }
- }
- }
-
- updateText(id: number, text: string) {
- const node = this.#getEntry(id).node;
-
- if (node.nodeType !== node.TEXT_NODE) {
- throw new Error("Trying to update text on a non text node");
- }
-
- node.textContent = text;
- }
-
- appendText(id: number, text: string) {
- const node = this.#getEntry(id).node;
-
- if (node.nodeType !== node.TEXT_NODE) {
- throw new Error("Trying to update text on a non text node");
- }
-
- node.textContent += text;
- }
-
- setAttribute(id: number, name: string, value: string) {
- const node = this.#getEntry(id).node as Element;
-
- // logger.log("Trying to set attribute", name, value);
-
- if (name === "open") {
- if (node instanceof HTMLDialogElement) {
- node.showModal();
- return;
- }
- }
-
- if (node instanceof HTMLInputElement) {
- if (name === "value") {
- node.value = value;
- }
-
- if (name === "checked") {
- node.checked = true;
- }
-
- if (name === "indeterminate") {
- node.indeterminate = true;
- return;
- }
- }
-
- if (name === "initial_value") {
- name = "value";
- } else {
- name = name.replaceAll(/_/g, "");
- }
-
- node.setAttribute(name, value);
- }
-
- removeAttribute(id: number, name: string) {
- const node = this.#getEntry(id).node as Element;
-
- if (name === "open") {
- if (node instanceof HTMLDialogElement) {
- node.open = false;
- node.close();
- }
- }
-
- if (node instanceof HTMLInputElement) {
- if (name === "value") {
- node.value = "";
- }
-
- if (name === "checked") {
- node.checked = false;
- }
-
- if (name === "indeterminate") {
- node.indeterminate = false;
- return;
- }
- }
-
- node.removeAttribute(name);
- }
-
- insert({ parent, before, after, ids, html }: InsertPatch) {
- logger.group(`Trying to insert html into`, parent);
-
- const parentEntry = this.#getEntry(parent);
- const referenceId = before || after;
- const referenceEntry = referenceId && this.#cache.get(referenceId);
-
- const template = document
- .createRange()
- .createContextualFragment(`${html}`)
- .firstElementChild!;
- const content = (template as HTMLTemplateElement).content;
-
- const children = Array.from(content.childNodes).reverse();
-
- const idsArray = [ids].flat();
-
- idsArray.forEach((idTreeNode, i) => {
- parentEntry.childIds.add(idTreeNode.id);
- const entry = this.#cache.get(idTreeNode.id);
- const node = entry?.node || children[i];
- const ref = referenceEntry
- ? after
- ? referenceEntry.node.nextSibling
- : referenceEntry.node
- : null;
-
- const insertedNode = parentEntry.node.insertBefore(node, ref);
-
- if (entry) {
- (entry.node as HTMLElement).outerHTML = (node as HTMLElement).outerHTML;
- }
-
- requestIdleCallback(() => {
- handleAutofocus(insertedNode);
- });
-
- requestIdleCallback(() => {
- replaceScriptNodes(parentEntry.node, insertedNode);
- });
-
- this.updateCache(insertedNode, idTreeNode);
- });
-
- logger.groupEnd();
- }
-
- #getEntry(id: number) {
- const entry = this.#cache.get(id);
-
- if (!entry) {
- logger.error("Could not find", id, "in cache!");
- logger.error(Array.from(this.#cache.keys()));
- throw new Error(`Could not find ${id} in cache!`);
- }
-
- return entry;
- }
-
- remove(nodeId: number) {
- logger.info("Trying to remove", nodeId);
-
- try {
- const entry = this.#getEntry(nodeId);
- const parentNode = entry.node.parentNode;
-
- if (parentNode) {
- const parentId = parentNode.__MAYU_ID;
- const parentEntry = this.#cache.get(parentId);
-
- logger.log(`Removing child`, entry.node.textContent);
- parentNode.removeChild(entry.node);
-
- if (parentEntry) {
- parentEntry.childIds.delete(nodeId);
- }
- } else {
- logger.warn(`Node`, entry.node.__MAYU_ID, "has no parent??");
- }
-
- this.#removeRecursiveFromCache(nodeId, false);
- } catch (e) {
- logger.warn(e);
- }
- }
-
- move({ id, parent, before, after }: MovePatch) {
- const entry = this.#getEntry(id);
- const parentEntry = this.#getEntry(parent);
- const refId = before || after;
- const refEntry = refId && this.#cache.get(refId);
-
- const ref = refEntry ? (after ? refEntry.node : refEntry.node) : null;
-
- logger.log(
- "Moving",
- entry.node.textContent,
- before ? "before" : after ? "after" : "last",
- ref?.textContent || parentEntry.node.__MAYU_ID
- );
- logger.log({ before, after });
- logger.log(ref?.textContent);
-
- parentEntry.node.insertBefore(entry.node, ref);
- }
-
- #removeRecursiveFromCache(id: number, includeParent = false) {
- const entry = this.#cache.get(id);
-
- if (!entry) return;
-
- logger.group("Removing from cache", id);
-
- if (includeParent) {
- const parentEntry = this.#cache.get(entry.node.parentNode!.__MAYU_ID);
- parentEntry?.childIds?.delete(id);
- }
-
- this.#cache.delete(id);
-
- entry.childIds.forEach((childId) => {
- this.#removeRecursiveFromCache(childId, false);
- });
-
- entry.childIds.delete(id);
-
- logger.groupEnd();
- }
-
- isIgnoredNode(node: Node) {
- if (node.nodeType === node.TEXT_NODE) return false;
- if (node.nodeType === node.COMMENT_NODE) return false;
- if (node.nodeType === node.ELEMENT_NODE) {
- const dataset = (node as HTMLElement).dataset;
- if (typeof dataset.mayuId === "string") return false;
- }
-
- return true;
- }
-
- updateCache(node: Node, idTreeNode: IdNode) {
- if (!node) {
- logger.error(idTreeNode);
- throw new Error("No node found for idTreeNode");
- }
- const childIds = new Set((idTreeNode.ch || []).map((child) => child.id));
-
- this.#removeRecursiveFromCache(idTreeNode.id);
-
- this.#cache.set(idTreeNode.id, { node, childIds });
- node.__MAYU_ID = idTreeNode.id;
-
- logger.group(
- "Add to cache",
- idTreeNode.id,
- "type",
- node.nodeName,
- idTreeNode.type
- );
-
- // logger.log('Updating cache for', node, 'with id', idTreeNode.i)
-
- let i = 0;
- const ch = idTreeNode.ch || [];
-
- node.childNodes.forEach((childNode) => {
- if (this.isIgnoredNode(childNode)) {
- logger.warn(`Ignored:`, childNode);
- return;
- }
-
- const childIdNode = ch[i++];
-
- if (!childIdNode) {
- logger.error(
- `No childIdNode at index`,
- i,
- "on node",
- null,
- "with parent id",
- idTreeNode.id,
- "and child node",
- null
- );
- return;
- }
-
- this.updateCache(childNode, childIdNode);
- });
-
- if (i < ch.length) {
- // throw new Error("hello");
- }
-
- logger.groupEnd();
- }
-}
-
-export default NodeTree;
diff --git a/lib/mayu/client/src/README.md b/lib/mayu/client/src/README.md
new file mode 100644
index 00000000..02a5c621
--- /dev/null
+++ b/lib/mayu/client/src/README.md
@@ -0,0 +1,157 @@
+# Mayu Client Runtime (`lib/mayu/client/src`)
+
+This directory contains the browser-side runtime that:
+
+- opens a server patch stream,
+- applies incoming DOM patches,
+- sends user callbacks/navigation/ping events back to the server,
+- reconnects and restores state when possible.
+
+## High-Level Flow
+
+1. `init(sessionId)` in `main.ts`:
+ - installs a default view-transition stylesheet,
+ - creates `Runtime`,
+ - creates `Mayu` API instance and stores it on `window.Mayu`,
+ - creates `SessionConnection`,
+ - starts session connection loop at `/.mayu/session/:sessionId`.
+2. `SessionConnection.run()` loop:
+ - opens input stream (`GET` or `POST` with transfer state),
+ - opens callback output stream (`PATCH`),
+ - decodes MessagePack patch batches,
+ - applies patches through `runtime.apply(...)`,
+ - on failure: reset session or reconnect with backoff.
+3. `Runtime` in `runtime.ts`:
+ - keeps an `id -> Node` map (`NodeSet`),
+ - executes patches sequentially (`Batch`),
+ - supports DOM creation/update/remove, history, transfer, errors, etc.
+
+## Module Map
+
+- `main.ts`: minimal bootstrap and wiring.
+- `mayu.ts`: browser API used by runtime and app (`callback`, `navigate`, `ping`, writer accessors).
+- `session-connection.ts`: stream loop, patch decode/apply, reconnect/backoff.
+- `session-recovery.ts`: reset policy (`shouldResetSession`) and full session reset (`resetSessionEntirely`).
+- `view-transition.ts`: shared transition wrapper with fallback when View Transitions API is unavailable.
+- `stream.ts`: HTTP stream connect logic + callback stream transport.
+- `runtime.ts`: patch dispatcher and DOM mutation engine.
+- `serializeEvent.ts`: serializes event/currentTarget/target payloads.
+- `throttle.ts`: per-target callback throttling (~30 FPS).
+- `transfer.ts`: in-memory session transfer blob between reconnects.
+- `ping.ts`: updates `` status and RTT label.
+- `constants.ts`: shared MIME types, endpoint path, ping interval.
+
+## Data Paths
+
+### Server -> Client (patch stream)
+
+- `SessionConnection.run` calls `initInputStream(endpoint, transferState)`.
+- `connect(...)` validates:
+ - HTTP success,
+ - `content-type === application/vnd.mayu.event-stream`.
+- Input stream is optionally decompressed based on `content-encoding`.
+- `decodeMultiStream` yields patch batches.
+- Each batch is applied with `runtime.apply(...)`.
+
+### Client -> Server (callback stream)
+
+- `window.Mayu.callback(event, id)` serializes and writes:
+ - `{type: "callback", payload: {id, event}, ping}`.
+- `window.Mayu.navigate(href, pushState)` writes navigation message.
+- `window.Mayu.ping()` writes heartbeat every `PING_INTERVAL`.
+- Writes go through:
+ - `TransformStream` -> `JSONEncoderStream` -> `TextEncoderStream` -> output writable.
+- Output writable is:
+ - streaming `PATCH` request when request streams are supported,
+ - per-message fetch fallback otherwise.
+
+## Runtime/Patch Behavior Notes
+
+- `Batch` runs patches in order, awaiting async patches.
+- `ViewTransition` patch wraps a nested patch list in `document.startViewTransition(...)` when available, and falls back to immediate apply when not available.
+- `Transfer` patch stores transfer blob for reconnect handoff.
+- `Pong` updates measured ping in ``.
+- `RenderError` renders server-side exception UI.
+
+## Reconnect and Recovery (Current)
+
+In `SessionConnection.run(...)`:
+
+- `failures` resets to `0` on successful connection.
+- each loop iteration creates one `AbortController` for input/callback requests.
+- On error:
+ - if `shouldResetSession(error)` is true:
+ - checks structured `error.code` first (e.g. `SESSION_EXPIRED`, `SESSION_CIPHER_ERROR`, `SESSION_NOT_FOUND`, `TOKEN_COOKIE_NOT_SET`),
+ - falls back to normalized message matching (`"expired"`, `"cipher error"`, `"Session not found"`, `"Token cookie not set"`, and SCREAMING_SNAKE_CASE variants),
+ - call `resetSessionEntirely()`,
+ - fetch full HTML page + `x-mayu-session-id`,
+ - morph DOM with `morphdom`,
+ - clear transfer state,
+ - continue with new endpoint,
+ - if reset fails, log the reset failure and continue with normal backoff/retry.
+ - otherwise:
+ - wait with linear backoff (`1s .. 10s`),
+ - retry same endpoint.
+- at iteration teardown (`finally`):
+ - abort active requests/streams,
+ - abort/release callback writer,
+ - clear `window.Mayu` writer reference.
+
+## Structured Stream Errors
+
+Server stream errors now return JSON with:
+
+- `code` (stable machine-readable error code),
+- `message` (human-readable message),
+- `error` (legacy field kept for backward compatibility).
+
+Client parsing in `stream.ts` supports both new and legacy payload shapes.
+
+## Client Tests
+
+- test runner: Vitest (`jsdom` environment).
+- config: `lib/mayu/client/vitest.config.ts`.
+- test files:
+ - `lib/mayu/client/src/session-recovery.test.ts`
+ - `lib/mayu/client/src/session-connection.test.ts`
+ - `lib/mayu/client/src/stream.test.ts`
+
+Run:
+
+- `npm run test --workspace lib/mayu/client`
+- `npm run test:watch --workspace lib/mayu/client`
+
+## Refactor Plan
+
+1. Split `main.ts` into focused modules. (Done)
+ - Extract `SessionConnection` (connect/decode/apply/retry loop).
+ - Extract `SessionRecovery` (`shouldResetSession`, `resetSessionEntirely`).
+ - Keep `Mayu` as a small client API surface (`callback`, `navigate`, `ping`).
+2. Unify view-transition handling in a shared helper. (Done)
+ - Use the same helper from both `session-recovery.ts` (full session reset morph) and `runtime.ts` (`ViewTransition` patch).
+3. Move from message matching to structured stream error codes. (Done)
+ - Server returns `{ code, message }` and also keeps legacy `error`.
+ - Client reset/retry policy checks `code` first, then falls back to messages.
+4. Add explicit connection teardown/cancellation. (Done)
+ - Each connection attempt now has an `AbortController`.
+ - Teardown aborts stream requests and clears callback writer resources.
+5. Isolate retry/backoff policy.
+ - Extract backoff calculation so reconnect timing is easy to test and tune.
+6. Tighten patch typing.
+ - Replace broad tuples/`any` with stronger patch payload types so runtime patch dispatch is type-safe.
+7. Gate debug logging.
+ - Route noisy `console.*` calls through a debug logger flag to keep production output clean.
+8. Add targeted tests for critical recovery behavior. (Done)
+ - `shouldResetSession` cases.
+ - missing `x-mayu-session-id` during reset.
+ - reset failure falls back to reconnect backoff.
+
+### Suggested Order
+
+1. Step 1 (split responsibilities).
+2. Step 2 (shared view transition helper).
+3. Step 5 (retry policy extraction).
+4. Step 4 (connection teardown model).
+5. Step 8 (tests around current behavior).
+6. Step 6 (typing hardening).
+7. Step 7 (logging cleanup).
diff --git a/lib/mayu/client/src/constants.ts b/lib/mayu/client/src/constants.ts
new file mode 100644
index 00000000..9273f009
--- /dev/null
+++ b/lib/mayu/client/src/constants.ts
@@ -0,0 +1,7 @@
+export const STREAM_MIME_TYPE = "application/vnd.mayu.event-stream";
+export const STREAM_CONTENT_ENCODING = "deflate-raw";
+
+export const SESSION_MIME_TYPE = "application/vnd.mayu.session";
+export const SESSION_PATH = "/.mayu/session";
+
+export const PING_INTERVAL = 1_000;
diff --git a/lib/mayu/client/src/custom-elements/mayu-alert.html b/lib/mayu/client/src/custom-elements/mayu-alert.html
deleted file mode 100644
index 2b1f1e2a..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-alert.html
+++ /dev/null
@@ -1,137 +0,0 @@
-
-
diff --git a/lib/mayu/client/src/custom-elements/mayu-alert.ts b/lib/mayu/client/src/custom-elements/mayu-alert.ts
deleted file mode 100644
index 3a7ab637..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-alert.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import html from "./mayu-alert.html";
-
-const template = document.createElement("template");
-template.innerHTML = html;
-
-class MayuAlert extends HTMLElement {
- #dialog: HTMLDialogElement;
- #message: HTMLParagraphElement;
- #button: HTMLButtonElement;
-
- static observedAttributes = ["message"];
-
- constructor() {
- super();
-
- if (!this.shadowRoot) {
- this.attachShadow({ mode: "open" });
- }
-
- this.shadowRoot!.appendChild(
- template.content.cloneNode(true)
- ) as DocumentFragment;
-
- this.#dialog = this.shadowRoot!.querySelector(
- "dialog"
- ) as HTMLDialogElement;
-
- this.#button = this.shadowRoot!.querySelector(
- "button"
- ) as HTMLButtonElement;
-
- this.#message = this.shadowRoot!.getElementById(
- "message"
- ) as HTMLParagraphElement;
-
- this.#dialog.addEventListener("close", () => this.remove());
- }
-
- connectedCallback() {
- this.#dialog.showModal();
- this.#message.textContent = this.getAttribute("message");
- this.#button.focus();
- }
-
- attributeChangedCallback(name: string, oldValue: string, newValue: string) {
- switch (name) {
- case "message":
- this.#message.textContent = String(newValue);
- break;
- default:
- break;
- }
- }
-
- disconnectedCallback() {
- this.#dialog.close();
- }
-}
-
-window.customElements.define("mayu-alert", MayuAlert);
-
-export default MayuAlert;
diff --git a/lib/mayu/client/src/custom-elements/mayu-disconnected.html b/lib/mayu/client/src/custom-elements/mayu-disconnected.html
deleted file mode 100644
index aa919558..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-disconnected.html
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
diff --git a/lib/mayu/client/src/custom-elements/mayu-disconnected.ts b/lib/mayu/client/src/custom-elements/mayu-disconnected.ts
deleted file mode 100644
index e0a7ba4e..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-disconnected.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import html from "./mayu-disconnected.html";
-
-const template = document.createElement("template");
-template.innerHTML = html;
-
-class MayuDisconnected extends HTMLElement {
- dialog?: HTMLDialogElement;
- reason?: HTMLParagraphElement;
-
- static observedAttributes = ["reason"];
-
- constructor() {
- super();
-
- if (!this.shadowRoot) {
- this.attachShadow({ mode: "open" });
- }
-
- this.shadowRoot!.appendChild(
- template.content.cloneNode(true)
- ) as DocumentFragment;
-
- this.dialog = this.shadowRoot!.querySelector("dialog") as HTMLDialogElement;
- this.reason = this.shadowRoot!.getElementById(
- "reason"
- ) as HTMLParagraphElement;
- }
-
- connectedCallback() {
- this.dialog!.showModal();
- }
-
- attributeChangedCallback(name: string, oldValue: string, newValue: string) {
- switch (name) {
- case "reason":
- if (!this.reason) break;
- this.reason.textContent = String(newValue);
- break;
- default:
- break;
- }
- }
-
- disconnectedCallback() {
- this.dialog?.close();
- }
-}
-
-window.customElements.define("mayu-disconnected", MayuDisconnected);
-
-export default MayuDisconnected;
diff --git a/lib/mayu/client/src/custom-elements/mayu-exception.html b/lib/mayu/client/src/custom-elements/mayu-exception.html
index 7336ca34..aa921f8d 100644
--- a/lib/mayu/client/src/custom-elements/mayu-exception.html
+++ b/lib/mayu/client/src/custom-elements/mayu-exception.html
@@ -1,79 +1,259 @@
diff --git a/lib/mayu/client/src/custom-elements/mayu-exception.ts b/lib/mayu/client/src/custom-elements/mayu-exception.ts
index 3d8e5b01..43bb3839 100644
--- a/lib/mayu/client/src/custom-elements/mayu-exception.ts
+++ b/lib/mayu/client/src/custom-elements/mayu-exception.ts
@@ -3,8 +3,9 @@ import html from "./mayu-exception.html";
const template = document.createElement("template");
template.innerHTML = html;
-class MayuException extends HTMLElement {
+export default class MayuException extends HTMLElement {
dialog?: HTMLDialogElement;
+ closeButton?: HTMLButtonElement;
connectedCallback() {
if (!this.shadowRoot) {
@@ -13,9 +14,18 @@ class MayuException extends HTMLElement {
this.shadowRoot!.appendChild(template.content.cloneNode(true));
- this.dialog = this.shadowRoot!.querySelector("dialog") as HTMLDialogElement;
+ this.dialog = this.shadowRoot!.querySelector("dialog")!;
+ this.closeButton =
+ this.shadowRoot!.querySelector(
+ "[data-action='close']"
+ ) || undefined;
- this.dialog!.showModal();
+ this.closeButton?.addEventListener("click", () => this.dialog?.close());
+ this.dialog!.addEventListener("close", () => this.remove());
+
+ if (!this.dialog!.open) {
+ this.dialog!.showModal();
+ }
}
disconnectedCallback() {
@@ -24,5 +34,3 @@ class MayuException extends HTMLElement {
}
window.customElements.define("mayu-exception", MayuException);
-
-export default MayuException;
diff --git a/lib/mayu/client/src/custom-elements/mayu-log.html b/lib/mayu/client/src/custom-elements/mayu-log.html
deleted file mode 100644
index b73a5cbb..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-log.html
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
- toggle log
-
-
-
-
-
-
-
- | Id |
- Event |
- Payload |
-
-
-
-
-
-
diff --git a/lib/mayu/client/src/custom-elements/mayu-log.ts b/lib/mayu/client/src/custom-elements/mayu-log.ts
deleted file mode 100644
index cd9e9942..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-log.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import html from "./mayu-log.html";
-import h from "../h";
-import { stringifyJSON } from "../utils";
-
-const template = document.createElement("template");
-template.innerHTML = html;
-
-class LogComponent extends HTMLElement {
- log?: HTMLTableSectionElement;
-
- connectedCallback() {
- if (!this.shadowRoot) {
- this.attachShadow({ mode: "open" });
- }
-
- this.shadowRoot!.appendChild(
- template.content.cloneNode(true)
- ) as DocumentFragment;
-
- this.log = this.shadowRoot!.querySelector(
- ".log"
- ) as HTMLTableSectionElement;
-
- (
- this.shadowRoot!.querySelector(".clear-button") as HTMLButtonElement
- ).addEventListener("click", () => {
- this.log!.innerHTML = "";
- });
- }
-
- addEntry(id: string, event: string, payload: any) {
- this.log!.appendChild(
- h("tr", [
- h("td", [id]),
- h("td", [event]),
- h("td", [h("pre", [stringifyJSON(payload)])]),
- ])
- );
- }
-}
-
-export default LogComponent;
diff --git a/lib/mayu/client/src/custom-elements/mayu-ping.html b/lib/mayu/client/src/custom-elements/mayu-ping.html
index a715efc8..4ba93fc9 100644
--- a/lib/mayu/client/src/custom-elements/mayu-ping.html
+++ b/lib/mayu/client/src/custom-elements/mayu-ping.html
@@ -1,35 +1,76 @@
-
- Ping:
+
N/A
- (N/A @ N/A)
+
diff --git a/lib/mayu/client/src/custom-elements/mayu-ping.ts b/lib/mayu/client/src/custom-elements/mayu-ping.ts
index ae4bd046..d7ec5da1 100644
--- a/lib/mayu/client/src/custom-elements/mayu-ping.ts
+++ b/lib/mayu/client/src/custom-elements/mayu-ping.ts
@@ -3,91 +3,94 @@ import html from "./mayu-ping.html";
const template = document.createElement("template");
template.innerHTML = html;
-const REGION_NAMES: Record
= {
- ams: "Amsterdam, Netherlands",
- arn: "Stockholm, Sweden",
- atl: "Atlanta, Georgia (US)",
- bog: "Bogotá, Colombia",
- bom: "Mumbai, India",
- bos: "Boston, Massachusetts (US)",
- cdg: "Paris, France",
- den: "Denver, Colorado (US)",
- dfw: "Dallas, Texas (US)",
- ewr: "Secaucus, NJ (US)",
- eze: "Ezeiza, Argentina",
- fra: "Frankfurt, Germany",
- gdl: "Guadalajara, Mexico",
- gig: "Rio de Janeiro, Brazil",
- gru: "Sao Paulo, Brazil",
- hkg: "Hong Kong, Hong Kong",
- iad: "Ashburn, Virginia (US)",
- jnb: "Johannesburg, South Africa",
- lax: "Los Angeles, California (US)",
- lhr: "London, United Kingdom",
- maa: "Chennai (Madras), India",
- mad: "Madrid, Spain",
- mia: "Miami, Florida (US)",
- nrt: "Tokyo, Japan",
- ord: "Chicago, Illinois (US)",
- otp: "Bucharest, Romania",
- phx: "Phoenix, Arizona (US)",
- qro: "Querétaro, Mexico",
- scl: "Santiago, Chile",
- sea: "Seattle, Washington (US)",
- sin: "Singapore, Singapore",
- sjc: "San Jose, California (US)",
- syd: "Sydney, Australia",
- waw: "Warsaw, Poland",
- yul: "Montreal, Canada",
- yyz: "Toronto, Canada",
-};
-
class MayuPing extends HTMLElement {
#div?: HTMLDivElement;
#ping?: HTMLSpanElement;
- #instance?: HTMLSpanElement;
- #region?: HTMLSpanElement;
+ #disconnectDialog?: HTMLDialogElement;
+ #disconnectTitle?: HTMLParagraphElement;
+ #disconnectText?: HTMLParagraphElement;
- static observedAttributes = ["ping", "region", "status"];
+ static observedAttributes = ["ping", "status"];
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
}
- this.shadowRoot!.appendChild(
- template.content.cloneNode(true)
- ) as DocumentFragment;
+ this.shadowRoot!.replaceChildren(template.content.cloneNode(true));
this.#div = this.shadowRoot!.querySelector(".mayu-ping") as HTMLDivElement;
this.#ping = this.shadowRoot!.querySelector(".ping") as HTMLSpanElement;
- this.#instance = this.shadowRoot!.querySelector(
- ".instance"
- ) as HTMLSpanElement;
- this.#region = this.shadowRoot!.querySelector(".region") as HTMLSpanElement;
+ this.#disconnectDialog = this.shadowRoot!.querySelector(
+ ".disconnect-dialog"
+ ) as HTMLDialogElement;
+ this.#disconnectTitle = this.shadowRoot!.querySelector(
+ ".disconnect-title"
+ ) as HTMLParagraphElement;
+ this.#disconnectText = this.shadowRoot!.querySelector(
+ ".disconnect-text"
+ ) as HTMLParagraphElement;
+ this.#disconnectDialog?.addEventListener("cancel", (event) => {
+ // Keep this modal non-cancelable while connection is unavailable.
+ event.preventDefault();
+ });
+
+ const status = this.getAttribute("status");
+
+ if (status) {
+ this.attributeChangedCallback("status", "", status);
+ }
}
+ disconnectedCallback() {}
+
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
switch (name) {
case "ping":
- this.#ping!.textContent = newValue;
- break;
- case "instance":
- this.#instance!.textContent = newValue;
- break;
- case "region":
- this.#region!.textContent = REGION_NAMES[newValue] || newValue;
+ if (!this.#ping) return;
+ this.#ping.textContent = newValue;
break;
case "status":
+ const classList = this.#div?.classList;
if (oldValue && oldValue !== newValue) {
- this.#div!.classList.remove(`status-${oldValue}`);
+ classList?.remove(`status-${oldValue}`);
}
if (newValue) {
- this.#div!.classList.add(`status-${newValue}`);
+ classList?.add(`status-${newValue}`);
}
+ this.#updateConnectionDialog(newValue);
break;
}
}
+
+ #updateConnectionDialog(status: string) {
+ const dialog = this.#disconnectDialog;
+ const title = this.#disconnectTitle;
+ const text = this.#disconnectText;
+ if (!dialog || !title || !text) return;
+
+ if (status === "disconnected") {
+ title.textContent = "Disconnected";
+ text.textContent = "Trying to reconnect…";
+ if (!dialog.open) {
+ dialog.showModal();
+ }
+ return;
+ }
+
+ if (status === "transferring") {
+ title.textContent = "Transferring";
+ text.textContent = "Trying to restore connection…";
+ if (!dialog.open) {
+ dialog.showModal();
+ }
+ return;
+ }
+
+ if (dialog.open) {
+ dialog.close();
+ }
+ }
}
window.customElements.define("mayu-ping", MayuPing);
diff --git a/lib/mayu/client/src/custom-elements/mayu-progress-bar.html b/lib/mayu/client/src/custom-elements/mayu-progress-bar.html
deleted file mode 100644
index 9bcead17..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-progress-bar.html
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
diff --git a/lib/mayu/client/src/custom-elements/mayu-progress-bar.ts b/lib/mayu/client/src/custom-elements/mayu-progress-bar.ts
deleted file mode 100644
index fa173c85..00000000
--- a/lib/mayu/client/src/custom-elements/mayu-progress-bar.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import html from "./mayu-progress-bar.html";
-
-const template = document.createElement("template");
-template.innerHTML = html;
-
-class MayuProgressBar extends HTMLElement {
- progress: HTMLDivElement | null = null;
-
- static observedAttributes = ["progress"];
-
- connectedCallback() {
- const shadowRoot = this.attachShadow({ mode: "open" });
-
- shadowRoot.appendChild(
- template.content.cloneNode(true)
- ) as DocumentFragment;
-
- this.progress = shadowRoot.querySelector(".progress")!;
- }
-
- attributeChangedCallback(name: string, oldValue: string, newValue: string) {
- if (name === "progress") {
- switch (Number(newValue)) {
- case 0:
- this.progress!.removeAttribute("hidden");
- break;
- case 100:
- this.progress!.setAttribute("hidden", "");
- break;
- default:
- this.progress!.removeAttribute("hidden");
- break;
- }
- }
- }
-}
-
-window.customElements.define("mayu-progress-bar", MayuProgressBar);
-
-export default MayuProgressBar;
diff --git a/lib/mayu/client/src/global.d.ts b/lib/mayu/client/src/global.d.ts
deleted file mode 100644
index 86b451af..00000000
--- a/lib/mayu/client/src/global.d.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type Mayu from "./Mayu.js";
-
-declare global {
- interface Node {
- __MAYU_ID: number;
- }
-
- interface Navigation extends EventTarget {}
-
- interface NavigateEvent extends Event {
- transitionWhile: (promise: Promise) => void;
- }
-
- interface Window {
- Mayu: Mayu;
- navigation?: Navigation;
- }
-
- class DecompressionStream extends TransformStream {
- constructor(format: string);
- }
-
- interface Document {
- adoptedStyleSheets: any[];
- }
-}
diff --git a/lib/mayu/client/src/h.ts b/lib/mayu/client/src/h.ts
index 079a2887..cbffbe94 100644
--- a/lib/mayu/client/src/h.ts
+++ b/lib/mayu/client/src/h.ts
@@ -1,7 +1,7 @@
export default function h(
type: string,
children: any[] = [],
- attrs: Record = {}
+ attrs: Record = {},
) {
const el = document.createElement(type);
diff --git a/lib/mayu/client/src/logger.ts b/lib/mayu/client/src/logger.ts
deleted file mode 100644
index a76b9b87..00000000
--- a/lib/mayu/client/src/logger.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-export function createSilentLogger() {
- const noop = (..._args: any[]) => {};
-
- return {
- info: noop,
- log: noop,
- warn: noop,
- error: noop,
- success: noop,
- group: noop,
- groupEnd: noop,
- };
-}
-
-function generateStyle(color: string) {
- return [
- `background: ${color}`,
- `border: 1px solid rgba(0, 0, 0, 0.5)`,
- `border-radius: 2px`,
- `padding: 0 2px`,
- `color: #000`,
- `font-weight: bold`,
- ].join(";");
-}
-
-export function createLogger(prefix = "mayu/") {
- return {
- info: console.info.bind(
- console,
- `%c${prefix}info`,
- generateStyle("#35baf6")
- ),
- log: console.info.bind(console, `%c${prefix}log`, generateStyle("#ccc")),
- error: console.error.bind(
- console,
- `%c${prefix}error`,
- generateStyle("#f6685e")
- ),
- warn: console.warn.bind(
- console,
- `%c${prefix}warn`,
- generateStyle("#ffc107")
- ),
- success: console.info.bind(
- console,
- `%c${prefix}success`,
- generateStyle("#a2cf6e")
- ),
- group: console.group.bind(console),
- groupEnd: console.groupEnd.bind(console),
- };
-}
-
-const SILENT = false;
-
-export default SILENT ? createSilentLogger() : createLogger();
diff --git a/lib/mayu/client/src/main.ts b/lib/mayu/client/src/main.ts
index a57328a9..b06f0d21 100644
--- a/lib/mayu/client/src/main.ts
+++ b/lib/mayu/client/src/main.ts
@@ -1,283 +1,39 @@
-import { sessionStream } from "./stream";
-import NodeTree from "./NodeTree";
-import h from "./h";
-import type MayuPingElement from "./custom-elements/mayu-ping";
-import type MayuLogElement from "./custom-elements/mayu-log";
-import type MayuExceptionElement from "./custom-elements/mayu-exception";
-import type MayuAlertElement from "./custom-elements/mayu-alert";
+import Runtime from "./runtime.js";
+import { SESSION_PATH } from "./constants";
+import Mayu from "./mayu.js";
+import SessionConnection from "./session-connection.js";
-import serializeEvent from "./serializeEvent";
+import "./custom-elements/mayu-exception";
-import logger from "./logger";
-
-const onDOMContentLoaded = new Promise((resolve) => {
- if (document.readyState !== "loading") {
- return resolve();
- }
-
- window.addEventListener("DOMContentLoaded", () => resolve());
-});
-
-function shouldPreventDefault(e: Event) {
- if (typeof TouchEvent !== "undefined") {
- if (e instanceof TouchEvent) {
- return false;
- }
+declare global {
+ interface Window {
+ Mayu: Mayu;
}
- return true;
-}
-
-async function showException({
- type,
- message,
- backtrace,
-}: {
- type: string;
- message: string;
- backtrace: string[];
-}) {
- await import("./custom-elements/mayu-exception");
-
- const cleanedBacktrace = backtrace
- .filter((line) => !/\/vendor\/bundle\//.test(line))
- .join("\n");
-
- const el = h("mayu-exception", [
- h("span", [`${type}: ${message}`], { slot: "title" }),
- h("span", [cleanedBacktrace], { slot: "backtrace" }),
- ]);
-
- document.body.appendChild(el);
-}
-
-async function showAlert(message: string) {
- await import("./custom-elements/mayu-alert");
- const elem = document.createElement("mayu-alert") as MayuAlertElement;
- elem.setAttribute("message", message);
- document.body.appendChild(elem);
}
-class MayuGlobal {
- #sessionId: string;
-
- constructor(sessionId: string) {
- this.#sessionId = sessionId;
-
- onDOMContentLoaded.then(() => {
- window.addEventListener("popstate", () => {
- return navigateTo(this.#sessionId, location.pathname);
- });
- });
- }
-
- async handle(e: Event, handlerId: string) {
- if (shouldPreventDefault(e)) {
- e.preventDefault();
- }
-
- const payload = serializeEvent(e);
- console.log(payload);
- // progressBar.setAttribute("progress", "0");
-
- await mayuCallback(this.#sessionId, handlerId, payload);
-
- let didRun = false;
- const timeout = setTimeout(() => {
- // progressBar.setAttribute("progress", "25");
- didRun = true;
- }, 1);
-
- clearTimeout(timeout);
-
- // progressBar.setAttribute("progress", "100");
+export default function init(sessionId: string) {
+ if (window.Mayu) {
+ console.error(
+ "%cwindow.Mayu is already defined",
+ "font-size: 1.5em; color: #c00;"
+ );
+ throw "window.Mayu is already defined";
}
- async navigate(e: MouseEvent) {
- if (e.metaKey || e.ctrlKey) return;
-
- e.preventDefault();
- const anchor = (e.target as HTMLElement).closest("a");
-
- if (!anchor) {
- logger.error("Could not find anchor element for", e.target);
- return;
- }
-
- const url = new URL((anchor as HTMLAnchorElement).href);
- // progressBar.setAttribute("progress", "0");
- return navigateTo(this.#sessionId, url.pathname + url.search);
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(`
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation-duration: 1s;
}
-}
-
-function mayuCallback(sessionId: string, handlerId: string, payload: any) {
- return fetch(`/__mayu/session/${sessionId}/callback/${handlerId}`, {
- method: "POST",
- headers: new Headers({
- "content-type": "application/json",
- "x-request-time": String(performance.now()),
- }),
- body: JSON.stringify(payload),
- }).then(logRequestTime);
-}
-
-async function navigateTo(sessionId: string, url: string) {
- return fetch(`/__mayu/session/${sessionId}/navigate`, {
- method: "POST",
- headers: new Headers({
- "content-type": "text/plain; charset=utf-8",
- "x-request-time": String(performance.now()),
- }),
- body: url,
- }).then(logRequestTime);
-}
+ `);
+ document.adoptedStyleSheets.push(sheet);
-function logRequestTime(res: Response) {
- const requestTime = res.headers.get("x-request-time");
-
- if (requestTime) {
- console.log("Pong:", performance.now() - Number(requestTime), "ms");
- }
-
- return res;
-}
-
-function getSessionIdFromUrl(url: string) {
- const index = url.lastIndexOf("#");
- if (index === -1) {
- throw new Error(`No # found in script url: ${url}`);
- }
- return url.slice(index + 1);
-}
-
-function loadCustomElements() {
- import("./custom-elements/mayu-ping");
- import("./custom-elements/mayu-disconnected");
- import("./custom-elements/mayu-progress-bar");
- import("./custom-elements/mayu-exception");
- import("./custom-elements/mayu-alert");
-}
-
-async function main(url: string) {
- const sessionId = getSessionIdFromUrl(url);
- const mayu = new MayuGlobal(sessionId);
+ const runtime = new Runtime();
+ const mayu = new Mayu();
window.Mayu = mayu;
- let nodeTree: NodeTree | undefined;
-
- const disconnectedElement = document.createElement("mayu-disconnected");
-
- const pingElement = document.createElement("mayu-ping") as MayuPingElement;
- pingElement.setAttribute("region", "Connecting...");
- pingElement.setAttribute("status", "connecting");
- document.body.appendChild(pingElement);
-
- for await (const [event, payload] of sessionStream(sessionId)) {
- switch (event) {
- case "system.connected":
- loadCustomElements();
-
- pingElement.setAttribute("region", "Connected!");
- pingElement.setAttribute("status", "connected");
- logger.success("Connected!");
-
- document.body
- .querySelectorAll("mayu-disconnected")
- .forEach((el) => el.remove());
- break;
- case "system.disconnected":
- if (payload.transferring) {
- pingElement.setAttribute("region", "Transferring…");
- pingElement.setAttribute("status", "transferring");
- break;
- }
-
- pingElement.setAttribute("region", "Disconnected");
- pingElement.setAttribute("status", "disconnected");
-
- logger.error("Disconnected");
-
- disconnectedElement.setAttribute("reason", payload.reason);
-
- if (disconnectedElement.parentElement !== document.body) {
- document.body.appendChild(disconnectedElement);
- }
- break;
- case "session.init":
- await onDOMContentLoaded;
- nodeTree = new NodeTree(payload.ids);
- break;
- case "session.patch":
- nodeTree?.apply(payload);
- break;
- case "session.navigate":
- const path = payload.path;
-
- if (path !== location.pathname) {
- logger.info("Navigating to", path);
- history.pushState({}, "", path);
- // progressBar.setAttribute("progress", "100");
- }
- break;
- case "session.action":
- handleAction(payload.type, payload.payload);
- break;
- case "session.keep_alive":
- break;
- case "session.transfer":
- pingElement.setAttribute("region", "Transferring");
- pingElement.setAttribute("status", "transferring");
- break;
- case "session.exception":
- showException(payload);
- break;
- case "ping":
- const values = Object.values(payload.values) as number[];
- const mean = values.reduce((a, b) => a + b, 0.0) / values.length;
- pingElement.setAttribute("ping", `${mean.toFixed(2)} ms`);
- pingElement.setAttribute("instance", payload.instance);
- pingElement.setAttribute("region", payload.region);
- pingElement.setAttribute("status", "ping");
- break;
- default:
- logger.warn("Unhandled event:", event, payload);
- break;
- }
- }
-
- function handleAction(type: string, payload: any) {
- switch (type) {
- case "scroll_into_view": {
- scrollIntoView(payload.selector, payload.options || {});
- break;
- }
- case "alert": {
- showAlert(payload);
- break;
- }
- default: {
- logger.error("Unhandled action:", type, payload);
- break;
- }
- }
- }
-
- function scrollIntoView(selector: string, options: Record) {
- const elem = document.querySelector(selector);
-
- if (elem) {
- elem.scrollIntoView({
- block: "start",
- inline: "nearest",
- behavior: "smooth",
- ...options,
- });
- } else {
- console.error(
- "Could not find element to scrollIntoView, selector:",
- selector
- );
- }
- }
+ const endpoint = `${SESSION_PATH}/${sessionId}`;
+ const connection = new SessionConnection({ runtime, mayu, endpoint });
+ void connection.run();
}
-
-export default main(import.meta.url);
diff --git a/lib/mayu/client/src/mayu.ts b/lib/mayu/client/src/mayu.ts
new file mode 100644
index 00000000..bcefa07b
--- /dev/null
+++ b/lib/mayu/client/src/mayu.ts
@@ -0,0 +1,69 @@
+import serializeEvent from "./serializeEvent.js";
+import { PING_INTERVAL } from "./constants";
+import throttle from "./throttle";
+
+export default class Mayu {
+ #writer: WritableStreamDefaultWriter | null;
+ #pingTimer: number;
+
+ constructor() {
+ this.#writer = null;
+
+ window.addEventListener("popstate", () => {
+ this.navigate(location.pathname + location.search, false);
+ });
+
+ this.#pingTimer = setTimeout(() => this.ping(), 100);
+ }
+
+ setWriter(writer: WritableStreamDefaultWriter) {
+ this.#writer = writer;
+ }
+
+ clearWriter() {
+ this.#writer = null;
+ }
+
+ async #write(message: any) {
+ try {
+ await this.#writer?.write(message);
+ } catch (_error) {
+ console.error("Write error");
+ }
+ }
+
+ callback(event: Event, id: string) {
+ event.preventDefault();
+
+ const serializedEvent = serializeEvent(event);
+
+ throttle(event.currentTarget!, () => {
+ this.#write({
+ type: "callback",
+ payload: { id, event: serializedEvent },
+ ping: performance.now(),
+ });
+ });
+ }
+
+ navigate(href: string, pushState: boolean = true) {
+ console.warn("navigate", href);
+
+ this.#write({
+ type: "navigate",
+ payload: { href, pushState },
+ ping: performance.now(),
+ });
+ }
+
+ ping() {
+ clearTimeout(this.#pingTimer);
+
+ this.#pingTimer = setTimeout(() => this.ping(), PING_INTERVAL);
+
+ this.#write({
+ type: "ping",
+ ping: performance.now(),
+ });
+ }
+}
diff --git a/lib/mayu/client/src/patches.ts b/lib/mayu/client/src/patches.ts
new file mode 100644
index 00000000..e6f920e4
--- /dev/null
+++ b/lib/mayu/client/src/patches.ts
@@ -0,0 +1,41 @@
+export default interface Patches {
+ Initialize(idTree: any): void;
+
+ CreateTree(html: string, tree: any): void;
+
+ CreateElement(id: string, type: string): void;
+ CreateTextNode(id: string, content: string): void;
+ CreateComment(id: string, content: string): void;
+
+ ReplaceChildren(id: string, childIds: string[]): void;
+
+ RemoveNode(id: string): void;
+
+ SetAttribute(id: string, name: string, value: string): void;
+ RemoveAttribute(id: string, name: string): void;
+
+ SetClassName(id: string, className: string): void;
+
+ SetListener(id: string, name: string, listenerId: string): void;
+ RemoveListener(id: string, name: string, listenerId: string): void;
+
+ SetCSSProperty(id: string, name: string, value: string): void;
+ RemoveCSSProperty(id: string, name: string): void;
+
+ SetTextContent(id: string, content: string): void;
+ ReplaceData(id: string, offset: number, count: number, data: string): void;
+ InsertData(id: string, offset: number, data: string): void;
+ DeleteData(id: string, offset: number, count: number): void;
+
+ AddStyleSheet(filename: string): void;
+
+ Transfer(payload: Blob): void;
+
+ Ping(timestamp: number): void;
+ Pong(timestamp: number): void;
+
+ Event(event: string, payload: any): void;
+ HistoryPushState(path: string): void;
+
+ RenderError(file: string, type: string, message: string, backtrace: string[], source: string, treePath: any): void;
+};
\ No newline at end of file
diff --git a/lib/mayu/client/src/ping.ts b/lib/mayu/client/src/ping.ts
new file mode 100644
index 00000000..d65dbe70
--- /dev/null
+++ b/lib/mayu/client/src/ping.ts
@@ -0,0 +1,17 @@
+import type MayuPing from "./custom-elements/mayu-ping";
+
+import("./custom-elements/mayu-ping");
+
+export type ConnectionStatus = "disconnected" | "connected" | "transferring";
+
+function getPingElement() {
+ return document.querySelector("mayu-ping");
+}
+
+export function updatePing(value: number) {
+ getPingElement()?.setAttribute("ping", `${Math.round(value)}ms`);
+}
+
+export function updateConnectionStatus(status: ConnectionStatus) {
+ getPingElement()?.setAttribute("status", status);
+}
diff --git a/lib/mayu/client/src/renderError.ts b/lib/mayu/client/src/renderError.ts
new file mode 100644
index 00000000..882c30cb
--- /dev/null
+++ b/lib/mayu/client/src/renderError.ts
@@ -0,0 +1,114 @@
+import h from "./h";
+
+export function clearRenderError() {
+ document.querySelectorAll("mayu-exception").forEach((e) => e.remove());
+}
+
+export default function renderError(
+ file: string,
+ type: string,
+ message: string,
+ backtrace: string[],
+ source: string,
+ treePath: { name: string; path?: string }[]
+) {
+ const formats: string[] = [];
+ const buf: string[] = [];
+
+ buf.push(`%c${type}: ${message}`);
+ formats.push("font-size: 1.25em");
+
+ treePath.forEach((path, i) => {
+ const indent = " ".repeat(i);
+ if (path.path) {
+ buf.push(`%c${indent}%%%c${path.name} %c(${path.path})`);
+ formats.push("color: #ff4d6d;", "color: #7fd1ff;", "color: #9aa3b2;");
+ } else {
+ buf.push(`%c${indent}%%%c${path.name}`);
+ formats.push("color: #ff4d6d;", "color: #7fd1ff;");
+ }
+ });
+
+ backtrace.forEach((line) => {
+ buf.push(`%c${line}`);
+
+ formats.push(
+ line.startsWith(`${file}:`)
+ ? "font-size: 1em; font-weight: 600; text-shadow: 0 0 3px #000;"
+ : "font-size: 1em;"
+ );
+ });
+
+ console.error(buf.join("\n"), ...formats);
+
+ clearRenderError();
+ const element = document.createElement("mayu-exception");
+
+ const interestingLines = new Set();
+
+ backtrace.forEach((line) => {
+ if (line.startsWith(`${file}:`)) {
+ interestingLines.add(Number(line.split(":")[1]));
+ }
+ });
+
+ const exceptionClass = h("span", [type], {
+ slot: "exception-class",
+ class: "exception-class",
+ });
+
+ const filename = h("span", [file || "Runtime Error"], {
+ slot: "filename",
+ class: "filename",
+ });
+
+ const errorMessage = h("span", [message], {
+ slot: "message",
+ class: "message",
+ });
+
+ const treeItems = treePath.map((path, i) => {
+ const indent = " ".repeat(i);
+ const line =
+ indent + "% " + path.name + (path.path ? ` (${path.path})` : "");
+
+ return h("li", [line], { slot: "tree-path", class: "tree-item" });
+ });
+
+ const backtraceItems = backtrace.map((line) => {
+ const isInteresting = line.startsWith(`${file}:`);
+ const className = isInteresting
+ ? "trace-item is-interesting"
+ : "trace-item";
+
+ return h("li", [line], {
+ slot: "backtrace",
+ class: className,
+ });
+ });
+
+ const sourceItems = source.split("\n").map((line, i) => {
+ const isInteresting = interestingLines.has(i + 1);
+ const className = isInteresting
+ ? "source-line is-interesting"
+ : "source-line";
+ const number = String(i + 1).padStart(4, " ");
+ const content = `${number} ${line}`;
+
+ return h("li", [content], {
+ slot: "source",
+ class: className,
+ });
+ });
+
+ element.replaceChildren(
+ exceptionClass,
+ filename,
+ errorMessage,
+ ...treeItems,
+ ...backtraceItems,
+ ...sourceItems
+ );
+
+ document.body.appendChild(element);
+}
diff --git a/lib/mayu/client/src/runtime.ts b/lib/mayu/client/src/runtime.ts
new file mode 100644
index 00000000..1746a6f1
--- /dev/null
+++ b/lib/mayu/client/src/runtime.ts
@@ -0,0 +1,546 @@
+// Copyright Andreas Alin
+// License: AGPL-3.0
+
+import { updatePing } from "./ping";
+import { setTransferState } from "./transfer";
+import renderError, { clearRenderError } from "./renderError";
+import withViewTransition from "./view-transition";
+
+type IdNode = {
+ id: string;
+ name: string;
+ children: IdNode[];
+};
+
+type PatchType = keyof typeof Patches;
+
+type Patch = [id: string, name: PatchType, ...args: string[]];
+
+type PatchSet = Patch[];
+
+export default class Runtime {
+ #nodeSet = new NodeSet();
+
+ async apply(patches: PatchSet) {
+ await Patches.Batch.call(this.#nodeSet, patches);
+ }
+}
+
+type NodeInfo = {
+ id: string;
+ childIds: string[];
+};
+
+function initNodeInfo(id: string, childIds: string[] = []): NodeInfo {
+ return {
+ id,
+ childIds,
+ };
+}
+
+class NodeSet {
+ #nodes: Record = {};
+ #nodeInfo = new WeakMap();
+
+ clear() {
+ this.#nodes = {};
+ }
+
+ deleteNode(id: string) {
+ const node = this.#nodes[id];
+ if (!node) return;
+ // console.debug(`%cDeleting ${id}`, "color: #c00; font-weight: bold; font-size: 1.5em;", node)
+ delete this.#nodes[id];
+ const nodeInfo = this.getNodeInfo(node);
+ this.#nodeInfo.delete(node);
+ if (nodeInfo) {
+ nodeInfo.childIds.forEach((childId) => this.deleteNode(childId));
+ }
+ }
+
+ setNode(id: string, node: Node) {
+ this.#nodes[id] = node;
+ const nodeInfo = initNodeInfo(id);
+ this.#nodeInfo.set(node, nodeInfo);
+ return nodeInfo;
+ }
+
+ getNode(id: string) {
+ const node = this.#nodes[id];
+
+ if (!node) {
+ throw new Error(`Node not found: ${id}`);
+ }
+
+ return node;
+ }
+
+ getNodeInfo(node: Node) {
+ return this.#nodeInfo.get(node);
+ }
+
+ getNodes(ids: string[]) {
+ return ids.map((id) => this.getNode(id));
+ }
+
+ getElement(id: string) {
+ const node = this.getNode(id);
+
+ if (node instanceof HTMLElement) {
+ return node;
+ }
+
+ if (node instanceof SVGElement) {
+ return node;
+ }
+
+ throw new Error(`Node ${id} is not an Element`);
+ }
+
+ getCharacterData(id: string) {
+ const node = this.getNode(id);
+
+ if (node instanceof CharacterData) {
+ return node;
+ }
+
+ throw new Error(`Node ${id} is not a CharacterData`);
+ }
+}
+
+const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
+const SVG_TAGS = new Set([
+ "svg",
+ "g",
+ "path",
+ "rect",
+ "text",
+ "tspan",
+ "textpath",
+ "circle",
+ "line",
+ "polyline",
+ "polygon",
+ "ellipse",
+ "defs",
+ "marker",
+ "symbol",
+ "use",
+ "image",
+ "pattern",
+ "clippath",
+ "mask",
+ "filter",
+ "lineargradient",
+ "radialgradient",
+ "stop",
+ "foreignobject",
+]);
+
+function createDomElement(type: string): Element {
+ const tag = type.toLowerCase();
+ if (SVG_TAGS.has(tag)) {
+ return document.createElementNS(SVG_NAMESPACE, type);
+ }
+
+ return document.createElement(type);
+}
+
+function createTreeRootNode(html: string, tree: IdNode): Node {
+ const rootTag = tree.name.toLowerCase();
+ const isSvgRoot = SVG_TAGS.has(rootTag);
+ const wrappedHtml = isSvgRoot
+ ? ``
+ : html;
+
+ const template = document
+ .createRange()
+ .createContextualFragment(`${wrappedHtml}`)
+ .firstElementChild!;
+ const content = (template as HTMLTemplateElement).content;
+
+ if (!isSvgRoot) {
+ const root = content.firstChild;
+ if (!root)
+ throw new Error(`CreateTree: missing root node for ${tree.name}`);
+ return root;
+ }
+
+ const svgWrapper = content.firstElementChild;
+ if (!svgWrapper) throw new Error("CreateTree: missing svg wrapper");
+
+ const svgChildren = Array.from(svgWrapper.childNodes).filter((child) => {
+ if (child.nodeType === Node.DOCUMENT_TYPE_NODE) return false;
+ if (
+ child.nodeType === Node.TEXT_NODE &&
+ (child.textContent == null || child.textContent.trim() === "")
+ ) {
+ return false;
+ }
+ return true;
+ });
+ const root = svgChildren[0] || svgWrapper.firstChild;
+ if (!root)
+ throw new Error(`CreateTree: missing SVG root node for ${tree.name}`);
+ return root;
+}
+
+function debugTree(node: IdNode, level = 0): string {
+ return [
+ [" ".repeat(level), node.name, " (", node.id, ")"].join(""),
+ ...(node.children || []).map((child) => debugTree(child, level + 1)),
+ ]
+ .flat()
+ .join("\n");
+}
+
+const configuredLinks = new WeakSet();
+
+function configureLink(a: HTMLAnchorElement) {
+ if (configuredLinks.has(a)) return;
+ configuredLinks.add(a);
+ a.addEventListener("click", (e) => {
+ if (a.host !== location.host) {
+ return;
+ }
+
+ if (a.target === "_blank") {
+ return;
+ }
+
+ if (e.metaKey) {
+ return;
+ }
+
+ e.preventDefault();
+ window.Mayu.navigate(a.pathname + a.search);
+ });
+}
+
+function setupTree(nodeSet: NodeSet, domNode: Node, idNode: IdNode) {
+ if (!domNode) return;
+
+ if (domNode.nodeName.toUpperCase() !== idNode.name.toUpperCase()) {
+ console.error(
+ `Node ${idNode.id} should be ${idNode.name}, but found ${domNode.nodeName}`
+ );
+ }
+
+ const nodeInfo = nodeSet.setNode(idNode.id, domNode);
+
+ if (domNode.nodeName === "A") {
+ configureLink(domNode as HTMLAnchorElement);
+ }
+
+ if (!idNode.children) return;
+
+ const childNodes = Array.from(domNode.childNodes).filter(
+ (child) => child.nodeType !== Node.DOCUMENT_TYPE_NODE
+ );
+
+ nodeInfo.childIds = idNode.children.map((child) => child.id);
+
+ idNode.children.forEach((child, i) => {
+ setupTree(nodeSet, childNodes[i], child);
+ });
+}
+
+declare global {
+ interface ObjectConstructor {
+ groupBy- (
+ items: Iterable
- ,
+ keySelector: (item: Item, index: number) => Key
+ ): Record;
+ }
+
+ interface MapConstructor {
+ groupBy
- (
+ items: Iterable
- ,
+ keySelector: (item: Item, index: number) => Key
+ ): Map;
+ }
+}
+
+function updateHead(
+ nodeSet: NodeSet,
+ element: Element,
+ nodeInfo: NodeInfo,
+ newChildIds: string[]
+) {
+ console.log("UPDATE HEAD");
+ const oldChildIds = nodeInfo.childIds;
+
+ const existingNodes = new Map();
+
+ oldChildIds.forEach((id, i) => {
+ existingNodes.set(id, element.childNodes[i]);
+ });
+
+ newChildIds.forEach((id) => {
+ existingNodes.set(id, nodeSet.getNode(id));
+ });
+
+ // Remove nodes that are no longer needed
+ oldChildIds.forEach((id) => {
+ if (newChildIds.includes(id)) return;
+ if (!existingNodes.has(id)) return;
+ const nodeToRemove = existingNodes.get(id);
+ if (!nodeToRemove) return;
+ element.removeChild(nodeToRemove);
+ existingNodes.delete(id); // Ensure to remove from the map as well
+ nodeSet.deleteNode(id);
+ });
+
+ // Insert or move nodes to match the newChildIds order
+ let lastInsertedNode: Element | null = null;
+ newChildIds.forEach((id, index) => {
+ let node = existingNodes.get(id) as Element;
+
+ if (node) {
+ // If the node exists but is not in the correct order, move it
+ if (lastInsertedNode && lastInsertedNode.nextSibling !== node) {
+ element.insertBefore(node, lastInsertedNode.nextSibling);
+ }
+ } else {
+ // If the node doesn't exist, insert it
+ node = nodeSet.getNode(id) as Element; // Assuming nodeSet.getNode(id) returns an Element or Node
+
+ if (node) {
+ // If lastInsertedNode is null, insert as the first child or before the first existing node in newChildIds found in the head
+ if (!lastInsertedNode) {
+ const nextExistingNode =
+ newChildIds
+ .slice(index + 1)
+ .find((nextId) => existingNodes.get(nextId)) ?? null;
+ const nextNode =
+ (nextExistingNode
+ ? existingNodes.get(nextExistingNode)
+ : element.firstChild) || null;
+ element.insertBefore(node, nextNode);
+ } else {
+ element.insertBefore(node, lastInsertedNode.nextSibling);
+ }
+ existingNodes.set(id, node); // Add to the map for future look-ups
+ }
+ }
+ lastInsertedNode = node;
+ });
+
+ nodeInfo.childIds = newChildIds;
+}
+
+const Patches = {
+ async Batch(this: NodeSet, patches: Patch[]) {
+ for (const patch of patches) {
+ const [name, ...args] = patch;
+ console.log(name, args);
+
+ const patchFn = Patches[name as PatchType] as any;
+
+ if (!patchFn) {
+ throw new Error(`Not implemented: ${name}`);
+ }
+
+ try {
+ const result = patchFn.apply(this, args as any);
+
+ if (result instanceof Promise) {
+ await result;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+ async ViewTransition(this: NodeSet, ...patches: Patch[]) {
+ return withViewTransition(() => Patches.Batch.call(this, patches));
+ },
+
+ Initialize(this: NodeSet, tree: IdNode) {
+ console.debug(`%c${debugTree(tree)}`, "color: #6cf;");
+
+ this.clear();
+ setupTree(this, document, tree);
+ },
+ CreateTree(this: NodeSet, html: string, tree: IdNode) {
+ setupTree(this, createTreeRootNode(html, tree), tree);
+ },
+ CreateElement(this: NodeSet, id: string, type: string) {
+ this.setNode(id, createDomElement(type));
+ },
+ CreateTextNode(this: NodeSet, id: string, content: string) {
+ this.setNode(id, document.createTextNode(content));
+ },
+ CreateComment(this: NodeSet, id: string, content: string) {
+ this.setNode(id, document.createComment(content));
+ },
+ RemoveNode(this: NodeSet, id: string) {
+ this.deleteNode(id);
+ },
+ HistoryPushState(this: NodeSet, path: string) {
+ const currentPath = location.pathname + location.search;
+
+ if (currentPath === path) return;
+
+ console.warn("pushState going from", currentPath, "to", path);
+
+ history.pushState({ path: currentPath }, "", path);
+ },
+ SetClassName(this: NodeSet, id: string, value: string) {
+ (this.getElement(id) as HTMLElement).className = value;
+ },
+ AddClass(this: NodeSet, id: string, classes: string[]) {
+ (this.getElement(id) as HTMLElement).classList.add(...classes);
+ },
+ RemoveClass(this: NodeSet, id: string, classes: string[]) {
+ (this.getElement(id) as HTMLElement).classList.remove(...classes);
+ },
+ SetAttribute(this: NodeSet, id: string, name: string, value: string) {
+ const element = this.getElement(id);
+
+ if (name === "open") {
+ if (element instanceof HTMLDialogElement) {
+ element.showModal();
+ }
+ }
+
+ if (element instanceof HTMLInputElement) {
+ switch (name) {
+ case "value": {
+ element.value = value;
+ break;
+ }
+ case "checked": {
+ element.checked = true;
+ break;
+ }
+ case "indeterminate": {
+ element.indeterminate = true;
+ return;
+ }
+ }
+ }
+
+ if (name === "initial_value") {
+ name = "value";
+ } else {
+ name = name.replaceAll(/_/g, "");
+ }
+
+ element.setAttribute(name, value);
+ },
+ RemoveAttribute(this: NodeSet, id: string, name: string) {
+ const element = this.getElement(id);
+
+ if (name === "open") {
+ if (element instanceof HTMLDialogElement) {
+ element.open = false;
+ element.close();
+ }
+ }
+
+ if (element instanceof HTMLInputElement) {
+ switch (name) {
+ case "value": {
+ element.value = "";
+ break;
+ }
+ case "checked": {
+ element.checked = false;
+ break;
+ }
+ case "indeterminate": {
+ element.indeterminate = false;
+ return;
+ }
+ }
+ }
+
+ element.removeAttribute(name);
+ },
+ SetCSSProperty(this: NodeSet, id: string, name: string, value: string) {
+ this.getElement(id).style.setProperty(name, value);
+ },
+ RemoveCSSProperty(this: NodeSet, id: string, name: string) {
+ this.getElement(id).style.removeProperty(name);
+ },
+ SetTextContent(this: NodeSet, id: string, content: string) {
+ this.getCharacterData(id).data = content;
+ },
+ ReplaceChildren(this: NodeSet, id: string, childIds: string[]) {
+ const element = this.getElement(id);
+ const nodeInfo = this.getNodeInfo(element);
+
+ if (nodeInfo) {
+ if (element.nodeName === "HEAD") {
+ updateHead(this, element, nodeInfo, childIds);
+ return;
+ }
+
+ nodeInfo.childIds.forEach((id) => {
+ if (!childIds.includes(id)) {
+ this.deleteNode(id);
+ }
+ });
+ }
+
+ element.replaceChildren(...this.getNodes(childIds));
+
+ requestIdleCallback(() => {
+ handleAutofocus(element);
+ });
+ },
+ Transfer(this: NodeSet, state: Blob) {
+ console.log("Transfer", state);
+ setTransferState(state);
+ },
+ AddStyleSheet(this: NodeSet, path: string) {
+ console.error(path);
+ console.error(path);
+ console.error(path);
+ console.error(path);
+ console.error(path);
+ console.error(path);
+ console.error(path);
+ },
+ Pong(this: NodeSet, timestamp: number) {
+ updatePing(performance.now() - timestamp);
+ },
+ RenderError(
+ this: NodeSet,
+ file: string,
+ type: string,
+ message: string,
+ backtrace: string[],
+ source: string,
+ treePath: { name: string; path?: string }[]
+ ) {
+ renderError(file, type, message, backtrace, source, treePath);
+ },
+ Event(this: NodeSet, event: string, _payload: unknown) {
+ if (event === "reload:success") {
+ clearRenderError();
+ }
+ },
+ RegisterCustomElement(name: string, path: string) {
+ if (customElements.get(name)) return;
+
+ (async () => {
+ const mod = await import(path);
+ customElements.define(name, mod.default);
+ })();
+ },
+} as const;
+
+function handleAutofocus(node: Node) {
+ if (node instanceof HTMLInputElement) {
+ if (node.autofocus) {
+ node.focus();
+ return;
+ }
+ }
+
+ for (const child of node.childNodes) {
+ handleAutofocus(child);
+ }
+}
diff --git a/lib/mayu/client/src/serializeEvent.test.ts b/lib/mayu/client/src/serializeEvent.test.ts
new file mode 100644
index 00000000..79441b1d
--- /dev/null
+++ b/lib/mayu/client/src/serializeEvent.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import serializeEvent from "./serializeEvent";
+
+function captureSerializedEvent(target: Element, type: string, event: Event) {
+ let payload: Record | undefined;
+
+ target.addEventListener(type, (e) => {
+ payload = serializeEvent(e);
+ });
+
+ target.dispatchEvent(event);
+
+ if (!payload) throw new Error("Expected serialized payload");
+ return payload as Record;
+}
+
+describe("serializeEvent", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("serializes dataset for html and svg elements", () => {
+ const button = document.createElement("button");
+ button.id = "save-button";
+ button.dataset.action = "save";
+ document.body.append(button);
+
+ const htmlPayload = captureSerializedEvent(
+ button,
+ "click",
+ new MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ buttons: 1,
+ })
+ );
+
+ expect(htmlPayload.currentTarget.dataset).toEqual({ action: "save" });
+ expect(htmlPayload.target.dataset).toEqual({ action: "save" });
+
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ const circle = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "circle"
+ );
+ circle.dataset.state = "active";
+ svg.append(circle);
+ document.body.append(svg);
+
+ const svgPayload = captureSerializedEvent(
+ circle,
+ "click",
+ new MouseEvent("click", { bubbles: true, cancelable: true })
+ );
+
+ expect(svgPayload.currentTarget.dataset).toEqual({ state: "active" });
+ expect(svgPayload.target.dataset).toEqual({ state: "active" });
+ });
+
+ it("serializes keyboard event metadata", () => {
+ const input = document.createElement("input");
+ document.body.append(input);
+
+ const payload = captureSerializedEvent(
+ input,
+ "keydown",
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "A",
+ code: "KeyA",
+ location: 1,
+ repeat: true,
+ ctrlKey: true,
+ shiftKey: true,
+ })
+ );
+
+ expect(payload.type).toBe("KeyboardEvent");
+ expect(payload.eventType).toBe("keydown");
+ expect(payload.key).toBe("A");
+ expect(payload.code).toBe("KeyA");
+ expect(payload.location).toBe(1);
+ expect(payload.repeat).toBe(true);
+ expect(payload.ctrlKey).toBe(true);
+ expect(payload.shiftKey).toBe(true);
+ });
+
+ it("serializes textarea values", () => {
+ const textarea = document.createElement("textarea");
+ textarea.name = "message";
+ textarea.value = "hello world";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 5;
+ document.body.append(textarea);
+
+ const payload = captureSerializedEvent(
+ textarea,
+ "input",
+ new Event("input", { bubbles: true, cancelable: true })
+ );
+
+ expect(payload.target).toMatchObject({
+ tagName: "TEXTAREA",
+ name: "message",
+ value: "hello world",
+ selectionStart: 2,
+ selectionEnd: 5,
+ });
+ });
+
+ it("serializes select metadata for multi-select controls", () => {
+ const select = document.createElement("select");
+ select.name = "flavor";
+ select.multiple = true;
+
+ const vanilla = document.createElement("option");
+ vanilla.value = "vanilla";
+ vanilla.selected = true;
+ const chocolate = document.createElement("option");
+ chocolate.value = "chocolate";
+ chocolate.selected = true;
+ select.append(vanilla, chocolate);
+ document.body.append(select);
+
+ const payload = captureSerializedEvent(
+ select,
+ "change",
+ new Event("change", { bubbles: true, cancelable: true })
+ );
+
+ expect(payload.target).toMatchObject({
+ tagName: "SELECT",
+ name: "flavor",
+ multiple: true,
+ selectedOptions: ["vanilla", "chocolate"],
+ value: "vanilla",
+ selectedIndex: 0,
+ });
+ });
+
+ it("serializes submit details and grouped formData values", () => {
+ const form = document.createElement("form");
+ form.name = "save-form";
+ form.method = "post";
+
+ const firstTag = document.createElement("input");
+ firstTag.name = "tag";
+ firstTag.value = "frontend";
+
+ const secondTag = document.createElement("input");
+ secondTag.name = "tag";
+ secondTag.value = "ruby";
+
+ const submitButton = document.createElement("button");
+ submitButton.type = "submit";
+ submitButton.name = "action";
+ submitButton.value = "save";
+
+ form.append(firstTag, secondTag, submitButton);
+ document.body.append(form);
+
+ const payload = captureSerializedEvent(
+ form,
+ "submit",
+ new SubmitEvent("submit", {
+ bubbles: true,
+ cancelable: true,
+ submitter: submitButton,
+ })
+ );
+
+ expect(payload.target.formData.tag).toEqual(["frontend", "ruby"]);
+ expect(payload.submitter).toMatchObject({
+ tagName: "BUTTON",
+ name: "action",
+ value: "save",
+ });
+ });
+});
diff --git a/lib/mayu/client/src/serializeEvent.ts b/lib/mayu/client/src/serializeEvent.ts
index 300e0115..1e922e30 100644
--- a/lib/mayu/client/src/serializeEvent.ts
+++ b/lib/mayu/client/src/serializeEvent.ts
@@ -1,90 +1,219 @@
-function serializeElement(obj: any) {
- if (obj instanceof HTMLFormElement) {
- const formData = Object.fromEntries(new FormData(obj).entries());
+// Copyright Andreas Alin
+// License: AGPL-3.0
+
+export default function serializeEvent(e: Event) {
+ const payload: Record = {};
+
+ payload.type = e.constructor.name;
+ payload.eventType = e.type;
+ payload.bubbles = e.bubbles;
+ payload.cancelable = e.cancelable;
+ payload.defaultPrevented = e.defaultPrevented;
+ payload.timeStamp = e.timeStamp;
+
+ if (e.currentTarget instanceof Element) {
+ payload.currentTarget = serializeElement(e.currentTarget);
+ }
+
+ if (e.target instanceof Element) {
+ payload.target = serializeElement(e.target);
+ }
+
+ if (e instanceof KeyboardEvent) {
+ payload.key = e.key;
+ payload.code = e.code;
+ payload.keyCode = e.keyCode;
+ payload.location = e.location;
+ payload.isComposing = e.isComposing;
+ Object.assign(payload, serializeModifierKeys(e));
+ payload.repeat = e.repeat;
+ }
+
+ if (e instanceof MouseEvent) {
+ payload.button = e.button;
+ payload.buttons = e.buttons;
+ payload.clientX = e.clientX;
+ payload.clientY = e.clientY;
+ Object.assign(payload, serializeModifierKeys(e));
+ }
+
+ if (e instanceof FocusEvent && e.relatedTarget instanceof Element) {
+ payload.relatedTarget = serializeElement(e.relatedTarget);
+ }
+
+ if (typeof InputEvent !== "undefined" && e instanceof InputEvent) {
+ payload.data = e.data;
+ payload.inputType = e.inputType;
+ payload.isComposing = e.isComposing;
+ }
+
+ if (typeof WheelEvent !== "undefined" && e instanceof WheelEvent) {
+ payload.deltaX = e.deltaX;
+ payload.deltaY = e.deltaY;
+ payload.deltaZ = e.deltaZ;
+ payload.deltaMode = e.deltaMode;
+ }
+
+ if (typeof PointerEvent !== "undefined" && e instanceof PointerEvent) {
+ payload.pointerId = e.pointerId;
+ payload.pointerType = e.pointerType;
+ payload.isPrimary = e.isPrimary;
+ payload.pressure = e.pressure;
+ }
+
+ if (e instanceof SubmitEvent) {
+ if (e.submitter instanceof HTMLElement) {
+ payload.submitter = serializeElement(e.submitter);
+ }
+ }
+
+ return payload;
+}
+
+function serializeElement(elem: Element) {
+ const base = {
+ tagName: elem.tagName,
+ id: elem.id,
+ dataset: serializeDataset(elem),
+ };
+
+ if (elem instanceof HTMLInputElement) {
+ const valueAsNumber = Number.isNaN(elem.valueAsNumber)
+ ? null
+ : elem.valueAsNumber;
return {
- tagName: obj.tagName,
- id: obj.id,
- method: obj.method,
- target: obj.target,
- name: obj.name,
- formData,
+ ...base,
+ type: elem.type,
+ name: elem.name,
+ value: elem.value,
+ valueAsNumber,
+ checked: elem.checked,
+ indeterminate: elem.indeterminate,
+ disabled: elem.disabled,
+ readOnly: elem.readOnly,
+ required: elem.required,
+ files: elem.files ? Array.from(elem.files, serializeFile) : [],
};
}
- if (obj instanceof HTMLSelectElement) {
+ if (elem instanceof HTMLTextAreaElement) {
return {
- tagName: obj.tagName,
- id: obj.id,
- type: obj.type,
- name: obj.name,
- value: obj.value,
+ ...base,
+ name: elem.name,
+ value: elem.value,
+ selectionStart: elem.selectionStart,
+ selectionEnd: elem.selectionEnd,
+ disabled: elem.disabled,
+ readOnly: elem.readOnly,
+ required: elem.required,
};
}
- if (obj instanceof HTMLDetailsElement) {
+ if (elem instanceof HTMLSelectElement) {
return {
- tagName: obj.tagName,
- id: obj.id,
- open: obj.open,
+ ...base,
+ type: elem.type,
+ name: elem.name,
+ value: elem.value,
+ selectedIndex: elem.selectedIndex,
+ multiple: elem.multiple,
+ selectedOptions: Array.from(
+ elem.selectedOptions,
+ (option) => option.value
+ ),
+ disabled: elem.disabled,
+ required: elem.required,
};
}
- if (obj instanceof HTMLInputElement) {
+ if (elem instanceof HTMLFormElement) {
+ const formData = serializeFormData(elem);
+
return {
- tagName: obj.tagName,
- id: obj.id,
- type: obj.type,
- name: obj.name,
- value: obj.value,
- checked: obj.checked,
+ ...base,
+ method: elem.method,
+ target: elem.target,
+ action: elem.action,
+ enctype: elem.enctype,
+ noValidate: elem.noValidate,
+ name: elem.name,
+ formData,
};
}
- if (obj instanceof HTMLButtonElement) {
+ if (elem instanceof HTMLDetailsElement) {
return {
- tagName: obj.tagName,
- id: obj.id,
- type: obj.type,
- name: obj.name,
- value: obj.value,
+ ...base,
+ open: elem.open,
};
}
- if (obj instanceof HTMLElement) {
+ if (elem instanceof HTMLDialogElement) {
return {
- tagName: obj.tagName,
- id: obj.id,
+ ...base,
+ open: elem.open,
+ returnValue: elem.returnValue,
};
}
- return {};
-}
+ if (elem instanceof HTMLButtonElement) {
+ return {
+ ...base,
+ type: elem.type,
+ name: elem.name,
+ value: elem.value,
+ disabled: elem.disabled,
+ };
+ }
-function serializeEvent(e: Event) {
- const payload: Record = {};
+ return base;
+}
- payload.type = e.constructor.name;
+function serializeDataset(elem: Element) {
+ if (!(elem instanceof HTMLElement || elem instanceof SVGElement)) return {};
+ return { ...elem.dataset };
+}
- if (e.currentTarget) {
- payload.currentTarget = serializeElement(e.currentTarget);
- }
+function serializeModifierKeys(
+ e: Pick
+) {
+ return {
+ ctrlKey: e.ctrlKey,
+ metaKey: e.metaKey,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ };
+}
- if (e.target) {
- payload.target = serializeElement(e.target);
- }
+function serializeFormData(form: HTMLFormElement) {
+ const formData: Record = {};
- if (e instanceof MouseEvent) {
- payload.buttons = e.buttons;
- }
+ for (const [key, value] of new FormData(form).entries()) {
+ const serializedValue = serializeFormDataValue(value);
+ const existing = formData[key];
- if (e instanceof SubmitEvent) {
- if (e.submitter instanceof HTMLElement) {
- payload.submitter = serializeElement(e.submitter);
+ if (typeof existing === "undefined") {
+ formData[key] = serializedValue;
+ } else if (Array.isArray(existing)) {
+ existing.push(serializedValue);
+ } else {
+ formData[key] = [existing, serializedValue];
}
}
- return payload;
+ return formData;
}
-export default serializeEvent;
+function serializeFormDataValue(value: FormDataEntryValue) {
+ if (typeof value === "string") return value;
+ return serializeFile(value);
+}
+
+function serializeFile(file: File) {
+ return {
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ };
+}
diff --git a/lib/mayu/client/src/session-connection.test.ts b/lib/mayu/client/src/session-connection.test.ts
new file mode 100644
index 00000000..4bce01af
--- /dev/null
+++ b/lib/mayu/client/src/session-connection.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
+
+const {
+ initInputStreamMock,
+ initCallbackStreamMock,
+ getErrorMessageMock,
+ shouldResetSessionMock,
+ resetSessionEntirelyMock,
+ updateConnectionStatusMock,
+} = vi.hoisted(() => {
+ return {
+ initInputStreamMock: vi.fn(),
+ initCallbackStreamMock: vi.fn(),
+ getErrorMessageMock: vi.fn((error: unknown) =>
+ error instanceof Error ? error.message : String(error)
+ ),
+ shouldResetSessionMock: vi.fn(),
+ resetSessionEntirelyMock: vi.fn(),
+ updateConnectionStatusMock: vi.fn(),
+ };
+});
+
+vi.mock("./stream.js", () => ({
+ initInputStream: initInputStreamMock,
+ initCallbackStream: initCallbackStreamMock,
+ JSONEncoderStream: class JSONEncoderStream extends TransformStream {},
+ StreamError: class StreamError extends Error {},
+}));
+
+vi.mock("./session-recovery.js", () => ({
+ getErrorMessage: getErrorMessageMock,
+ shouldResetSession: shouldResetSessionMock,
+ resetSessionEntirely: resetSessionEntirelyMock,
+}));
+
+vi.mock("./ping", () => ({
+ updateConnectionStatus: updateConnectionStatusMock,
+}));
+
+import SessionConnection from "./session-connection";
+
+describe("session-connection", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ vi.spyOn(console, "info").mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("falls back to retry sleep when session reset fails", async () => {
+ const inputError = new Error("stream down");
+ const resetError = new Error("reset failed");
+ const stopError = new Error("stop test loop");
+
+ initInputStreamMock.mockRejectedValue(inputError);
+ shouldResetSessionMock.mockReturnValue(true);
+ resetSessionEntirelyMock.mockRejectedValue(resetError);
+
+ const sleepMock = vi.fn(async () => {
+ throw stopError;
+ });
+
+ const runtime = { apply: vi.fn() };
+ const mayu = {
+ setWriter: vi.fn(),
+ clearWriter: vi.fn(),
+ };
+
+ const connection = new SessionConnection({
+ runtime: runtime as any,
+ mayu: mayu as any,
+ endpoint: "/.mayu/session/test",
+ sleep: sleepMock,
+ });
+
+ await expect(connection.run()).rejects.toBe(stopError);
+
+ expect(shouldResetSessionMock).toHaveBeenCalledWith(inputError);
+ expect(resetSessionEntirelyMock).toHaveBeenCalledTimes(1);
+ expect(sleepMock).toHaveBeenCalledWith(1000);
+ expect(mayu.clearWriter).toHaveBeenCalled();
+ });
+});
diff --git a/lib/mayu/client/src/session-connection.ts b/lib/mayu/client/src/session-connection.ts
new file mode 100644
index 00000000..7bc66d7e
--- /dev/null
+++ b/lib/mayu/client/src/session-connection.ts
@@ -0,0 +1,167 @@
+import { decodeMultiStream, ExtensionCodec } from "@msgpack/msgpack";
+
+import Runtime from "./runtime.js";
+import {
+ initInputStream,
+ initCallbackStream,
+ JSONEncoderStream,
+ StreamError,
+} from "./stream.js";
+import { SESSION_MIME_TYPE } from "./constants";
+import { updateConnectionStatus } from "./ping";
+import { getTransferState, setTransferState } from "./transfer";
+import {
+ getErrorMessage,
+ resetSessionEntirely,
+ shouldResetSession,
+} from "./session-recovery.js";
+import Mayu from "./mayu.js";
+
+async function sleep(milliseconds: number) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+function createExtensionCodec() {
+ const extensionCodec = new ExtensionCodec();
+
+ extensionCodec.register({
+ type: 0x01,
+ encode() {
+ throw new Error("Not implemented");
+ },
+ decode(buffer) {
+ return new Blob([buffer], { type: SESSION_MIME_TYPE });
+ },
+ });
+
+ return extensionCodec;
+}
+
+type SessionConnectionOptions = {
+ runtime: Runtime;
+ mayu: Mayu;
+ endpoint: string;
+ sleep?: (milliseconds: number) => Promise;
+};
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+}
+
+export default class SessionConnection {
+ #runtime: Runtime;
+ #mayu: Mayu;
+ #endpoint: string;
+ #sleep: (milliseconds: number) => Promise;
+
+ constructor({
+ runtime,
+ mayu,
+ endpoint,
+ sleep: sleepFn,
+ }: SessionConnectionOptions) {
+ this.#runtime = runtime;
+ this.#mayu = mayu;
+ this.#endpoint = endpoint;
+ this.#sleep = sleepFn || sleep;
+ }
+
+ async run() {
+ const extensionCodec = createExtensionCodec();
+ let failures = 0;
+
+ while (true) {
+ const abortController = new AbortController();
+ let callbackWriter: WritableStreamDefaultWriter | null = null;
+ let callbackPipeline: Promise | null = null;
+
+ try {
+ const state = getTransferState();
+
+ updateConnectionStatus(state ? "transferring" : "disconnected");
+
+ const input = await initInputStream(
+ this.#endpoint,
+ state,
+ abortController.signal
+ );
+ setTransferState(null);
+
+ const callbackStream = new TransformStream();
+ callbackWriter = callbackStream.writable.getWriter();
+ this.#mayu.setWriter(callbackWriter);
+ const output = initCallbackStream(
+ this.#endpoint,
+ abortController.signal
+ );
+
+ failures = 0;
+
+ callbackPipeline = callbackStream.readable
+ .pipeThrough(new JSONEncoderStream())
+ .pipeThrough(new TextEncoderStream())
+ .pipeTo(output)
+ .catch((error) => {
+ if (!isAbortError(error)) {
+ console.error("Callback pipeline error", error);
+ }
+ });
+
+ updateConnectionStatus("connected");
+
+ for await (const patch of decodeMultiStream(input, {
+ extensionCodec,
+ })) {
+ updateConnectionStatus("connected");
+
+ try {
+ await this.#runtime.apply(patch as any);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ } catch (error: unknown) {
+ failures += 1;
+ const message = getErrorMessage(error);
+
+ if (error instanceof StreamError) {
+ console.error("StreamError", error.message);
+ } else {
+ console.error(error);
+ }
+
+ if (shouldResetSession(error)) {
+ console.warn("Resetting session because of:", message);
+
+ try {
+ this.#endpoint = await resetSessionEntirely();
+ failures = 0;
+ continue;
+ } catch (resetError) {
+ console.error("Session reset failed", resetError);
+ }
+ }
+
+ const sleepTime = Math.min(10_000, 1000 * failures);
+ console.info(`Attempting to reconnect in`, sleepTime, "ms");
+ await this.#sleep(sleepTime);
+ } finally {
+ abortController.abort();
+ this.#mayu.clearWriter();
+
+ if (callbackWriter) {
+ try {
+ await callbackWriter.abort();
+ } catch (_error) {
+ } finally {
+ callbackWriter.releaseLock();
+ }
+ }
+
+ if (callbackPipeline) {
+ await callbackPipeline;
+ }
+ }
+ }
+ }
+}
diff --git a/lib/mayu/client/src/session-recovery.test.ts b/lib/mayu/client/src/session-recovery.test.ts
new file mode 100644
index 00000000..7023cfbf
--- /dev/null
+++ b/lib/mayu/client/src/session-recovery.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ getErrorCode,
+ getErrorMessage,
+ resetSessionEntirely,
+ shouldResetSession,
+} from "./session-recovery";
+
+describe("session-recovery", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("extracts error messages from string and Error values", () => {
+ expect(getErrorMessage("boom")).toBe("boom");
+ expect(getErrorMessage(new Error("oops"))).toBe("oops");
+ expect(getErrorMessage({})).toBeNull();
+ });
+
+ it("extracts error codes from error-like values", () => {
+ const codedError = Object.assign(new Error("oops"), {
+ code: "SESSION_EXPIRED",
+ });
+
+ expect(getErrorCode(codedError)).toBe("SESSION_EXPIRED");
+ expect(getErrorCode(new Error("oops"))).toBeNull();
+ expect(getErrorCode({ code: 12 })).toBeNull();
+ });
+
+ it("matches reset-worthy stream errors", () => {
+ expect(shouldResetSession(new Error("expired"))).toBe(true);
+ expect(shouldResetSession(new Error("cipher error"))).toBe(true);
+ expect(shouldResetSession(new Error("Session not found"))).toBe(true);
+ expect(shouldResetSession(new Error("Token cookie not set"))).toBe(true);
+ expect(shouldResetSession(new Error("SESSION_NOT_FOUND"))).toBe(true);
+ expect(shouldResetSession(new Error("TOKEN_COOKIE_NOT_SET"))).toBe(true);
+ expect(shouldResetSession(new Error("SESSION_CIPHER_ERROR"))).toBe(true);
+ expect(shouldResetSession(new Error("SESSION_EXPIRED"))).toBe(true);
+ expect(
+ shouldResetSession(
+ Object.assign(new Error("expired"), { code: "SESSION_EXPIRED" })
+ )
+ ).toBe(true);
+ expect(
+ shouldResetSession(
+ Object.assign(new Error("whatever"), {
+ code: "TOKEN_COOKIE_NOT_SET",
+ })
+ )
+ ).toBe(true);
+ });
+
+ it("does not match unrelated errors", () => {
+ expect(shouldResetSession(new Error("random network error"))).toBe(false);
+ expect(shouldResetSession("Unknown error")).toBe(false);
+ });
+
+ it("raises when reset response is missing x-mayu-session-id", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("\n", {
+ status: 200,
+ headers: {
+ "content-type": "text/html",
+ },
+ })
+ );
+
+ await expect(resetSessionEntirely()).rejects.toThrow(
+ "Missing x-mayu-session-id header during session reset"
+ );
+ });
+});
diff --git a/lib/mayu/client/src/session-recovery.ts b/lib/mayu/client/src/session-recovery.ts
new file mode 100644
index 00000000..91459761
--- /dev/null
+++ b/lib/mayu/client/src/session-recovery.ts
@@ -0,0 +1,75 @@
+import { SESSION_PATH } from "./constants";
+import { setTransferState } from "./transfer";
+import withViewTransition from "./view-transition";
+
+const RESET_SESSION_ERROR_CODES = new Set([
+ "EXPIRED",
+ "SESSION_EXPIRED",
+ "CIPHER_ERROR",
+ "SESSION_CIPHER_ERROR",
+ "SESSION_NOT_FOUND",
+ "TOKEN_COOKIE_NOT_SET",
+]);
+
+function normalizeErrorToken(value: string): string {
+ return value.trim().toUpperCase().replaceAll(/\s+/g, "_");
+}
+
+export function getErrorMessage(error: unknown): string | null {
+ if (typeof error === "string") return error;
+ if (error instanceof Error) return error.message;
+ return null;
+}
+
+export function getErrorCode(error: unknown): string | null {
+ if (!error || typeof error !== "object") return null;
+ if (!("code" in error)) return null;
+
+ return typeof error.code === "string" ? error.code : null;
+}
+
+export function shouldResetSession(error: unknown): boolean {
+ const code = getErrorCode(error);
+
+ if (code && RESET_SESSION_ERROR_CODES.has(normalizeErrorToken(code))) {
+ return true;
+ }
+
+ const message = getErrorMessage(error);
+ if (!message) return false;
+
+ return RESET_SESSION_ERROR_CODES.has(normalizeErrorToken(message));
+}
+
+export async function resetSessionEntirely() {
+ const [morphdom, res] = await Promise.all([
+ import("morphdom"),
+ fetch(location.pathname + location.search, {
+ method: "GET",
+ credentials: "include",
+ headers: new Headers({
+ accept: "text/html",
+ }),
+ }),
+ ]);
+
+ const html = (await res.text()).replace(/^\n/, "");
+ const sessionId = res.headers.get("x-mayu-session-id");
+
+ if (!sessionId) {
+ throw new Error("Missing x-mayu-session-id header during session reset");
+ }
+
+ console.warn(
+ `%cmorphing dom`,
+ "font-size: 4em; font-weight: bold; font-family: monospace;"
+ );
+
+ await withViewTransition(async () => {
+ morphdom.default(document.documentElement, html);
+ });
+
+ setTransferState(null);
+
+ return `${SESSION_PATH}/${sessionId}`;
+}
diff --git a/lib/mayu/client/src/stream.test.ts b/lib/mayu/client/src/stream.test.ts
new file mode 100644
index 00000000..fc321fd7
--- /dev/null
+++ b/lib/mayu/client/src/stream.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { connect, StreamError } from "./stream";
+
+describe("stream", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("parses structured error payload with code/message", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: "SESSION_NOT_FOUND",
+ message: "Session not found",
+ }),
+ {
+ status: 403,
+ headers: { "content-type": "application/json" },
+ }
+ )
+ );
+
+ await expect(connect("/.mayu/session/abc")).rejects.toEqual(
+ expect.objectContaining({
+ name: "StreamError",
+ message: "Session not found",
+ code: "SESSION_NOT_FOUND",
+ })
+ );
+ });
+
+ it("supports legacy error payload shape", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ error: "expired" }), {
+ status: 403,
+ headers: { "content-type": "application/json" },
+ })
+ );
+
+ let thrown: unknown;
+ try {
+ await connect("/.mayu/session/abc");
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(StreamError);
+ expect((thrown as StreamError).message).toBe("expired");
+ expect((thrown as StreamError).code).toBeNull();
+ });
+});
diff --git a/lib/mayu/client/src/stream.ts b/lib/mayu/client/src/stream.ts
index c2105d42..c982cc1c 100644
--- a/lib/mayu/client/src/stream.ts
+++ b/lib/mayu/client/src/stream.ts
@@ -1,175 +1,217 @@
-import { decodeMultiStream, ExtensionCodec } from "@msgpack/msgpack";
-import { stringifyJSON, retry, FatalError, sleep } from "./utils";
-import { MimeTypes } from "./MimeTypes";
-import logger from "./logger";
-import DecompressionStream from "./DecompressionStream";
-
-function createExtensionCodec() {
- const extensionCodec = new ExtensionCodec();
-
- extensionCodec.register({
- type: 0x01,
- encode() {
- throw new Error("Not implemented");
- },
- decode(buffer: Uint8Array) {
- return new Blob([buffer], { type: "application/vnd.mayu.session" });
- },
- });
+// Copyright Andreas Alin
+// License: AGPL-3.0
- return extensionCodec;
-}
+import {
+ STREAM_MIME_TYPE,
+ STREAM_CONTENT_ENCODING,
+ SESSION_MIME_TYPE,
+} from "./constants";
-async function startStream(sessionId: string, encryptedState?: Blob) {
- const res = await resume(sessionId, encryptedState);
+import supportsRequestStreams from "./supportsRequestStreams";
- if (!res.ok) {
- const text = await res.text();
+const CALLBACK_STREAM_METHOD = "PATCH";
- if (res.status == 503) {
- // Server is shutting down, so retry..
- throw new Error(`${res.status}: ${text}`);
- }
+export async function initInputStream(
+ endpoint: string,
+ state: Blob | null = null,
+ signal?: AbortSignal
+): Promise> {
+ const res = await connect(endpoint, state, signal);
- throw new FatalError(`${res.status}: ${text}`);
- }
+ if (!res.body) throw new Error("No body");
- if (!res.body) {
- throw new FatalError("body is null");
- }
+ const contentEncoding = res.headers.get("content-encoding");
- const decompressionStream = new DecompressionStream("deflate-raw");
+ if (!contentEncoding) return res.body;
- return res.body.pipeThrough(decompressionStream);
+ return res.body.pipeThrough(new DecompressionStream(contentEncoding as any));
}
-type ServerMessage = [id: string, event: string, payload: any];
-type SessionStreamMessage = [string, any];
+export class StreamError extends Error {
+ code: string | null;
-function resume(sessionId: string, encryptedState?: Blob) {
- if (!encryptedState) {
- return retry(() =>
- fetch(`/__mayu/session/${sessionId}/init`, {
- method: "POST",
- })
- );
+ constructor(message: string, code: string | null = null) {
+ super(message);
+ this.name = "StreamError";
+ this.code = code;
}
-
- return retry(() =>
- fetch(`/__mayu/session/${sessionId}/resume`, {
- method: "POST",
- headers: { "content-type": MimeTypes.MAYU_SESSION },
- body: encryptedState,
- })
- );
}
-function errorMessage(e: any) {
- if (e instanceof Error) {
- return e.message;
+function parseErrorResponse(payload: unknown): {
+ message: string;
+ code: string | null;
+} {
+ if (payload && typeof payload === "object") {
+ const code =
+ "code" in payload && typeof payload.code === "string"
+ ? payload.code
+ : null;
+
+ if ("message" in payload && typeof payload.message === "string") {
+ return { message: payload.message, code };
+ }
+
+ if ("error" in payload && typeof payload.error === "string") {
+ return { message: payload.error, code };
+ }
}
- if (typeof e === "string") {
- return e;
+ if (typeof payload === "string") {
+ return { message: payload, code: null };
}
- return String(e);
+ return { message: "Unknown stream error", code: null };
}
-export async function* sessionStream(
- sessionId: string
-): AsyncGenerator {
- let isRunning = true;
- let encryptedState: Blob | undefined;
- let isConnected = false;
- const extensionCodec = createExtensionCodec();
- let reason: string | undefined;
+export async function connect(
+ endpoint: string,
+ state: Blob | null = null,
+ signal?: AbortSignal
+): Promise {
+ console.info("🟡 Connecting to", endpoint);
+
+ let res: Response | null = null;
+
+ try {
+ res = state
+ ? await fetch(endpoint, {
+ method: "POST",
+ credentials: "include",
+ signal,
+ headers: new Headers({
+ accept: STREAM_MIME_TYPE,
+ "accept-encoding": STREAM_CONTENT_ENCODING,
+ "content-type": SESSION_MIME_TYPE,
+ }),
+ body: state,
+ })
+ : await fetch(endpoint, {
+ method: "GET",
+ credentials: "include",
+ signal,
+ headers: new Headers({
+ accept: STREAM_MIME_TYPE,
+ "accept-encoding": STREAM_CONTENT_ENCODING,
+ }),
+ });
+ } catch (e) {
+ if (e instanceof Error) {
+ throw new StreamError(e.message);
+ } else {
+ throw new StreamError("Unknown error");
+ }
+ }
+
+ if (!res.ok) {
+ let payload: unknown = null;
- while (isRunning) {
try {
- const stream = await retry(() => startStream(sessionId, encryptedState));
+ payload = await res.json();
+ } catch (_error) {}
- try {
- for await (const message of decodeMultiStream(stream, {
- extensionCodec,
- })) {
- const [id, event, payload] = message as ServerMessage;
-
- if (!isConnected) {
- isConnected = true;
- yield ["system.connected", {}];
- }
-
- if (encryptedState) {
- logger.info("Clearing encryptedState");
- encryptedState = undefined;
- }
-
- try {
- switch (event) {
- case "session.transfer":
- yield ["session.transfer", {}];
- encryptedState = payload;
- logger.info("Setting encryptedState", payload);
- break;
- case "pong":
- yield [
- "ping",
- {
- values: {
- client: performance.now() - Number(payload.pong),
- server: payload.server,
- },
- region: payload.region,
- instance: payload.instance,
- },
- ];
- break;
- case "ping":
- postCallback(sessionId, "ping", {
- pong: payload,
- ping: performance.now(),
- });
- break;
- default:
- yield [event, payload];
- }
- } catch (e) {
- reason = errorMessage(e);
- logger.error(e);
- }
- }
- } catch (e) {
- reason = errorMessage(e);
- logger.error(e);
- }
+ const { message, code } = parseErrorResponse(payload);
+ throw new StreamError(message, code);
+ }
- isConnected = false;
+ const contentType = res.headers.get("content-type");
- if (isRunning) {
- reason ||= "Stream ended unexpectedly";
- }
+ if (contentType !== STREAM_MIME_TYPE) {
+ // alert(`Unexpected content type: ${contentType}`);
+ // console.error(res);
+ throw new StreamError(`Unexpected content type: ${contentType}`);
+ }
- yield ["system.disconnected", { transferring: !!encryptedState, reason }];
- } catch (e) {
- logger.error(e);
+ console.info("🟢 Connected to", endpoint);
- if (e instanceof FatalError) {
- isRunning = false;
- isConnected = false;
- yield ["system.disconnected", { reason: e.message }];
- return;
- }
+ return res;
+}
- await sleep(1000);
- }
+export class RAFQueue {
+ onFlush: (queue: T[]) => void;
+ queue: T[];
+ raf: number | null;
+
+ constructor(onFlush: (queue: T[]) => void) {
+ this.onFlush = onFlush;
+ this.queue = [];
+ this.raf = null;
+ }
+
+ enqueue(messages: T[]) {
+ messages.forEach((msg) => this.queue.push(msg));
+ this.raf ||= requestAnimationFrame(() => this.flush());
+ }
+
+ flush() {
+ this.raf = null;
+ const queue = this.queue;
+ if (queue.length === 0) return;
+ this.queue = [];
+ this.onFlush(queue);
}
}
-async function postCallback(sessionId: string, callbackId: string, data: any) {
- return fetch(`/__mayu/session/${sessionId}/${callbackId}`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: stringifyJSON(data),
+export class JSONEncoderStream extends TransformStream {
+ constructor() {
+ super({
+ transform(chunk, controller) {
+ controller.enqueue(JSON.stringify(chunk) + "\n");
+ },
+ });
+ }
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+}
+
+export function initCallbackStream(endpoint: string, signal?: AbortSignal) {
+ if (!supportsRequestStreams) {
+ console.warn("Request streams not supported, using fallback.");
+ return initCallbackStreamFetchFallback(endpoint, signal);
+ }
+
+ const contentEncoding = "identity"; // STREAM_CONTENT_ENCODING;
+ const { readable, writable } = new TransformStream(); // new CompressionStream(contentEncoding);
+
+ void fetch(endpoint, {
+ method: CALLBACK_STREAM_METHOD,
+ headers: new Headers({
+ "content-type": STREAM_MIME_TYPE,
+ "content-encoding": contentEncoding,
+ }),
+ duplex: "half",
+ mode: "cors",
+ signal,
+ body: readable,
+ } as any).catch((error) => {
+ if (isAbortError(error)) return;
+ console.error("Callback stream error", error);
+ });
+
+ return writable;
+}
+
+function initCallbackStreamFetchFallback(
+ endpoint: string,
+ signal?: AbortSignal
+) {
+ return new WritableStream({
+ async write(body) {
+ try {
+ await fetch(endpoint, {
+ method: CALLBACK_STREAM_METHOD,
+ headers: new Headers({
+ "content-type": "application/json",
+ }),
+ mode: "cors",
+ signal,
+ body: body,
+ });
+ } catch (error) {
+ if (isAbortError(error)) return;
+ throw error;
+ }
+ },
});
}
diff --git a/lib/mayu/client/src/supportsRequestStreams.ts b/lib/mayu/client/src/supportsRequestStreams.ts
new file mode 100644
index 00000000..fa46b986
--- /dev/null
+++ b/lib/mayu/client/src/supportsRequestStreams.ts
@@ -0,0 +1,21 @@
+function supportsRequestStreams() {
+ // https://developer.chrome.com/articles/fetch-streaming-requests/#feature-detection
+ let duplexAccessed = false;
+
+ try {
+ const hasContentType = new Request("/", {
+ body: new ReadableStream(),
+ method: "POST",
+ get duplex() {
+ duplexAccessed = true;
+ return "half";
+ },
+ } as any).headers.has("Content-Type");
+
+ return duplexAccessed && !hasContentType;
+ } catch {
+ return false;
+ }
+}
+
+export default supportsRequestStreams();
diff --git a/lib/mayu/client/src/throttle.ts b/lib/mayu/client/src/throttle.ts
new file mode 100644
index 00000000..6bd8b5e5
--- /dev/null
+++ b/lib/mayu/client/src/throttle.ts
@@ -0,0 +1,31 @@
+const TIMEOUT_MS = 1_000 / 30;
+
+const ThrottledNodes = new WeakMap();
+
+type ThrottleEntry = {
+ timeout: number;
+ cb: (() => void) | null;
+};
+
+export default function throttle(target: EventTarget, cb: () => void) {
+ const entry = ThrottledNodes.get(target);
+
+ if (entry) {
+ entry.cb = cb;
+ return;
+ }
+
+ ThrottledNodes.set(target, {
+ timeout: setTimeout(() => {
+ const entry = ThrottledNodes.get(target);
+ ThrottledNodes.delete(target);
+ if (entry) {
+ clearTimeout(entry.timeout);
+ entry?.cb?.();
+ }
+ }, TIMEOUT_MS),
+ cb: null,
+ });
+
+ cb();
+}
diff --git a/lib/mayu/client/src/transfer.ts b/lib/mayu/client/src/transfer.ts
new file mode 100644
index 00000000..c0e5c598
--- /dev/null
+++ b/lib/mayu/client/src/transfer.ts
@@ -0,0 +1,9 @@
+let transferState: Blob | null = null
+
+export function setTransferState(state: Blob | null) {
+ transferState = state;
+}
+
+export function getTransferState(): Blob | null {
+ return transferState
+}
diff --git a/lib/mayu/client/src/types.ts b/lib/mayu/client/src/types.ts
deleted file mode 100644
index 8de7df95..00000000
--- a/lib/mayu/client/src/types.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type MayuNodeData = { id: number };
diff --git a/lib/mayu/client/src/utils.ts b/lib/mayu/client/src/utils.ts
deleted file mode 100644
index 103ef385..00000000
--- a/lib/mayu/client/src/utils.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-export function stringifyJSON(payload: any, space?: number) {
- return JSON.stringify(
- payload,
- (_key: string, value: any) => {
- if (typeof value === "bigint") {
- return Number(value);
- } else if (value instanceof Blob) {
- return `Blob{type: ${value.type}, size: ${value.size}}`;
- } else {
- return value;
- }
- },
- space
- );
-}
-
-export async function sleep(ms = 1000) {
- return new Promise((resolve) => {
- setTimeout(resolve, ms);
- });
-}
-
-export class FatalError extends Error {}
-
-export async function retry(fn: () => Promise): Promise {
- const maxAttempts = 10;
- let attempts = 0;
-
- while (true) {
- try {
- return await fn();
- } catch (e) {
- if (e instanceof FatalError) {
- throw e;
- }
-
- if (attempts >= maxAttempts) {
- console.error("Reached the maximum number of attempts!");
- throw e;
- }
-
- const waitTime = attempts + Math.random();
-
- console.error(
- `Got error (attempts: ${attempts}, wait: ${waitTime.toFixed(2)})`,
- e
- );
-
- const logTimes = Math.ceil(waitTime);
- const sleepTime = waitTime / logTimes;
-
- for (let i = 0; i < logTimes; i++) {
- console.warn(
- `Retrying in ${(waitTime - i * sleepTime).toFixed(2)} seconds`
- );
- await sleep(sleepTime * 1000);
- }
-
- attempts++;
- }
- }
-}
-
-export function* splitChunk(chunk: Uint8Array) {
- let offset = 0;
-
- while (offset < chunk.byteLength) {
- yield chunk.slice(offset, offset + 1024);
- offset += 1024;
- }
-}
diff --git a/lib/mayu/client/src/view-transition.ts b/lib/mayu/client/src/view-transition.ts
new file mode 100644
index 00000000..d99d1483
--- /dev/null
+++ b/lib/mayu/client/src/view-transition.ts
@@ -0,0 +1,19 @@
+type DocumentWithViewTransition = Document & {
+ startViewTransition?: (
+ update: () => void | Promise
+ ) => { updateCallbackDone?: Promise } | void;
+};
+
+export default async function withViewTransition(
+ update: () => void | Promise
+) {
+ const start = (document as DocumentWithViewTransition).startViewTransition;
+
+ if (!start) {
+ await update();
+ return;
+ }
+
+ const transition = start.call(document, update);
+ await transition?.updateCallbackDone;
+}
diff --git a/lib/mayu/client/tsconfig.json b/lib/mayu/client/tsconfig.json
index b2ba9c41..a37e1d11 100644
--- a/lib/mayu/client/tsconfig.json
+++ b/lib/mayu/client/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
- "allowJs": false,
+ "allowJs": true,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
diff --git a/lib/mayu/client/vitest.config.ts b/lib/mayu/client/vitest.config.ts
new file mode 100644
index 00000000..bd553c13
--- /dev/null
+++ b/lib/mayu/client/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ include: ["src/**/*.test.ts"],
+ clearMocks: true,
+ },
+});
diff --git a/lib/mayu/colors.rb b/lib/mayu/colors.rb
deleted file mode 100644
index 111c5114..00000000
--- a/lib/mayu/colors.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- module Colors
- extend T::Sig
-
- sig { params(str: String, t: Float).returns(String) }
- def self.rainbow(str, t = Time.now.to_f)
- str
- .each_line
- .map do |line|
- line
- .chars
- .map
- .with_index do |ch, i|
- next ch if ch.strip.empty?
-
- r, g, b =
- 3
- .times
- .map { _1 / 3.0 * Math::PI }
- .map { _1 + i / 10.0 }
- .map { Math.sin(_1 - t)**2 }
- .map { (_1 * 255).to_i }
-
- format("\e[38;2;%d;%d;%dm%s", r, g, b, ch)
- end
- .join
- end
- .join + "\e[0m"
- end
- end
-end
diff --git a/lib/mayu/commands.rb b/lib/mayu/commands.rb
index cfde95bd..0e2d2016 100644
--- a/lib/mayu/commands.rb
+++ b/lib/mayu/commands.rb
@@ -1,60 +1,45 @@
-# typed: strict
# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
-require_relative "configuration"
-require_relative "commands/base"
-require_relative "colors"
-require_relative "banner"
+require "samovar"
+require_relative "commands/dev"
+require_relative "commands/transform"
+require_relative "commands/routes"
+require_relative "commands/build"
+require_relative "commands/start"
+require_relative "commands/init"
+require_relative "version"
module Mayu
module Commands
- extend T::Sig
+ class Application < Samovar::Command
+ nested :command,
+ {
+ "init" => Init,
+ "dev" => Dev,
+ "transform" => Transform,
+ "routes" => Routes,
+ "build" => Build,
+ "start" => Start
+ }
- sig { params(argv: T::Array[String]).void }
- def self.call(argv)
- puts Colors.rainbow(BANNER)
+ def call
+ print_header
- case argv
- in ["dev", *rest]
- # Initialize RMagick on start to avoid the following error:
- # objc[88493]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called.
- # We cannot safely call it or ignore it in the fork() child process. Crashing instead.
- require "rmagick"
- require_relative "server"
- Server.start(load_config(:dev))
- in ["devbundle", *rest]
- require_relative "server"
- Server.start(load_config(:devbundle))
- in ["build", *rest]
- require_relative "commands/build"
- Commands::Build.new(
- load_config(
- :prod,
- overrides: {
- "use_bundle" => false,
- "secret_key" => "not important, just needed to avoid an exception"
- }
- )
- ).call(rest)
- in ["serve", *rest]
- require_relative "server"
- Server.start(load_config(:prod))
- in ["init", *rest]
- require_relative "commands/init"
- Commands::Init.new.call(rest)
- else
- puts "Invalid args: #{argv.inspect}"
- exit 1
+ @command ? @command.call : print_usage
end
- end
- sig do
- params(env: Symbol, overrides: T::Hash[String, T.untyped]).returns(
- Configuration
- )
+ private
+
+ def print_header
+ puts "\e[1;95mMayu v#{Mayu::VERSION}\e[0m"
+ end
end
- def self.load_config(env, overrides: {})
- Mayu::Configuration.load_config(env, pwd: Dir.pwd, overrides:)
+
+ def self.call(argv)
+ Application.call(argv)
end
end
end
diff --git a/lib/mayu/commands/base.rb b/lib/mayu/commands/base.rb
deleted file mode 100644
index 962ba131..00000000
--- a/lib/mayu/commands/base.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- module Commands
- class Base
- extend T::Sig
-
- sig { returns(Configuration) }
- attr_reader :configuration
-
- sig { params(configuration: Configuration).void }
- def initialize(configuration)
- @configuration = configuration
- end
-
- sig { params(argv: T::Array[String]).void }
- def call(argv)
- end
- end
- end
-end
diff --git a/lib/mayu/commands/build.rb b/lib/mayu/commands/build.rb
index d3734a9d..743f31fb 100644
--- a/lib/mayu/commands/build.rb
+++ b/lib/mayu/commands/build.rb
@@ -1,80 +1,66 @@
-# typed: strict
# frozen_string_literal: true
-
-require_relative "base"
-require_relative "../environment"
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
module Mayu
module Commands
- class Build < Base
- extend T::Sig
-
- sig { params(argv: T::Array[String]).void }
- def call(argv)
- require "fileutils"
-
- Async do
- started_at = Time.now.to_f
-
- metrics = AppMetrics.setup(Prometheus::Client.registry)
- environment = Environment.new(configuration, metrics)
- environment.init_js
- resources = environment.resources
-
- components = []
-
- components.push(File.join("/app", "root"))
+ class Build < Samovar::Command
+ self.description = "Build app for production"
+
+ options do
+ option(
+ "--filename ",
+ "Filename of the generated bundle",
+ default: "app.mayu-bundle"
+ )
+
+ option(
+ "--concurrency ",
+ "Number of concurrent tasks for generating assets",
+ default: 4
+ ) { _1.to_i }
+ end
- environment.routes.each do |route|
- route.layouts.each do |layout|
- components.push(File.join("/app", "pages", layout))
+ def call
+ require_relative "../system_config"
+ require_relative "../environment"
+ require_relative "../routes"
+ require_relative "../component"
+
+ Sync do
+ elapsed =
+ Async::Clock.measure do
+ Environment.with(:development) do |environment|
+ environment.modules.import("/root.haml")
+
+ environment.router.all_templates.each do |template|
+ environment.modules.import(File.join("/pages", template))
+ end
+
+ environment.modules.update_overall_order
+
+ environment
+ .modules
+ .generate_assets(
+ environment.assets_dir,
+ concurrency: options[:concurrency],
+ forever: false
+ )
+ .wait
+
+ File.write(options[:filename], environment.dump)
+ end
+ rescue => e
+ Console.logger.error(self, e)
+ raise
end
- components.push(File.join("/app", "pages", route.template))
- end
-
- components.each do |component|
- resources.load_resource(component).type.component
- end
-
- File.write("app-graph.md", <<~EOF)
- ```mermaid
- #{resources.dependency_graph.to_mermaid_source.chomp}
- ```
- EOF
-
- mermaid_url = resources.mermaid_url
-
- assets_dir = environment.path(:assets)
- FileUtils.mkdir_p(assets_dir)
- files_to_remove = Dir.glob(File.join(assets_dir, "*"))
-
- unless files_to_remove.empty?
- puts "\e[33mRemoving #{files_to_remove.size} files from #{assets_dir}\e[0m"
- FileUtils.rm(files_to_remove)
- end
-
- puts "\e[35mGenerating assets\e[0m"
-
- resources.generate_assets(
- assets_dir,
- concurrency: Async::Container.processor_count,
- forever: false
- ).wait
-
- filename = configuration.paths.bundle_filename
- puts "\e[35mWriting \e[1m#{filename}\e[0m"
- File.write(filename, resources.dump)
-
- puts
puts format(
- "\e[36mBuilt app in \e[1m%.2f seconds\e[0m",
- Time.now.to_f - started_at
+ "\e[32mBuilt \e[1m%s\e[22m in \e[1m%.2fs\e[0m",
+ options[:filename],
+ elapsed
)
-
- puts
- puts "View the app graph:"
- puts "\e[34;4m#{resources.mermaid_url}\e[0m"
end
end
end
diff --git a/lib/mayu/commands/dev.rb b/lib/mayu/commands/dev.rb
new file mode 100644
index 00000000..c45225c2
--- /dev/null
+++ b/lib/mayu/commands/dev.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Commands
+ class Dev < Samovar::Command
+ self.description = "Start the development server"
+
+ def call
+ require_relative "../configuration"
+ require_relative "../server"
+
+ Configuration.with(:development) do |config|
+ Mayu::Server.new(config:, mayu_env: :development).run
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/commands/init.rb b/lib/mayu/commands/init.rb
index 2f1cb961..c58f3e5f 100644
--- a/lib/mayu/commands/init.rb
+++ b/lib/mayu/commands/init.rb
@@ -1,121 +1,142 @@
-# typed: strict
# frozen_string_literal: true
-
-require "reline"
-require "shellwords"
-require_relative "base"
-require_relative "../environment"
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
module Mayu
module Commands
- class Init < Base
- class NewAppConfig < T::Struct
- extend T::Sig
-
- const :name, String
- const :path, String
- const :primary_region, String
- const :enable_yjit, T::Boolean
- end
+ class Init < Samovar::Command
+ NewAppConfig = Data.define(:name, :path, :fly_region, :enable_yjit)
- extend T::Sig
+ self.description = "Initialize a new Mayu app"
- sig { void }
- def initialize
- end
+ options do
+ option("--name ", "Application name")
- sig { params(argv: T::Array[String]).void }
- def call(argv)
- app_name = T.let(argv.first.to_s, String)
+ option(
+ "--fly-region ",
+ "Primary fly.io region, https://fly.io/docs/reference/regions/#fly-io-regions"
+ )
+ end
- app_name = read_app_name unless valid_app_name?(app_name)
+ def call
+ require "reline"
+ require "fileutils"
+ require "json"
- if File.exist?(app_name)
- puts "#{app_name} already exists"
- exit 1
- end
+ name = read_app_name
+ fly_region = read_fly_region(options[:fly_region])
config =
NewAppConfig.new(
- path: File.join(Dir.pwd, app_name),
- name: app_name,
- primary_region: read_region,
- enable_yjit: read_boolean("Do you want to enable yjit?")
+ name:,
+ path: File.expand_path(name),
+ fly_region:,
+ enable_yjit: true
)
- puts "\nInitializing #{config.name}"
+ if File.exist?(config.path)
+ puts "\e[31mPath already exists: #{config.path}\e[0m"
+ return
+ end
+
+ puts "",
+ "\e[34mInitializing \e[1m#{config.name}\e[22m at \e[1m#{config.path}\e[0m"
FileUtils.cp_r(File.join(__dir__, "init", "template"), config.path)
- update_fly_toml(config)
- puts "Installing dependencies"
- system("bundle install > /dev/null")
+ Dir.chdir(config.path) do
+ update_fly_toml(config)
- puts "\n\e[32mSuccess!\e[0m Created \e[1m#{config.name}\e[0m at \e[1m#{config.path}\e[0m"
+ hide_cursor do
+ print "\e[33mInstalling dependencies\e[0m"
+
+ if system("bundle install > /dev/null")
+ puts "\e[G\e[2K\e[32mInstalling dependencies ✅\e[0m"
+ else
+ puts "\e[G\e[2K\e[31;1mbundle install\e[22m failed, please try manually!\e[0m"
+ end
+ end
+ end
+
+ puts "",
+ "\e[32mInitialized \e[1m#{config.name}\e[22m at \e[1m#{config.path}\e[0m"
end
private
- sig { params(config: NewAppConfig).void }
- def update_fly_toml(config)
- Dir.chdir(config.path) do
- File.write(
- "fly.toml",
- File
- .read("fly.toml")
- .sub(/^app\s*=.*/, "app = \"#{config.name}\"")
- .sub(
- /^primary_region\s*=.*/,
- "primary_region = \"#{config.primary_region}\""
- )
- .sub(/^(\s+ENABLE_YJIT)\s*=.*/) do
- "#{$1} = #{config.enable_yjit.to_s.inspect}"
- end
- )
- end
+ def read_app_name(name = options[:name])
+ name = ask("App name:") until valid_app_name?(name)
+
+ name
end
- sig { params(app_name: String).returns(T::Boolean) }
- def valid_app_name?(app_name)
- app_name in /\A[a-z][a-z0-9_-]+\z/
+ def valid_app_name?(name)
+ name in /\A[a-z][a-z0-9_-]*\z/i
end
- sig { returns(String) }
- def read_app_name
- loop do
- app_name = readline("What is your app called?")
- return app_name if valid_app_name?(app_name)
- puts "app name needs to start with a letter and only include \e[1ma-z 0-9 - _\e[0m"
+ def read_fly_region(fly_region = options[:fly_region])
+ fly_regions = load_fly_regions
+
+ until valid_fly_region?(fly_regions, fly_region)
+ begin
+ if fly_regions
+ Reline.autocompletion = true
+ Reline.completion_proc = ->(word) do
+ fly_regions.map { it["Code"] }.select { it.start_with?(word) }
+ end
+ end
+
+ fly_region = ask("Fly.io primary region:")
+ ensure
+ Reline.completion_proc = nil
+ Reline.autocompletion = false
+ end
end
+
+ fly_region
end
- sig { returns(String) }
- def read_region
- puts
- puts "See all valid regions with \e[1;34mfly platform regions\e[0m"
+ def load_fly_regions
+ JSON.parse(`flyctl platform regions --json`)
+ rescue Errno::ENOENT
+ $stderr.puts "\e[31mCould not find flyctl executable\e[0m"
+ nil
+ end
- loop do
- region = readline("In what region do you want to deploy your app?")
- return region if region in /\A[a-z]{3}\z/
- puts "\nregion is a 3 letter code, see them with \e[1;34mfly platform regions\e[0m"
+ def valid_fly_region?(fly_regions, region)
+ if fly_regions
+ fly_regions.any? { it["Code"] == region }
+ else
+ region in /\A[a-z]{3}\z/
end
end
- sig { params(question: String).returns(String) }
- def readline(question)
+ def ask(question)
Reline.readline("\e[1m#{question}\e[0m ", false)
end
- sig { params(question: String).returns(T::Boolean) }
- def read_boolean(question)
- loop do
- case readline("#{question} [y/n]").downcase
- in /\Ay/
- return true
- in /\An/
- return false
+ def update_fly_toml(config)
+ File
+ .read("fly.toml")
+ .sub(/^app\s*=.*/, "app = \"#{config.name}\"")
+ .sub(
+ /^primary_region\s*=.*/,
+ "primary_region = \"#{config.fly_region}\""
+ )
+ .sub(/^(\s+ENABLE_YJIT)\s*=.*/) do
+ "#{$1} = #{config.enable_yjit.to_s.inspect}"
end
- end
+ .then { File.write("fly.toml", it) }
+ end
+
+ def hide_cursor(&)
+ $stdin.echo = false
+ print "\e[?25l"
+ yield
+ ensure
+ print "\e[?25h"
+ $stdin.echo = true
end
end
end
diff --git a/lib/mayu/commands/init/.gitignore b/lib/mayu/commands/init/.gitignore
deleted file mode 100644
index 14560886..00000000
--- a/lib/mayu/commands/init/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-template/.assets
-template/vendor
diff --git a/lib/mayu/commands/init/template/.dockerignore b/lib/mayu/commands/init/template/.dockerignore
index 0221226d..118f6d35 100644
--- a/lib/mayu/commands/init/template/.dockerignore
+++ b/lib/mayu/commands/init/template/.dockerignore
@@ -1,4 +1,12 @@
-vendor
-metrics.ipc
-fly.toml
-.assets/*
+/*.mayu-bundle
+/vendor
+/.assets
+/Dockerfile
+/dist
+/tmp
+/fly.toml
+
+*.ipc
+.DS_store
+
+*.env*
diff --git a/lib/mayu/commands/init/template/.gitignore b/lib/mayu/commands/init/template/.gitignore
index 95e02432..a6be3195 100644
--- a/lib/mayu/commands/init/template/.gitignore
+++ b/lib/mayu/commands/init/template/.gitignore
@@ -1,2 +1,8 @@
-vendor/
-metrics.ipc
+/vendor
+/*.mayu-bundle
+/tmp
+
+*.ipc
+.DS_store
+
+.env*
diff --git a/lib/mayu/commands/init/template/Dockerfile b/lib/mayu/commands/init/template/Dockerfile
index 6decc356..be1317a4 100644
--- a/lib/mayu/commands/init/template/Dockerfile
+++ b/lib/mayu/commands/init/template/Dockerfile
@@ -5,6 +5,7 @@ ARG BUNDLE_WITHOUT=development:test
ARG BUNDLE_PATH=vendor/bundle
ENV BUNDLE_PATH ${BUNDLE_PATH}
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
+
RUN gem install -N bundler -v ${BUNDLER_VERSION}
RUN apk update && apk add --no-cache \
curl bash jemalloc gcompat libsodium
@@ -25,5 +26,6 @@ COPY .fly /fly
COPY --from=install /app /app
ENV PORT 3000
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
+
ENTRYPOINT ["/fly/entrypoint.sh"]
-CMD ["bin/mayu", "serve", "--disable-sorbet"]
+CMD ["bin/mayu", "start"]
diff --git a/lib/mayu/commands/init/template/Gemfile.lock b/lib/mayu/commands/init/template/Gemfile.lock
deleted file mode 100644
index 927baf8f..00000000
--- a/lib/mayu/commands/init/template/Gemfile.lock
+++ /dev/null
@@ -1,137 +0,0 @@
-GEM
- remote: https://rubygems.org/
- specs:
- async (2.8.0)
- console (~> 1.10)
- fiber-annotation
- io-event (~> 1.1)
- timers (~> 4.1)
- async-container (0.16.12)
- async
- async-io
- async-http (0.61.0)
- async (>= 1.25)
- async-io (>= 1.28)
- async-pool (>= 0.2)
- protocol-http (~> 0.25.0)
- protocol-http1 (~> 0.16.0)
- protocol-http2 (~> 0.15.0)
- traces (>= 0.10.0)
- async-io (1.38.1)
- async
- async-pool (0.4.0)
- async (>= 1.25)
- base64 (0.2.0)
- brotli (0.4.0)
- citrus (3.0.2)
- coderay (1.1.3)
- console (1.23.3)
- fiber-annotation
- fiber-local
- ffi (1.16.3)
- fiber-annotation (0.2.0)
- fiber-local (1.0.0)
- haml (6.3.0)
- temple (>= 0.8.2)
- thor
- tilt
- image_size (3.2.0)
- io-event (1.4.1)
- json (2.7.1)
- kramdown (2.4.0)
- rexml
- listen (3.7.1)
- rb-fsevent (~> 0.10, >= 0.10.3)
- rb-inotify (~> 0.9, >= 0.9.10)
- localhost (1.1.10)
- mayu-css (0.1.2-arm64-darwin)
- mayu-live (0.0.6)
- async (~> 2.8.0)
- async-container (~> 0.16.12)
- async-http (~> 0.61.0)
- base64 (~> 0.2.0)
- brotli (~> 0.4.0)
- image_size (~> 3.2.0)
- kramdown (~> 2.4.0)
- listen (~> 3.7.1)
- localhost (~> 1.1.9)
- mayu-css (~> 0.1.2)
- mime-types (~> 3.4.1)
- msgpack (~> 1.6.0)
- nanoid (~> 2.0.0)
- prometheus-client (~> 4.0.0)
- protocol-http (~> 0.25.0)
- pry (~> 0.14.2)
- rack (>= 3.0.4.1, < 3.0.9.0)
- rake (~> 13.0.6)
- rbnacl (~> 7.1.1)
- rmagick (~> 5.3.0)
- rouge (~> 4.0.0)
- sorbet-runtime (~> 0.5.10634)
- source_map (~> 3.0.1)
- syntax_tree (~> 5.3.0)
- syntax_tree-haml (~> 3.0.0)
- syntax_tree-xml (~> 0.1.0)
- terminal-table (~> 3.0.2)
- toml-rb (~> 2.2.0)
- method_source (1.0.0)
- mime-types (3.4.1)
- mime-types-data (~> 3.2015)
- mime-types-data (3.2023.1205)
- msgpack (1.6.1)
- nanoid (2.0.0)
- pkg-config (1.5.6)
- prettier_print (1.2.1)
- prometheus-client (4.0.0)
- protocol-hpack (1.4.2)
- protocol-http (0.25.0)
- protocol-http1 (0.16.1)
- protocol-http (~> 0.22)
- protocol-http2 (0.15.1)
- protocol-hpack (~> 1.4)
- protocol-http (~> 0.18)
- pry (0.14.2)
- coderay (~> 1.1)
- method_source (~> 1.0)
- rack (3.0.8)
- rake (13.0.6)
- rb-fsevent (0.11.2)
- rb-inotify (0.10.1)
- ffi (~> 1.0)
- rbnacl (7.1.1)
- ffi
- rexml (3.2.6)
- rmagick (5.3.0)
- pkg-config (~> 1.4)
- rouge (4.0.1)
- sorbet-runtime (0.5.11188)
- source_map (3.0.1)
- json
- syntax_tree (5.3.0)
- prettier_print (>= 1.2.0)
- syntax_tree-haml (3.0.0)
- haml (>= 5.2, != 6.0.0)
- prettier_print (>= 1.0.0)
- syntax_tree (>= 5.0.1)
- syntax_tree-xml (0.1.0)
- prettier_print
- syntax_tree (>= 2.0.1)
- temple (0.10.3)
- terminal-table (3.0.2)
- unicode-display_width (>= 1.1.1, < 3)
- thor (1.3.0)
- tilt (2.3.0)
- timers (4.3.5)
- toml-rb (2.2.0)
- citrus (~> 3.0, > 3.0)
- traces (0.11.1)
- unicode-display_width (2.5.0)
-
-PLATFORMS
- arm64-darwin
-
-DEPENDENCIES
- mayu-live
-
-BUNDLED WITH
- 2.5.3
diff --git a/lib/mayu/commands/init/template/README.md b/lib/mayu/commands/init/template/README.md
index c53abeea..3c63ed03 100644
--- a/lib/mayu/commands/init/template/README.md
+++ b/lib/mayu/commands/init/template/README.md
@@ -32,7 +32,7 @@ When asked if you like to copy the configuration, choose `yes`.
When asked if you want to tweak settings, choose `yes`.
-You will need at least 512 MB memory.
+You will probably need at least 512 MB memory.
Set a secret key with the following command:
diff --git a/lib/mayu/commands/init/template/app/components/Layout/Header.haml b/lib/mayu/commands/init/template/app/components/Layout/Header.haml
index a1c1b222..26241acd 100644
--- a/lib/mayu/commands/init/template/app/components/Layout/Header.haml
+++ b/lib/mayu/commands/init/template/app/components/Layout/Header.haml
@@ -1,5 +1,5 @@
:ruby
- Image = svg('./header.svg')
+ Image = import('./header.svg')
%header
%h1
diff --git a/lib/mayu/commands/init/template/app/pages/layout.haml b/lib/mayu/commands/init/template/app/pages/layout.haml
index 92678196..f7c701e8 100644
--- a/lib/mayu/commands/init/template/app/pages/layout.haml
+++ b/lib/mayu/commands/init/template/app/pages/layout.haml
@@ -1,6 +1,6 @@
:ruby
- Header = import('/app/components/Layout/Header')
- Footer = import('/app/components/Layout/Footer')
+ Header = import('/components/Layout/Header')
+ Footer = import('/components/Layout/Footer')
.layout
%Header
diff --git a/lib/mayu/commands/init/template/app/pages/page.haml b/lib/mayu/commands/init/template/app/pages/page.haml
index 81a63a8b..74afaa7b 100644
--- a/lib/mayu/commands/init/template/app/pages/page.haml
+++ b/lib/mayu/commands/init/template/app/pages/page.haml
@@ -1,6 +1,6 @@
:ruby
Box = import("./Box")
- Dolphin = svg("./mayu-dolphin.svg")
+ Dolphin = import("./mayu-dolphin.svg")
.grid
%Box
@@ -11,9 +11,9 @@
%Box.links
%h3 Links
- %a.link(target="_blank" href="https://mayu.live/docs")
+ %a(target="_blank" href="https://mayu.live/docs")
Documentation
- %a.link(target="_blank" href="https://github.com/mayu-live/framework")
+ %a(target="_blank" href="https://github.com/mayu-live/framework")
GitHub
:css
diff --git a/lib/mayu/commands/init/template/app/root.haml b/lib/mayu/commands/init/template/app/root.haml
index ecde8588..b98cdb69 100644
--- a/lib/mayu/commands/init/template/app/root.haml
+++ b/lib/mayu/commands/init/template/app/root.haml
@@ -1,11 +1,10 @@
:ruby
- Hexagons = svg('./hexagons.svg')
-%html
- %head
- %meta(name="charset" value="utf-8")
- %meta(name="generator" value="Mayu #{Mayu::VERSION}")
- %meta(name="viewport" content="width=device-width, initial-scale=1")
- %title Mayu App
- %body
- .background{style: { mask_image: "url(#{Hexagons})" }}
- %slot
+ Hexagons = import('./hexagons.svg')
+%head
+ %meta(name="charset" value="utf-8")
+ %meta(name="generator" value="Mayu #{Mayu::VERSION}")
+ %meta(name="viewport" content="width=device-width, initial-scale=1")
+ %title Mayu App
+%body
+ .background{style: { mask_image: "url(#{Hexagons})" }}
+ %slot
diff --git a/lib/mayu/commands/init/template/bin/mayu b/lib/mayu/commands/init/template/bin/mayu
index 1897de41..b087f3a4 100755
--- a/lib/mayu/commands/init/template/bin/mayu
+++ b/lib/mayu/commands/init/template/bin/mayu
@@ -4,6 +4,4 @@
require "rubygems"
require "bundler/setup"
-require "mayu/version"
-
load Gem.bin_path("mayu-live", "mayu")
diff --git a/lib/mayu/commands/init/template/fly.toml b/lib/mayu/commands/init/template/fly.toml
index a71609c7..0e2c4456 100644
--- a/lib/mayu/commands/init/template/fly.toml
+++ b/lib/mayu/commands/init/template/fly.toml
@@ -1,7 +1,4 @@
-# fly.toml app configuration file generated for mayu-bold-shape-8295 on 2024-01-13T15:35:00-05:00
-#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
-#
app = "mayu"
primary_region = "bog"
@@ -33,6 +30,7 @@ kill_timeout = "5s"
handlers = ["tls"]
[services.ports.tls_options]
alpn = ["h2"]
+
[services.concurrency]
type = "connections"
hard_limit = 25
@@ -44,9 +42,9 @@ kill_timeout = "5s"
grace_period = "1s"
[[vm]]
+ memory = "512mb"
cpu_kind = "shared"
cpus = 1
- memory_mb = 512
[[metrics]]
port = 9092
diff --git a/lib/mayu/commands/init/template/mayu.toml b/lib/mayu/commands/init/template/mayu.toml
index 4d952287..4d47294b 100644
--- a/lib/mayu/commands/init/template/mayu.toml
+++ b/lib/mayu/commands/init/template/mayu.toml
@@ -1,57 +1,41 @@
-[dev]
+[development]
secret_key = "dev"
- [dev.server]
- scheme = "https"
- host = "localhost"
- port = 9292
+ [development.server]
+ listen = "https://localhost:9292"
- count = 1
- hot_swap = true
+ hmr = true
render_exceptions = true
self_signed_cert = true
generate_assets = true
- [dev.metrics]
- enabled = true
-
-[devbundle]
- secret_key = "dev"
- use_bundle = true
+ session_timeout_seconds = 15
+ transfer_timeout_seconds = 10
+ cookie_timeout_seconds = 60
- [devbundle.server]
- scheme = "https"
- host = "localhost"
- port = 9292
- render_exceptions = true
- self_signed_cert = true
-
- hot_swap = false
+ [development.metrics]
+ enabled = true
+ listen = "http://localhost:9091"
- count = 4
- forks = 2
+[production]
+ secret_key = "$MAYU_SECRET_KEY"
- [devbundle.metrics]
- enabled = true
- port = 9091
- host = "0.0.0.0"
+ [production.server]
+ listen = "https://0.0.0.0:3333"
-[prod]
- use_bundle = true
+ hmr = false
- [prod.server]
- scheme = "http"
- host = "0.0.0.0"
- port = 3000
+ render_exceptions = false
+ self_signed_cert = true
- hot_swap = false
+ generate_assets = false
- count = 2
- forks = 1
+ session_timeout_seconds = 15
+ transfer_timeout_seconds = 10
+ cookie_timeout_seconds = 60
- [prod.metrics]
+ [production.metrics]
enabled = true
- port = 9091
- host = "0.0.0.0"
+ listen = "http://0.0.0.0:9091"
diff --git a/lib/mayu/commands/routes.rb b/lib/mayu/commands/routes.rb
new file mode 100644
index 00000000..4ad5f270
--- /dev/null
+++ b/lib/mayu/commands/routes.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Commands
+ class Routes < Samovar::Command
+ RESET = "\e[0m"
+ FORMATTED_SLASH = "\e[2m/#{RESET}"
+ PARAM_FORMAT = "\e[1;34m%s#{RESET}"
+ SPLAT_PARAM_FORMAT = "\e[1;33m%s#{RESET}"
+
+ self.description = "Print routes"
+
+ options { option "--regexp", "Include regexp patterns", default: false }
+
+ def call
+ require "terminal-table"
+ require_relative "../environment"
+ require_relative "../routes"
+
+ Configuration.with(:development) do |config|
+ environment = Environment.new(config)
+
+ puts(
+ Terminal::Table.new do |t|
+ t.style = { all_separators: true, border: :unicode }
+ t.headings =
+ [
+ "Path",
+ ("Regexp" if @options[:regexp]),
+ "Page",
+ "Layouts"
+ ].compact.map { "\e[1m#{_1}\e[0m" }
+
+ environment.router.routes.each do |route|
+ t.add_row(
+ [
+ case format_segments(route.segments)
+ in ""
+ FORMATTED_SLASH
+ in path
+ path
+ end,
+ (route.regexp.inspect if @options[:regexp]),
+ Pathname.new(
+ File.join(environment.pages_dir, route.views.page)
+ ).relative_path_from(environment.config.root),
+ route
+ .layouts
+ .map do |layout|
+ Pathname.new(
+ File.join(environment.pages_dir, layout)
+ ).relative_path_from(environment.config.root)
+ end
+ .join("\n")
+ ].compact
+ )
+ end
+ end
+ )
+ end
+ end
+
+ private
+
+ def format_segments(segments)
+ segments
+ .map do |segment|
+ case segment
+ in Mayu::Routes::Param
+ format(PARAM_FORMAT, segment)
+ in Mayu::Routes::SplatParam
+ format(SPLAT_PARAM_FORMAT, segment)
+ in Mayu::Routes::Group
+ nil
+ else
+ segment
+ end
+ end
+ .compact
+ .join(FORMATTED_SLASH)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/commands/start.rb b/lib/mayu/commands/start.rb
new file mode 100644
index 00000000..c555a7b6
--- /dev/null
+++ b/lib/mayu/commands/start.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Commands
+ class Start < Samovar::Command
+ self.description = "Start the production server"
+
+ options do
+ option(
+ "--filename ",
+ "Filename of the generated bundle",
+ default: "app.mayu-bundle"
+ )
+ end
+
+ def call
+ unless File.exist?(options[:filename])
+ puts "\e[31mCould not find \e[1m#{options[:filename]}\e[0m"
+ puts "Try \e[1mbundle exec mayu build\e[0m to build the app."
+ exit 1
+ end
+
+ print_jit_message(:YJIT)
+ print_jit_message(:ZJIT)
+
+ require_relative "../configuration"
+ require_relative "../server"
+
+ Configuration.with(:production) do |config|
+ Mayu::Server.new(
+ config:,
+ mayu_env: :production,
+ bundle_filename: options[:filename]
+ ).run
+ end
+ rescue Interrupt
+ end
+
+ private
+
+ def print_jit_message(const_name)
+ if RubyVM.const_defined?(const_name)
+ if RubyVM.const_get(const_name).enabled?
+ puts "\e[1m#{const_name} is enabled!\e[0m"
+ else
+ puts "\e[2m#{const_name} is disabled!\e[0m"
+ end
+ else
+ puts "\e[2m#{const_name} is not supported\e[0m"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/commands/transform.rb b/lib/mayu/commands/transform.rb
new file mode 100644
index 00000000..229e60c3
--- /dev/null
+++ b/lib/mayu/commands/transform.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "samovar"
+
+module Mayu
+ module Commands
+ class Transform < Samovar::Command
+ self.description = "Transform Haml/CSS -> Ruby"
+
+ options do
+ option "--no-line-numbers", "Disable line numbers", default: false
+ option "--no-colors", "Disable syntax highlighting", default: false
+ end
+
+ one :path, "Path to file to transform", required: true
+
+ def call
+ require "rouge"
+ require "syntax_tree"
+ require_relative "../modules/loaders"
+
+ transform(
+ File.read(@path),
+ @path,
+ line_numbers: !@options[:no_line_numbers],
+ colors: !@options[:no_colors]
+ )
+ end
+
+ private
+
+ def transform(source, path, line_numbers:, colors:)
+ formatter = CodeFormatter.new(line_numbers:, colors:)
+
+ case extname = File.extname(path)
+ when ".haml"
+ transform_haml(formatter, source, path)
+ when ".rb"
+ transform_ruby(formatter, source, path)
+ when ".css"
+ transform_css(formatter, source, path)
+ else
+ puts "Can't transform #{extname}-files"
+ end
+ end
+
+ def transform_haml(formatter, source, path)
+ loading_file =
+ Mayu::Modules::Loaders::LoadingFile.new(
+ root: Dir.pwd,
+ path:,
+ source:,
+ digest: nil
+ ).load_source
+
+ puts "\e[1;3mInput:\e[0;2m #{path}\e[0m"
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::Haml)
+
+ loading_file =
+ Mayu::Modules::Loaders::Haml[
+ component_base_class: "Mayu::Component::Base",
+ using: ["Mayu::Component::CSSUnits::Refinements"],
+ factory: "H"
+ ].call(loading_file)
+
+ puts "\e[1;3mOutput:\e[0m"
+
+ formatter.handle_parse_error(loading_file.source.strip) do
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::Ruby)
+ end
+ end
+
+ def transform_ruby(formatter, source, path)
+ loading_file =
+ Mayu::Modules::Loaders::LoadingFile.new(
+ root: Dir.pwd,
+ path:,
+ source:,
+ digest: nil
+ ).load_source
+
+ puts "\e[1;3mInput:\e[0;2m #{path}\e[0m"
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::Haml)
+
+ loading_file = Mayu::Modules::Loaders::Ruby[].call(loading_file)
+
+ puts "\e[1;3mOutput:\e[0m"
+
+ formatter.handle_parse_error(loading_file.source.strip) do
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::Ruby)
+ end
+ end
+
+ def transform_css(formatter, source, path)
+ loading_file =
+ Mayu::Modules::Loaders::LoadingFile.new(
+ root: Dir.pwd,
+ path:,
+ source:,
+ digest: nil
+ ).load_source
+
+ puts "\e[1mInput:\e[0;2m #{path}\e[0m"
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::CSS)
+
+ loading_file = Mayu::Modules::Loaders::CSS.new.call(loading_file)
+
+ puts "\e[1mOutput:\e[0m"
+
+ formatter.handle_parse_error(loading_file.source.strip) do
+ puts formatter.format(loading_file.source.strip, Rouge::Lexers::Ruby)
+ end
+ end
+
+ class CodeFormatter
+ def initialize(
+ line_numbers:,
+ colors:,
+ theme: Rouge::Themes::Monokai.new
+ )
+ @line_numbers = line_numbers
+ @colors = colors
+ @formatter = Rouge::Formatters::Terminal256.new(theme:)
+ end
+
+ def format(source, lexer)
+ source
+ .chomp
+ .then { colorize(_1, lexer) }
+ .then { prepend_line_numbers(_1) }
+ end
+
+ def handle_parse_error(source)
+ yield
+ rescue SyntaxTree::Parser::ParseError => e
+ log_parse_error(source, e)
+ raise
+ end
+
+ private
+
+ def colorize(source, lexer)
+ @colors ? @formatter.format(lexer.lex(source)) : source
+ end
+
+ def prepend_line_numbers(lines, start_line: 1, error_line: nil)
+ return lines unless @line_numbers
+
+ number_format = "\e[38;5;250;48;5;236m%3d \e[0m"
+ error_format = "\e[41m%s\e[0m"
+
+ lines
+ .each_line
+ .map
+ .with_index(start_line) do |line, i|
+ if error_line == i
+ Kernel.format(error_format, line.chomp) + "\n"
+ else
+ line
+ end.prepend(Kernel.format(number_format, i))
+ end
+ end
+
+ def extract_lines(str, from, to)
+ str.each_line.to_a[from..to] || []
+ end
+
+ def log_parse_error(source, e)
+ start_line = [0, 0].max
+ formatted_source =
+ prepend_line_numbers(
+ extract_lines(source.to_s, start_line, -1),
+ start_line: start_line + 1,
+ error_line: e.lineno
+ ).join
+
+ puts(<<~ERROR)
+ #{e.message} on line #{e.lineno} col #{e.column}
+ #{formatted_source}
+ ERROR
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/component.rb b/lib/mayu/component.rb
index 7f057d0d..8310cd12 100644
--- a/lib/mayu/component.rb
+++ b/lib/mayu/component.rb
@@ -1,54 +1,11 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
-require_relative "vdom/interfaces"
-require_relative "component/wrapper"
require_relative "component/base"
module Mayu
module Component
- extend T::Sig
-
- Props = T.type_alias { T::Hash[Symbol, T.untyped] }
- State = T.type_alias { T::Hash[Symbol, T.untyped] }
-
- LambdaComponent =
- T.type_alias do
- T
- .proc
- .params(kwargs: Props)
- .returns(T.nilable(VDOM::Interfaces::Descriptor))
- end
-
- ComponentType = T.type_alias { T.any(T.class_of(Base), LambdaComponent) }
-
- Children = T.type_alias { T.any(ChildType, T::Array[ChildType]) }
-
- ChildType =
- T.type_alias do
- T.nilable(
- T.any(VDOM::Interfaces::Descriptor, T::Boolean, String, Numeric)
- )
- end
-
- ElementType = T.type_alias { T.any(Symbol, ComponentType) }
-
- sig { params(other: T.untyped).returns(T::Boolean) }
- def self.===(other)
- component_class?(other)
- end
-
- sig { params(klass: T.untyped).returns(T::Boolean) }
- def self.component_class?(klass)
- !!(klass.is_a?(Class) && klass < Base)
- end
-
- sig do
- params(vnode: VDOM::VNode, type: T.untyped, props: Props).returns(
- T.nilable(Wrapper)
- )
- end
- def self.wrap(vnode, type, props)
- component_class?(type) ? Wrapper.new(vnode, type, props) : nil
- end
end
end
diff --git a/lib/mayu/component/base.rb b/lib/mayu/component/base.rb
index 1ab3ce33..72e3e390 100644
--- a/lib/mayu/component/base.rb
+++ b/lib/mayu/component/base.rb
@@ -1,177 +1,96 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
-require_relative "handler_ref"
+require_relative "../style_sheet"
+require_relative "../runtime/h"
+require_relative "css_units"
+require_relative "fetch"
+require_relative "style_sheets"
module Mayu
module Component
class Base
- extend T::Sig
- extend T::Helpers
- abstract!
-
- sig do
- params(
- styles: T::Hash[Symbol, String],
- assets: T::Array[String]
- ).returns(T.class_of(Base))
- end
- def self.setup_component(styles:, assets:)
- T.unsafe(
- class << self
- self
- end
- ).undef_method(T.must(__method__))
+ H = Mayu::Runtime::H
- const_set(
- :MAYU,
- { styles: styles.freeze, assets: assets.freeze }.freeze
- )
+ using CSSUnits::Refinements
+ include Fetch::Helper
- self
- end
+ def self.module_path = nil
- sig { overridable.params(props: T.untyped).returns(Component::State) }
- def self.get_initial_state(**props)
- {}
- end
+ def self.to_s = File.join("MAYU_ROOT", module_path)
- class << self
- extend T::Sig
+ def self.import(filename) = Modules::System.import(filename, module_path)
- sig { void }
- def initialize
- # This will never be called but will make Sorbet happy
- @__mayu_resource = T.let(nil, T.nilable(Resources::Resource))
- end
+ def self.import?(filename) =
+ Modules::System.import?(filename, module_path)
- # TODO: Probably better use a WeakMap in Resources for this..
- sig { params(__mayu_resource: Resources::Resource).void }
- attr_writer :__mayu_resource
+ def self.merge_props(*sources)
+ result =
+ sources.reduce do |result, hash|
+ result.merge(hash) do |key, old_value, new_value|
+ case key
+ in :class
+ [old_value, new_value].flatten
+ else
+ new_value
+ end
+ end
+ end
- sig { returns(T.nilable(Resources::Resource)) }
- def __mayu_resource
- @__mayu_resource
- end
+ if classes = result.delete(:class)
+ classnames = self::Styles[*Array(classes).compact]
- sig { returns(Resources::Resource) }
- def __mayu_resource!
- @__mayu_resource or raise "__mayu_resource is not set"
+ result[:class] = classnames.flatten unless classnames.empty?
end
- sig { returns(T::Boolean) }
- def __mayu_resource?
- !!@__mayu_resource
- end
+ result.transform_keys { _1.to_s.tr("-", "_").to_sym }
end
- sig do
- overridable
- .params(props: Component::Props, state: Component::State)
- .returns(T.nilable(Component::State))
- end
- def self.get_derived_state_from_props(props, state)
- nil
+ def marshal_dump
+ instance_variables
+ .map { |ivar| [ivar, instance_variable_get(ivar)] }
+ .to_h
end
- sig { params(wrapper: Wrapper).void }
- def initialize(wrapper)
- @__wrapper = wrapper
+ def marshal_load(ivars)
+ ivars.each { |ivar, value| instance_variable_set(ivar, value) }
end
- sig { returns(State) }
- def state = mayu.state
- sig { returns(Props) }
- def props = mayu.props
- sig { returns(String) }
- def vnode_id = @__wrapper.vnode_id
-
- sig { overridable.void }
def mount
end
- sig { overridable.void }
def unmount
end
- sig do
- overridable
- .params(next_props: Component::Props, next_state: Component::State)
- .returns(T::Boolean)
- end
- def should_update?(next_props, next_state)
- case
- when props != next_props
- true
- when state != next_state
- true
- else
- false
- end
- end
-
- sig do
- overridable
- .params(prev_props: Component::Props, prev_state: Component::State)
- .void
+ def should_update?(old_props, old_state)
+ true
end
- def did_update(prev_props, prev_state)
- end
-
- INLINE_CSS_ASSETS = T.let([], T::Array[String])
- sig { returns(T::Array[String]) }
- def self.assets
- [self.stylesheet&.assets, const_get(:INLINE_CSS_ASSETS)].flatten
- .compact
- .map(&:filename)
+ def render
end
- # TODO: Could probably clean this up...
- sig { returns(T.nilable(Resources::Types::Stylesheet)) }
- def self.stylesheet = nil
- sig { returns(Resources::Types::Stylesheet) }
- def self.stylesheet! =
- stylesheet ||
- raise(RuntimeError, "There is no stylesheet for this component!")
- sig { returns(Resources::Types::Stylesheet::ClassNames) }
- def self.styles
- Resources::Types::Stylesheet::ClassNames.new({})
+ def __children
+ @__children
end
- sig { returns(Resources::Types::Stylesheet::ClassNames) }
- def styles = self.class.styles
- sig { params(blk: T.proc.bind(T.self_type).void).void }
- def async(&blk) = @__wrapper.async(&blk)
+ private
- sig { abstract.returns(ChildType) }
- def render
+ def rerender!
end
- sig do
- params(name: Symbol, args: T.untyped, kwargs: T.untyped).returns(
- HandlerRef
- )
- end
- def handler(name, *args, **kwargs)
- HandlerRef.new(self, name, args, kwargs)
+ def update!(value)
+ rerender!
+ value
end
- sig { returns(Helpers) }
- def mayu = @__wrapper.helpers
- alias helpers mayu
-
- sig do
- params(
- state: T.nilable(State),
- blk: T.nilable(Wrapper::UpdateProc)
- ).void
+ def view_transition
+ @__view_transition = true
+ yield
+ ensure
+ @__view_transition = false
end
- def update(state = nil, &blk)
- @__wrapper.update(state, &blk)
- end
-
- sig { returns(VDOM::Children) }
- def children = props[:children]
end
end
end
diff --git a/lib/mayu/component/css_units.rb b/lib/mayu/component/css_units.rb
new file mode 100644
index 00000000..4a3ca259
--- /dev/null
+++ b/lib/mayu/component/css_units.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Component
+ module CSSUnits
+ CustomProperty =
+ Data.define(:name) do
+ def self.[](name) = new(name.to_s.tr("_", "-"))
+
+ def to_s = "var(#{name})"
+ alias inspect to_s
+ end
+
+ Calc =
+ Data.define(:left, :operator, :right) do
+ def to_s = "calc(#{left} #{operator} #{right})".gsub("(calc(", "((")
+ alias inspect to_s
+
+ def +(other) = Calc[self, __method__, other]
+ def -(other) = Calc[self, __method__, other]
+ def *(other) = Calc[self, __method__, other]
+ def /(other) = Calc[self, __method__, other]
+ end
+
+ NumberWithUnit =
+ Data.define(:number, :unit) do
+ def to_s = "#{number}#{unit}"
+ alias inspect to_s
+
+ def +(other) = handle_operator(__method__, other)
+ def -(other) = handle_operator(__method__, other)
+ def *(other) = handle_operator(__method__, other)
+ def /(other) = handle_operator(__method__, other)
+
+ private
+
+ def handle_operator(operator, other)
+ case other
+ when Symbol
+ Calc[self, operator, CustomProperty[other]]
+ when Calc
+ Calc[self, operator, other]
+ when NumberWithUnit
+ if unit == other.unit
+ NumberWithUnit[number.send(operator, other.number), unit]
+ else
+ Calc[self, operator, other]
+ end
+ else
+ NumberWithUnit[number.send(operator, other), unit]
+ end
+ end
+ end
+
+ module Refinements
+ refine Numeric do
+ def with_css_unit(unit) = NumberWithUnit[self, unit]
+
+ def percent = with_css_unit(:%)
+ def cm = with_css_unit(__method__)
+ def mm = with_css_unit(__method__)
+ def Q = with_css_unit(:q)
+ def in = with_css_unit(__method__)
+ def pc = with_css_unit(__method__)
+ def pt = with_css_unit(__method__)
+ def px = with_css_unit(__method__)
+
+ # Font size of the parent, in the case of typographical properties like
+ # font-size, and font size of the element itself, in the case of other
+ # properties like width.
+ def em = with_css_unit(__method__)
+ # x-height of the element's font.
+ def ex = with_css_unit(__method__)
+ # The advance measure (width) of the glyph "0" of the element's font.
+ def ch = with_css_unit(__method__)
+ # Font size of the root element.
+ def rem = with_css_unit(__method__)
+ # Line height of the element.
+ def lh = with_css_unit(__method__)
+ # Line height of the root element. When used on the font-size or
+ # line-height properties of the root element, it refers to the
+ # properties' initial value.
+ def rlh = with_css_unit(__method__)
+ # 1% of the viewport's width.
+ def vw = with_css_unit(__method__)
+ # 1% of the viewport's height.
+ def vh = with_css_unit(__method__)
+ # 1% of the viewport's smaller dimension.
+ def vmin = with_css_unit(__method__)
+ # 1% of the viewport's larger dimension.
+ def vmax = with_css_unit(__method__)
+ # 1% of the size of the initial containing block in the direction of
+ # the root element's block axis.
+ def vb = with_css_unit(__method__)
+ # 1% of the size of the initial containing block in the direction of
+ # the root element's inline axis.
+ def vi = with_css_unit(__method__)
+ # 1% of the small viewport's width and height, respectively.
+ def svw = with_css_unit(__method__)
+ def svh = with_css_unit(__method__)
+ # 1% of the large viewport's width and height, respectively.
+ def lvw = with_css_unit(__method__)
+ def lvh = with_css_unit(__method__)
+ # 1% of the dynamic viewport's width and height, respectively.
+ def dvw = with_css_unit(__method__)
+ def dvh = with_css_unit(__method__)
+
+ # 1% of a query container's width
+ def cqw = with_css_unit(__method__)
+ # 1% of a query container's height
+ def cqh = with_css_unit(__method__)
+ # 1% of a query container's inline size
+ def cqi = with_css_unit(__method__)
+ # 1% of a query container's block size
+ def cqb = with_css_unit(__method__)
+ # The smaller value of either cqi or cqb
+ def cqmin = with_css_unit(__method__)
+ # The larger value of either cqi or cqb
+ def cqmax = with_css_unit(__method__)
+
+ # Fraction of grid
+ def fr = with_css_unit(__method__)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/component/fetch.rb b/lib/mayu/component/fetch.rb
new file mode 100644
index 00000000..a209a314
--- /dev/null
+++ b/lib/mayu/component/fetch.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "singleton"
+require "async/http/internet"
+require "rack/utils"
+require "uri"
+
+module Mayu
+ module Component
+ class Fetch
+ Response =
+ Data.define(
+ :url,
+ :body,
+ :headers,
+ :status,
+ :status_text,
+ :ok?,
+ :redirected?
+ ) do
+ def json(symbolize_names: false) = JSON.parse(body, symbolize_names:)
+
+ def content_type = headers.fetch("content-type").to_s
+
+ def inspect
+ "<##{self.class.name} #{inspect_attributes}>"
+
+ private
+
+ def inspect_attributes
+ [
+ "url=#{url.inspect}",
+ "status=#{status.inspect}",
+ "status_text=#{status_text.inspect}",
+ "content_type=#{content_type.inspect}",
+ "body=#{body.bytesize}b"
+ ].join(" ")
+ end
+ end
+ end
+
+ include Singleton
+
+ module Helper
+ def fetch(...)
+ Fetch.instance.fetch(...)
+ end
+ end
+
+ def initialize
+ @internet = Async::HTTP::Internet.new
+ end
+
+ def fetch(url, method: :GET, headers: {}, body: nil)
+ puts "\e[35mFETCHING #{url}\e[0m"
+ res = @internet.call(method, url, headers.to_a, body)
+ puts "\e[34mFETCHED #{url}\e[0m"
+
+ Response.new(
+ url:,
+ body: res.read,
+ headers: res.headers.to_h,
+ status: res.status,
+ status_text: Rack::Utils::HTTP_STATUS_CODES[res.status],
+ ok?: res.success?,
+ redirected?: res.redirection?
+ )
+ rescue => e
+ puts "\e[32mFAILED ON #{url}\e[0m"
+ raise
+ end
+ end
+ end
+end
diff --git a/lib/mayu/component/handler_ref.rb b/lib/mayu/component/handler_ref.rb
deleted file mode 100644
index 5d9aadca..00000000
--- a/lib/mayu/component/handler_ref.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-# typed: strict
-
-require_relative "base"
-
-module Mayu
- module Component
- class HandlerRef
- extend T::Sig
-
- ID_LENGTH = 16
- ID_FORMAT = /\A[[:graph:]]{#{ID_LENGTH}}\z/
-
- sig { returns(String) }
- attr_reader :id
-
- sig do
- params(
- component: Base,
- name: Symbol,
- args: T::Array[T.untyped],
- kwargs: T::Hash[Symbol, T.untyped]
- ).void
- end
- def initialize(component, name, args = [], kwargs = {})
- @component = component
- @name = name
- @args = args
- @kwargs = kwargs
- # TODO: Validate that args and kwargs match the method signature.
- method = T.let(component.public_method(name), Method)
- @arity = T.let(method.arity, Integer)
- @id =
- T.let(
- [component.vnode_id, name, @args, @kwargs].inspect
- .then { Digest::SHA256.digest(_1) }
- .then { Base64.urlsafe_encode64(_1) }
- .then { _1[0, ID_LENGTH] },
- String
- )
- end
-
- sig { returns(Integer) }
- def hash = [self.class, @id].hash
- sig { params(other: T.untyped).returns(T::Boolean) }
- def eql?(other) = self.class === other && other.id == id
- sig { params(other: T.untyped).returns(T::Boolean) }
- def ==(other) = self.class === other && other.id == id
-
- sig { returns(String) }
- def inspect
- "#
+# License: AGPL-3.0
+
+require_relative "../style_sheet"
+require_relative "../runtime/h"
+require_relative "css_units"
+require_relative "fetch"
+
+module Mayu
+ module Component
+ class StyleSheets
+ def initialize(component, style_sheets, logger: nil)
+ @component = component
+ @style_sheets = style_sheets
+ @classes = merge_classes(style_sheets.map(&:classes))
+ @logger = logger
+ end
+
+ def each(&)
+ @style_sheets.each(&)
+ end
+
+ def [](*class_names)
+ if @style_sheets.empty?
+ unless class_names.compact.all? {
+ _1.start_with?("__") || String === _1
+ }
+ Console.logger.error(@component, "No stylesheet defined")
+ end
+
+ return []
+ end
+
+ missing = []
+
+ result =
+ class_names
+ .map do |class_name|
+ case class_name
+ in String
+ class_name
+ in Hash
+ self[*class_name.filter { _2 }.keys]
+ in Symbol
+ @classes.fetch(class_name) do
+ missing << class_name unless class_name.start_with?("__")
+ nil
+ end
+ end
+ end
+ .flatten
+ .compact
+ .uniq
+
+ warn_missing(missing)
+
+ result
+ end
+
+ private
+
+ def merge_classes(hashes)
+ result = {}
+
+ hashes.each do |hash|
+ hash.each do |key, value|
+ result[key] ||= []
+ result[key] << value
+ end
+ end
+
+ result
+ end
+
+ def warn_missing(missing)
+ return if @warned || missing.empty?
+
+ available = @classes.keys.reject { _1.start_with?("__") }
+
+ logger.warn(
+ @component,
+ format(<<~MSG, join_class_names(missing), join_class_names(available))
+ Could not find classes: \e[1;31m%s\e[0m
+ Available class names:
+ \e[1;33m%s\e[0m
+ MSG
+ )
+
+ @warned = true
+ end
+
+ def join_class_names(class_names)
+ class_names.map { ".#{_1}" }.join(", ")
+ end
+
+ def logger
+ @logger || Console.logger
+ end
+ end
+ end
+end
diff --git a/lib/mayu/component/style_sheets.test.rb b/lib/mayu/component/style_sheets.test.rb
new file mode 100644
index 00000000..81b59425
--- /dev/null
+++ b/lib/mayu/component/style_sheets.test.rb
@@ -0,0 +1,58 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+# require "minitest/mock"
+require_relative "style_sheets"
+
+class Mayu::Component::StyleSheets::Test < Minitest::Test
+ def test_classes
+ component = Object.new
+ logger = Minitest::Mock.new
+
+ style_sheets =
+ Mayu::Component::StyleSheets.new(
+ component,
+ [
+ Mayu::StyleSheet[
+ source_filename: "foo.css",
+ content_hash: nil,
+ content: nil,
+ classes: {
+ __li: "tag-li",
+ __ul: "tag-ul",
+ active: "active",
+ item: "item",
+ hello: "hello1"
+ }
+ ],
+ Mayu::StyleSheet[
+ source_filename: "foo.css",
+ content_hash: nil,
+ content: nil,
+ classes: {
+ hello: "hello2"
+ }
+ ]
+ ],
+ logger:
+ )
+
+ assert_equal(%w[item], style_sheets[:item])
+ assert_equal(%w[item], style_sheets[:item, active: false])
+ assert_equal(%w[item active], style_sheets[:item, active: true])
+ assert_equal(%w[hello1 hello2], style_sheets[:hello])
+ assert_equal(%w[tag-li foobar], style_sheets[:__li, "foobar" => true])
+
+ logger.expect(:warn, nil) do |c, msg|
+ c == component && msg.start_with?("Could not find classes:")
+ end
+
+ style_sheets[:non_existant]
+
+ logger.verify
+ end
+end
diff --git a/lib/mayu/component/wrapper.rb b/lib/mayu/component/wrapper.rb
deleted file mode 100644
index 122cdf8c..00000000
--- a/lib/mayu/component/wrapper.rb
+++ /dev/null
@@ -1,165 +0,0 @@
-# typed: strict
-
-require "async/barrier"
-require_relative "helpers"
-
-module Mayu
- module Component
- class Wrapper
- extend T::Sig
-
- UpdateProc =
- T.type_alias do
- T.any(
- T.proc.params(arg0: State).returns(State),
- T.proc.params(kwargs: T.untyped).returns(State)
- )
- end
-
- sig { returns(String) }
- def vnode_id = @vnode.id
-
- sig { returns(Props) }
- attr_accessor :props
- sig { returns(State) }
- attr_accessor :state
- sig { returns(State) }
- attr_reader :next_state
-
- sig { returns(Helpers) }
- attr_reader :helpers
- sig { returns(Component::Base) }
- attr_reader :instance
-
- sig { returns(T::Boolean) }
- def dirty? = @dirty
- sig { returns(TrueClass) }
- def dirty! = @dirty = true
-
- sig { returns(VDOM::VNode) }
- attr_reader :vnode
-
- sig do
- params(vnode: VDOM::VNode, klass: T.class_of(Base), props: Props).void
- end
- def initialize(vnode, klass, props = {})
- @vnode = vnode
- @props = T.let(props, Props)
- @state = T.let(klass.get_initial_state(**props), State)
- @next_state = T.let(@state.dup, State)
- @dirty = T.let(true, T::Boolean)
- @instance = T.let(klass.new(self), Base)
- @barrier = T.let(Async::Barrier.new, Async::Barrier)
- @helpers = T.let(Helpers.new(self), Helpers)
- end
-
- sig { returns(T::Array[String]) }
- def assets
- @instance.class.assets
- end
-
- sig { returns(T.nilable(Resources::Resource)) }
- def resource
- if @instance.class.respond_to?(:__resource)
- @instance.class.send(:__resource)
- end
- end
-
- sig { void }
- def mount
- async { @instance.mount }
- end
-
- sig { params(prev_props: Props, prev_state: State).void }
- def did_update(prev_props, prev_state)
- async { @instance.did_update(prev_props, prev_state) }
- end
-
- sig { void }
- def unmount
- @instance.unmount
- ensure
- @barrier.stop
- end
-
- sig { returns(ChildType) }
- def render
- if derived_state =
- @instance.class.get_derived_state_from_props(props, state)
- @state = @state.merge(derived_state)
- end
-
- @instance.render
- rescue NotImplementedError => e
- raise NotImplementedError, "#{@instance} should implement #render"
- ensure
- @dirty = false
- end
-
- sig { params(next_props: Props, next_state: State).returns(T::Boolean) }
- def should_update?(next_props, next_state)
- @dirty || @instance.should_update?(next_props, next_state)
- end
-
- sig { params(blk: T.proc.void).void }
- def async(&blk)
- @barrier.async(&blk)
- end
-
- sig do
- params(new_state: T.nilable(State), block: T.nilable(UpdateProc)).void
- end
- def update(new_state = nil, &block)
- if new_state
- @next_state = @next_state.merge(new_state)
- enqueue_update!
- end
-
- return unless block
-
- if block.parameters in [[:opt, var]]
- Console.logger.warn(self, <<~EOF) unless var == :state
- update do |#{var}|
- # Are you sure you didn't misspell `#{var}`?
- # Usually it should be called `state`.
- end
- EOF
-
- update(block.call(@next_state))
- else
- if block.parameters.all? { _1 in [:key | :keyreq, key] }
- keys = block.parameters.map(&:last)
- sliced_state = T.unsafe(@next_state).slice(*keys)
- update(block.call(**sliced_state))
- else
- raise ArgumentError, "All arguments to #update are not keys."
- end
- end
- end
-
- sig { returns(T.untyped) }
- def marshal_dump
- [
- VDOM::Marshalling.dump_props(@props),
- VDOM::Marshalling.dump_state(@state)
- ]
- end
-
- sig { params(a: T.untyped).void }
- def marshal_load(a)
- @props, @state = a
- @next_state = @state.clone
- @dirty = true
- @barrier = Async::Barrier.new
- end
-
- private
-
- sig { void }
- def enqueue_update!
- @vnode.enqueue_update!
- @dirty = true
- end
- end
- end
-end
diff --git a/lib/mayu/configuration.rb b/lib/mayu/configuration.rb
index ab7f98fa..d304ef8e 100644
--- a/lib/mayu/configuration.rb
+++ b/lib/mayu/configuration.rb
@@ -1,194 +1,124 @@
-# typed: strict
# frozen_string_literal: true
-require "toml-rb"
-require "async/container"
+# Copyright Andreas Alin
+# License: AGPL-3.0
-module Mayu
- class Configuration < T::Struct
- extend T::Sig
-
- CONFIG_FILE = "mayu.toml"
-
- class Server < T::Struct
- extend T::Sig
-
- const :scheme, String, default: "https"
- const :host, String, default: "127.0.0.1"
- const :port, Integer, default: 9292
+require "toml"
- const :hot_swap, T::Boolean, default: false
- const :self_signed_cert, T::Boolean, default: false
-
- const :generate_assets, T::Boolean, default: false
-
- const :render_exceptions, T::Boolean, default: false
-
- const :count, Integer, default: Async::Container.processor_count
- const :forks, T.nilable(Integer)
- const :threads, T.nilable(Integer)
+module Mayu
+ module Configuration
+ DOTENV_FILES = { development: %w[.env .env.local], production: %w[.env] }
- sig { returns(URI::HTTP) }
- def uri
- URI.for(scheme, nil, host, port, nil, "/", nil, nil, nil).normalize
- end
+ class ConfigNotFound < StandardError
end
- class Instance < T::Struct
- const :app_name, String, default: ENV.fetch("FLY_APP_NAME", "mayu-live")
- const :region, String, default: ENV.fetch("FLY_REGION", "dev")
- const :alloc_id,
- String,
- default:
- ENV.fetch("FLY_ALLOC_ID", "00000000-0000-0000-0000-000000000000")
+ class EnvironmentNotDefined < StandardError
end
- class Paths < T::Struct
- const :components, String, default: "components"
- const :pages, String, default: "pages"
- const :stores, String, default: "stores"
- const :public, String, default: "public"
- const :assets, String, default: ".assets"
- const :dist, String, default: "dist"
- const :bundle_filename, String, default: "app.mayu-bundle"
+ class EnvironmentVariableNotDefined < StandardError
end
- class Metrics < T::Struct
- const :enabled, T::Boolean, default: false
- const :port, Integer, default: 9091
- const :host, String, default: "127.0.0.1"
- const :path, String, default: "/metrics"
+ def self.convert_env(value)
+ if var = value[/\A\$(.*)/, 1]
+ ENV.fetch(var) do
+ raise EnvironmentVariableNotDefined,
+ "Environment variable not defined: $#{var}"
+ end
+ else
+ value
+ end
end
- const :mode, Symbol
- const :root, String
- const :secret_key, String
- const :use_bundle, T::Boolean, default: false
-
- const :server, Server, default: Server.new
- const :metrics, Metrics, default: Metrics.new
- const :paths, Paths, default: Paths.new
- const :instance, Instance, default: Instance.new
-
- sig { params(dir: String).returns(String) }
- def self.resolve_config_file(dir = Dir.pwd)
- path = File.join(dir, CONFIG_FILE)
-
- return path if File.file?(path)
+ Config =
+ Data.define(:root, :secret_key, :server, :metrics) do
+ def self.parse(root, config)
+ new(
+ root:,
+ secret_key: Configuration.convert_env(config.fetch("secret_key")),
+ server: ServerConfig.parse(config.fetch("server")),
+ metrics: MetricsConfig.parse(config.fetch("metrics"))
+ )
+ end
+ end
- parent = File.expand_path("..", dir)
+ ServerConfig =
+ Data.define(
+ :listen,
+ :hmr?,
+ :render_exceptions?,
+ :self_signed_cert?,
+ :generate_assets?,
+ :session_timeout_seconds,
+ :transfer_timeout_seconds,
+ :cookie_timeout_seconds
+ ) do
+ def self.parse(config)
+ new(
+ listen:
+ Configuration.convert_env(
+ config.fetch("listen", "https://localhost:9292")
+ ),
+ hmr?: config.fetch("hmr", false),
+ render_exceptions?: config.fetch("render_exceptions", false),
+ self_signed_cert?: config.fetch("self_signed_cert", false),
+ generate_assets?: config.fetch("generate_assets", false),
+ session_timeout_seconds:
+ config.fetch("session_timeout_seconds", 10).to_i,
+ transfer_timeout_seconds:
+ config.fetch("transfer_timeout_seconds", 10).to_i,
+ cookie_timeout_seconds:
+ config.fetch("cookie_timeout_seconds", 10).to_i
+ )
+ end
+ end
- if dir == parent
- raise "Could not find #{CONFIG_FILE} in any parent directory."
+ MetricsConfig =
+ Data.define(:enabled?, :listen) do
+ def self.parse(config)
+ new(
+ enabled?: config.fetch("enabled", true),
+ listen: config.fetch("listen", "http://localhost:9091")
+ )
+ end
end
- resolve_config_file(parent)
+ def self.load(filename, env)
+ filename
+ .then { File.read(_1) }
+ .then { TOML.load(_1) }
+ .fetch(env.to_s) do
+ raise EnvironmentNotDefined,
+ "Could not find environment #{env} in #{filename}"
+ end
+ .then { Config.parse(Dir.pwd, _1) }
end
- sig do
- params(
- mode: Symbol,
- pwd: String,
- overrides: T::Hash[String, T.untyped]
- ).returns(T.attached_class)
- end
- def self.load_config(mode, pwd: Dir.pwd, overrides: {})
- file = resolve_config_file(pwd)
- root = File.dirname(file)
-
- config =
- T.cast(
- TomlRB.load_file(file),
- T::Hash[String, T::Hash[String, T.untyped]]
- )
-
- base_config = config.dig("base") || {}
- env_config = config.dig(mode.to_s) || {}
-
- merged_config = base_config.merge(env_config).merge(overrides)
-
- secret_key =
- merged_config.fetch("secret_key") do
- ENV.fetch("MAYU_SECRET_KEY") do
- raise "secret_key is not configured (can be set with env var MAYU_SECRET_KEY)"
- end
- end
+ def self.with(env, &)
+ path = find
- from_hash!(
- {
- **merged_config,
- "root" => root,
- "secret_key" => secret_key,
- "mode" => mode
- }
- )
- end
+ raise ConfigNotFound, "Could not find mayu.toml in #{Dir.pwd}" unless path
- sig { params(configuration: T::Struct).void }
- def self.log_config(configuration)
- Console.logger.info(self) { make_table(configuration) }
- end
+ root, filename = File.split(path)
- sig do
- params(
- configuration: T::Struct,
- style: T::Hash[Symbol, T.untyped]
- ).returns(String)
- end
- def self.make_table(
- configuration,
- style: { all_separators: true, border: :unicode }
- )
- Terminal::Table
- .new do |t|
- t.headings = %w[key value type].map { "\e[1m#{_1}\e[0m" }
- t.style = style
-
- configuration.class.props.each do |prop, opts|
- value =
- if prop.to_s.start_with?("secret_")
- "\e[2m***hidden***\e[0m"
- else
- case configuration.send(prop)
- in T::Struct => struct
- make_table(struct, style: { border: :unicode_round })
- in other
- colorize_value(other)
- end
- end
-
- t.add_row([prop, value, opts[:type].to_s.delete_prefix("Mayu::")])
- end
+ Dir.chdir(root) do
+ if dotenv_files = DOTENV_FILES[env]
+ require "dotenv"
+ Dotenv.load(*dotenv_files)
end
- .to_s
- end
- sig { params(value: T.untyped).returns(String) }
- def self.colorize_value(value)
- if color = value_color(value).nonzero?
- "\e[#{color}m#{value.inspect}\e[0m"
- else
- value.inspect
+ yield self.load(filename, env)
end
end
- sig { params(value: T.untyped).returns(Integer) }
- def self.value_color(value)
- case value
- when FalseClass
- 31
- when TrueClass
- 32
- when String
- 33
- when Numeric
- 34
- when Symbol
- 36
- when nil
- 2
+ def self.find(filename = "mayu.toml", dir = Dir.pwd)
+ path = File.join(dir, filename)
+
+ if File.exist?(path)
+ path
else
- 0
+ parent = File.join(dir, "..")
+ return if dir == parent
+ find(filename, parent)
end
end
end
diff --git a/lib/mayu/configuration.test.rb b/lib/mayu/configuration.test.rb
new file mode 100755
index 00000000..411a5328
--- /dev/null
+++ b/lib/mayu/configuration.test.rb
@@ -0,0 +1,72 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+
+require_relative "configuration"
+
+class Mayu::Configuration::Test < Minitest::Test
+ def test_configuration
+ filename = File.join(__dir__, "__test__", "configuration", "test.toml")
+ config = Mayu::Configuration.load(filename, "development")
+
+ assert_equal "dev", config.secret_key
+ assert_equal "https://localhost:9292", config.server.listen
+ assert_equal true, config.server.hmr?
+ assert_equal true, config.server.render_exceptions?
+ assert_equal true, config.server.self_signed_cert?
+ assert_equal true, config.server.generate_assets?
+ assert_equal 11, config.server.session_timeout_seconds
+ assert_equal 12, config.server.transfer_timeout_seconds
+ assert_equal 13, config.server.cookie_timeout_seconds
+
+ assert_equal true, config.metrics.enabled?
+ assert_equal "http://localhost:9293", config.metrics.listen
+ end
+
+ def test_configuration_production_env
+ filename = File.join(__dir__, "__test__", "configuration", "test.toml")
+
+ ENV["SECRET_KEY"] = "secret"
+ config = Mayu::Configuration.load(filename, "production")
+
+ assert_equal "secret", config.secret_key
+ assert_equal "http://localhost:3000", config.server.listen
+ assert_equal false, config.server.hmr?
+ assert_equal false, config.server.render_exceptions?
+ assert_equal false, config.server.self_signed_cert?
+ assert_equal false, config.server.generate_assets?
+ assert_equal 21, config.server.session_timeout_seconds
+ assert_equal 22, config.server.transfer_timeout_seconds
+ assert_equal 23, config.server.cookie_timeout_seconds
+ ensure
+ ENV.delete("SECRET_KEY")
+ end
+
+ def test_configuration_missing_environment
+ filename = File.join(__dir__, "__test__", "configuration", "test.toml")
+
+ error =
+ assert_raises(Mayu::Configuration::EnvironmentNotDefined) do
+ Mayu::Configuration.load(filename, "staging")
+ end
+
+ assert_match(/staging/, error.message)
+ end
+
+ def test_configuration_missing_env_var
+ filename = File.join(__dir__, "__test__", "configuration", "test.toml")
+
+ ENV.delete("SECRET_KEY")
+
+ error =
+ assert_raises(Mayu::Configuration::EnvironmentVariableNotDefined) do
+ Mayu::Configuration.load(filename, "production")
+ end
+
+ assert_match(/\$SECRET_KEY/, error.message)
+ end
+end
diff --git a/lib/mayu/custom_element.rb b/lib/mayu/custom_element.rb
new file mode 100644
index 00000000..b26107bf
--- /dev/null
+++ b/lib/mayu/custom_element.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ CustomElement =
+ Data.define(:name, :filename) do
+ def path
+ "/.mayu/assets/#{filename}"
+ end
+ end
+end
diff --git a/lib/mayu/disable_sorbet.rb b/lib/mayu/disable_sorbet.rb
deleted file mode 100644
index 73ee73a5..00000000
--- a/lib/mayu/disable_sorbet.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# typed: false
-# frozen_string_literal: true
-
-require "sorbet-runtime"
-
-module Mayu
- module DisableSorbet
- def self.disable_sorbet!
- # https://github.com/sorbet/sorbet/issues/3279#issuecomment-679154712
- T::Configuration.default_checked_level = :never
-
- error_handler =
- lambda do |error, *_|
- # Log error somewhere
- end
-
- # Suppresses errors caused by T.cast, T.let, T.must, etc.
- T::Configuration.inline_type_error_handler = error_handler
- # Suppresses errors caused by incorrect parameter ordering
- T::Configuration.sig_validation_error_handler = error_handler
- end
- end
-end
diff --git a/lib/mayu/encrypted_marshal.rb b/lib/mayu/encrypted_marshal.rb
index 44fce3d3..b181a7a3 100644
--- a/lib/mayu/encrypted_marshal.rb
+++ b/lib/mayu/encrypted_marshal.rb
@@ -1,119 +1,99 @@
-# typed: true
# frozen_string_literal: true
-require "sorbet-runtime"
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "time"
require "rbnacl"
+require "securerandom"
require "brotli"
module Mayu
class EncryptedMarshal
+ DEFAULT_TTL_SECONDS = 10
+
+ Message = Data.define(:iss, :exp, :payload)
+
class Error < StandardError
end
-
+ class ExpiredError < Error
+ end
class IssuedInTheFutureError < Error
end
-
- class ExpiredError < Error
+ class EncryptError < Error
end
-
class DecryptError < Error
end
+ class DumpError < Error
+ end
- AdditionalData =
- Data.define(:issued_at, :ttl) do
- def self.create(ttl:, now: Time.now) = new(now.to_f, ttl)
-
- def self.unpack(data)
- data.unpack("D S") => [issued_at, ttl]
- new(issued_at, ttl)
- end
+ def initialize(key, ttl: DEFAULT_TTL_SECONDS)
+ validate_ttl!(ttl)
+ @default_ttl_seconds = ttl
+ @box = RbNaCl::SimpleBox.from_secret_key(RbNaCl::Hash.sha256(key))
+ end
- def pack = [issued_at, ttl].pack("D S")
- def expired?(now: Time.now) = now > expires_at
- def expires_at = Time.at(issued_at + ttl)
- end
+ def dump(payload, ttl: @default_ttl_seconds)
+ encode_message(Marshal.dump(payload), ttl:)
+ rescue TypeError => e
+ raise DumpError, "Could not dump payload: #{e.message}"
+ end
- Message =
- Data.define(:nonce, :ad, :ciphertext) do
- def self.unpack(data)
- data.unpack("S a*") => [nonce_length, data]
- data.unpack("a#{nonce_length} S a*") => [nonce, ad_length, data]
- data.unpack("a#{ad_length} a*") => [ad, ciphertext]
- new(nonce, AdditionalData.unpack(ad), ciphertext)
- end
-
- def pack
- packed_ad = ad.pack
- [
- nonce.bytesize,
- nonce,
- packed_ad.bytesize,
- packed_ad,
- ciphertext
- ].pack("S a* S a* a*")
- end
-
- def expired?(now: Time.now) = ad.expired?(now:)
- def expires_at = ad.expires_at
-
- def verify_timestamps!(now: Time.now)
- if ad.expired?(now:)
- raise ExpiredError, "Message expired at #{ad.expires_at}"
- end
-
- if ad.issued_at > now.to_f
- raise IssuedInTheFutureError,
- "Message was issued in the future, #{Time.at(ad.issued_at)}"
- end
-
- self
- end
- end
+ def load(data)
+ Marshal.load(decode_message(data))
+ end
- extend T::Sig
+ private
- Cipher = RbNaCl::AEAD::ChaCha20Poly1305IETF
+ def encode_message(payload, ttl:)
+ build_message(payload, ttl:)
+ .then { Marshal.dump(_1) }
+ .then { Brotli.deflate(_1) }
+ .then { @box.encrypt(_1) }
+ end
- DEFAULT_TTL_SECONDS = 10
+ def build_message(payload, ttl:)
+ validate_ttl!(ttl)
- sig { returns(String) }
- def self.random_key = RbNaCl::Random.random_bytes(Cipher::KEYBYTES)
+ now = Time.now.to_f
- sig { params(base_key: String, default_ttl: Integer).void }
- def initialize(base_key, default_ttl: DEFAULT_TTL_SECONDS)
- @cipher = Cipher.new(RbNaCl::Hash.sha256(base_key))
- @default_ttl = default_ttl
+ { iss: now, exp: now + ttl, payload: }
end
- sig { params(object: T.untyped, ttl: Integer).returns(String) }
- def dump(object, ttl: @default_ttl) =
- object
- .then { Marshal.dump(_1) }
- .then { Brotli.deflate(_1) }
- .then { encrypt(_1, ttl:) }
-
- sig { params(encrypted: String).returns(T.untyped) }
- def load(encrypted) =
- encrypted
- .then { Message.unpack(_1) }
- .then { _1.verify_timestamps! }
- .then { decrypt(_1) }
+ def decode_message(message)
+ message
+ .then { @box.decrypt(_1) }
.then { Brotli.inflate(_1) }
.then { Marshal.load(_1) }
+ .then { validate_message(_1) }
+ rescue RbNaCl::CryptoError => e
+ raise DecryptError, e.message
+ end
- private
+ def validate_message(message)
+ message => { iss:, exp:, payload: }
+ now = Time.now.to_f
+ validate_iss!(now, iss)
+ validate_exp!(now, exp)
+ payload
+ end
- def encrypt(message, ttl:)
- nonce = RbNaCl::Random.random_bytes(@cipher.nonce_bytes)
- ad = AdditionalData.create(ttl:)
- ciphertext = @cipher.encrypt(nonce, message, ad.pack)
- Message.new(nonce, ad, ciphertext).pack
+ def validate_ttl!(ttl)
+ raise ArgumentError, "ttl must be positive" if ttl <= 0
end
- def decrypt(message)
- @cipher.decrypt(message.nonce, message.ciphertext, message.ad.pack)
- rescue RbNaCl::CryptoError => e
- raise DecryptError, e.message
+ def validate_iss!(now, iss)
+ if iss > now
+ raise IssuedInTheFutureError,
+ "The message was issued at #{Time.at(iss).iso8601}, which is in the future"
+ end
+ end
+
+ def validate_exp!(now, exp)
+ if exp <= now
+ raise ExpiredError,
+ "The message expired at #{Time.at(exp).iso8601}, which is in the past or present"
+ end
end
end
end
diff --git a/lib/mayu/encrypted_marshal.test.rb b/lib/mayu/encrypted_marshal.test.rb
old mode 100644
new mode 100755
index 80d53579..0a44a813
--- a/lib/mayu/encrypted_marshal.test.rb
+++ b/lib/mayu/encrypted_marshal.test.rb
@@ -1,66 +1,115 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
require "minitest/autorun"
-# require "test_helper"
+require "minitest/mock"
require_relative "encrypted_marshal"
class Mayu::EncryptedMarshal::Test < Minitest::Test
- EncryptedMarshal = Mayu::EncryptedMarshal
-
def test_dump_and_load
- encrypted_marshal = EncryptedMarshal.new(EncryptedMarshal.random_key)
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
- dumped = encrypted_marshal.dump("hello")
- loaded = encrypted_marshal.load(dumped)
+ dumped = message_cipher.dump("hello")
+ loaded = message_cipher.load(dumped)
assert_equal("hello", loaded)
end
def test_dump_and_load_object
- encrypted_marshal = EncryptedMarshal.new(EncryptedMarshal.random_key)
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
object = { foo: "hello", bar: { baz: [123.456, :asd] } }
- dumped = encrypted_marshal.dump(object)
- loaded = encrypted_marshal.load(dumped)
+ dumped = message_cipher.dump(object)
+ loaded = message_cipher.load(dumped)
assert_equal(object, loaded)
end
def test_issued_in_the_future
- encrypted_marshal = EncryptedMarshal.new(EncryptedMarshal.random_key)
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
- dumped = encrypted_marshal.dump("hello")
+ dumped = message_cipher.dump("hello")
Time.stub(:now, Time.at(Time.now - 1)) do
- assert_raises(EncryptedMarshal::IssuedInTheFutureError) do
- encrypted_marshal.load(dumped)
+ assert_raises(Mayu::EncryptedMarshal::IssuedInTheFutureError) do
+ message_cipher.load(dumped)
end
end
end
def test_expiration
- encrypted_marshal = EncryptedMarshal.new(EncryptedMarshal.random_key)
- dumped = encrypted_marshal.dump("hello")
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
+ dumped = message_cipher.dump("hello")
Time.stub(
:now,
- Time.at(Time.now + EncryptedMarshal::DEFAULT_TTL_SECONDS)
+ Time.at(Time.now + Mayu::EncryptedMarshal::DEFAULT_TTL_SECONDS)
) do
- assert_raises(EncryptedMarshal::ExpiredError) do
- encrypted_marshal.load(dumped)
+ assert_raises(Mayu::EncryptedMarshal::ExpiredError) do
+ message_cipher.load(dumped)
end
end
end
+ def test_custom_ttl_can_extend_validity
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
+ base_time = Time.at(1_700_000_000)
+
+ dumped =
+ Time.stub(:now, base_time) do
+ message_cipher.dump(
+ "hello",
+ ttl: Mayu::EncryptedMarshal::DEFAULT_TTL_SECONDS + 5
+ )
+ end
+
+ loaded =
+ Time.stub(
+ :now,
+ Time.at(base_time + Mayu::EncryptedMarshal::DEFAULT_TTL_SECONDS)
+ ) { message_cipher.load(dumped) }
+
+ assert_equal("hello", loaded)
+ end
+
+ def test_custom_ttl_can_shorten_validity
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
+ base_time = Time.at(1_700_000_000)
+
+ dumped = Time.stub(:now, base_time) { message_cipher.dump("hello", ttl: 1) }
+
+ Time.stub(:now, Time.at(base_time + 1)) do
+ assert_raises(Mayu::EncryptedMarshal::ExpiredError) do
+ message_cipher.load(dumped)
+ end
+ end
+ end
+
+ def test_ttl_must_be_positive
+ assert_raises(ArgumentError) do
+ Mayu::EncryptedMarshal.new(generate_key, ttl: 0)
+ end
+
+ message_cipher = Mayu::EncryptedMarshal.new(generate_key)
+
+ assert_raises(ArgumentError) { message_cipher.dump("hello", ttl: 0) }
+ end
+
def test_invalid_key
- em1 = EncryptedMarshal.new(EncryptedMarshal.random_key)
- em2 = EncryptedMarshal.new(EncryptedMarshal.random_key)
+ cipher1 = Mayu::EncryptedMarshal.new(generate_key)
+ cipher2 = Mayu::EncryptedMarshal.new(generate_key)
- dumped = em1.dump("hello")
+ dumped = cipher1.dump("hello")
- assert_raises(
- EncryptedMarshal::DecryptError,
- "Decryption failed. Ciphertext failed verification."
- ) { em2.load(dumped) }
+ assert_raises(Mayu::EncryptedMarshal::DecryptError) { cipher2.load(dumped) }
end
+
+ private
+
+ def generate_key = RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes)
end
diff --git a/lib/mayu/environment.rb b/lib/mayu/environment.rb
index 5884e27a..f1509d97 100644
--- a/lib/mayu/environment.rb
+++ b/lib/mayu/environment.rb
@@ -1,154 +1,155 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
-require "async"
-require "async/http/internet"
-require_relative "vdom"
-require_relative "state/store"
-require_relative "state/loader"
+require "msgpack"
require_relative "routes"
-require_relative "metrics"
-require_relative "app_metrics"
-require_relative "resources/registry"
-require_relative "fetch"
require_relative "encrypted_marshal"
require_relative "configuration"
+require_relative "system_config"
+require_relative "component"
+require_relative "watcher"
+require_relative "metrics"
+require_relative "utils"
module Mayu
class Environment
- # The Environment class is instantiated on startup and contains
- # configuration and everything that should be shared.
- extend T::Sig
-
- PAGES_DIR = "pages"
- STORE_DIR = "store"
+ class MsgPackWrapper < MessagePack::Factory
+ def initialize
+ super()
+ self.register_type(0x00, Symbol)
+ end
+ end
- sig { returns(String) }
- attr_reader :root
- sig { returns(Configuration) }
attr_reader :config
- sig { returns(T::Array[Routes::Route]) }
- attr_reader :routes
- sig { returns(State::Store::Reducers) }
- attr_reader :reducers
- sig { returns(Resources::Registry) }
- attr_reader :resources
- sig { returns(Fetch) }
- attr_reader :fetch
- sig { returns(EncryptedMarshal) }
- attr_reader :encrypted_marshal
- sig { returns(AppMetrics) }
+ attr_reader :app_dir
+ attr_reader :pages_dir
+ attr_reader :assets_dir
+ attr_reader :client_path
+ attr_reader :runtime_js_path
+ attr_reader :init_js_body
+ attr_reader :modules
+ attr_reader :router
+ attr_reader :marshaller
attr_reader :metrics
- sig { params(config: Configuration, metrics: AppMetrics).void }
- def initialize(config, metrics)
- @root = T.let(config.root, String)
- @app_root = T.let(File.join(config.root, "app"), String)
+ def self.with(mayu_env)
+ Configuration.with(mayu_env) do |config|
+ with_config(config).use { |environment| yield environment }
+ end
+ end
+
+ def self.with_config(config, metrics: nil)
+ new(config, metrics:)
+ end
+
+ def initialize(config, router: nil, modules: nil, metrics: nil)
@config = config
- @encrypted_marshal =
- T.let(
- EncryptedMarshal.new(config.secret_key, default_ttl: 30),
- EncryptedMarshal
- )
- # TODO: Reload routes when things change in /pages...
- # Should probably make routes into a resource type.
- @routes =
- T.let(
- Routes.build_routes(File.join(@app_root, PAGES_DIR)),
- T::Array[Routes::Route]
- )
- @reducers =
- T.let(
- State::Loader.new(File.join(@app_root, STORE_DIR)).load,
- State::Store::Reducers
- )
- @resources =
- T.let(
- if @config.use_bundle
- Resources::Registry.load(
- File.read(@config.paths.bundle_filename, encoding: "binary"),
- root:
- )
- else
- Resources::Registry.new(root: @root)
- end,
- Resources::Registry
+ @app_dir = File.join(config.root, "app")
+ @pages_dir = File.join(app_dir, "pages")
+
+ @client_path = File.join(__dir__, "client", "dist")
+ @assets_dir = File.join(config.root, ".assets")
+
+ @runtime_js_path = load_runtime_js_path
+ @init_js_body = <<~JS.freeze
+ import init from #{JSON.generate(@runtime_js_path)};
+ const sessionId = new URL(import.meta.url).hash.slice(1);
+ init(sessionId);
+ JS
+
+ @metrics =
+ metrics || Metrics::AppMetrics.setup(Prometheus::Client.registry)
+
+ @marshaller =
+ EncryptedMarshal.new(
+ config.secret_key,
+ ttl: config.server.transfer_timeout_seconds
)
- @metrics = metrics
- @fetch = T.let(Fetch.new, Fetch)
- @init_js = T.let(nil, T.nilable(String))
+
+ @router = router || Mayu::Routes::Router.build(@pages_dir)
+ @modules = modules || Modules::System.new(@app_dir, **SYSTEM_CONFIG)
end
- sig { returns(String) }
- def init_js
- @init_js ||=
- JSON.parse(File.read(File.join(js_runtime_path, "entries.json"))).fetch(
- "main"
- )
+ def asset_path(filename)
+ File.join(assets_dir, File.expand_path(filename, "/"))
end
- sig { params(name: Symbol).returns(String) }
- def path(name)
- File.join(@root, @config.paths.send(name))
+ def dump
+ MsgPackWrapper.new.pack(
+ {
+ mayu_version: Mayu::VERSION,
+ data: Marshal.dump({ modules: @modules, router: @router })
+ }
+ )
end
- sig { returns(String) }
- def js_runtime_path
- File.join(__dir__, "client", "dist")
+ def self.load(mayu_env, bundle)
+ Mayu::Configuration.with(mayu_env) do |config|
+ load_with_config(config, bundle).use { |environment| yield environment }
+ end
end
- sig do
- params(initial_state: T::Hash[Symbol, T.untyped]).returns(State::Store)
+ def self.load_with_config(config, bundle, metrics: nil)
+ data = load_bundle(bundle)
+
+ Marshal.load(data) => { modules:, router: }
+
+ new(config, router:, modules:, metrics:)
end
- def create_store(initial_state: {})
- State::Store.new(initial_state, reducers:)
+
+ private_class_method def self.load_bundle(bundle)
+ MsgPackWrapper.new.unpack(bundle) => { mayu_version:, data: }
+
+ unless mayu_version == Mayu::VERSION
+ Console.logger.warn(
+ self,
+ "App was built with Mayu #{mayu_version}. Running Mayu #{Mayu::VERSION}."
+ )
+ end
+
+ data
end
- sig do
- params(request_path: String, headers: T::Hash[String, String]).returns(
- VDOM::Descriptor
- )
+ def use(&)
+ @modules.use { yield self }
end
- def load_root(request_path, headers: {})
- path, search = request_path.split("?", 2)
- # We should match the route earlier, so that we don't have to get this
- # far in case it doesn't match...
- route_match = match_route(path.to_s)
- query = Rack::Utils.parse_nested_query(search).transform_keys(&:to_sym)
- params = route_match.params
-
- # Load the page component.
- component_path = File.join("/", "app", "pages", route_match.template)
- resources.load_resource(component_path).type =>
- Resources::Types::Component => mod_type
-
- page_component = mod_type.component
-
- resources.load_resource(File.join("/", "app", "root")).type =>
- Resources::Types::Component => root
-
- request_info = { path:, params:, query:, headers: }.freeze
-
- # Apply the layouts.
- # NOTE: Pages should probably be their own
- # resource type and load their layouts.
- route_match
- .layouts
- .reverse
- .reduce(VDOM::H[page_component, request: request_info]) do |app, layout|
- Console.logger.info(self, "Applying layout #{layout.inspect}")
-
- resources.load_resource(
- File.join("/", "app", "pages", layout)
- ).type => Resources::Types::Component => layout
-
- VDOM::H[layout.component, app, request: request_info]
+
+ def start_watcher
+ Async do
+ Mayu::Watcher.run(@modules) do |events|
+ if events.any? { |event| is_route_event?(event) }
+ Console.logger.info(self, "Rebuilding routes")
+ @router = Mayu::Routes::Router.build(@pages_dir)
+ end
+
+ @modules.handle_watch_events(events)
end
- .then { VDOM::H[root.component, _1] }
+ end
+ end
+
+ private
+
+ def load_runtime_js_path
+ File
+ .read(File.join(@client_path, "entries.json"))
+ .then { JSON.parse(_1) }
+ .fetch("main")
+ .then { File.join("/.mayu/runtime", _1) }
end
- sig { params(request_path: String).returns(Routes::RouteMatch) }
- def match_route(request_path)
- Routes.match_route(@routes, request_path)
+ def is_route_event?(event)
+ if event in Watcher::Events::Created | Watcher::Events::Deleted
+ if event.path.start_with?("/pages/")
+ File.basename(event.path) in
+ "page.haml" | "layout.haml" | "not_found.haml" | "template.haml"
+ else
+ false
+ end
+ else
+ false
+ end
end
end
end
diff --git a/lib/mayu/event_stream.rb b/lib/mayu/event_stream.rb
deleted file mode 100644
index 4d670975..00000000
--- a/lib/mayu/event_stream.rb
+++ /dev/null
@@ -1,158 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-require "nanoid"
-require "msgpack"
-require "zlib"
-
-module Mayu
- module EventStream
- class Writable
- extend T::Sig
-
- sig { params(body: Async::HTTP::Body::Writable).void }
- def initialize(body)
- @body = body
- @deflate =
- T.let(
- Zlib::Deflate.new(
- Zlib::BEST_COMPRESSION,
- -Zlib::MAX_WBITS,
- Zlib::MAX_MEM_LEVEL,
- Zlib::HUFFMAN_ONLY
- ),
- Zlib::Deflate
- )
-
- @wrapper = T.let(EventStream::Wrapper.new, EventStream::Wrapper)
- end
-
- sig { params(obj: T.untyped).void }
- def write(obj)
- obj
- .then { @wrapper.pack(_1.to_a) }
- .then { @deflate.deflate(_1, Zlib::SYNC_FLUSH) }
- .then { @body.write(_1) }
- end
-
- sig { returns(T::Boolean) }
- def closed?
- @body.closed?
- end
-
- sig { void }
- def close
- @body.write(@deflate.flush(Zlib::FINISH))
- @deflate.close
- @body.close
- end
- end
-
- class Blob
- extend T::Sig
-
- sig { params(data: String).void }
- def initialize(data)
- @data = data
- end
-
- sig { params(data: String).returns(T.attached_class) }
- def self.from_msgpack_ext(data)
- new(data)
- end
-
- sig { returns(String) }
- def to_msgpack_ext
- @data
- end
- end
-
- class Wrapper < MessagePack::Factory
- extend T::Sig
-
- sig { void }
- def initialize
- super()
-
- self.register_type(0x01, Blob)
- end
- end
-
- class Message
- extend T::Sig
-
- sig { returns(String) }
- attr_reader :id
- sig { returns(String) }
- attr_reader :event
- sig { returns(T.untyped) }
- attr_reader :data
-
- sig { params(event: T.any(String, Symbol), data: T.untyped).void }
- def initialize(event, data = {})
- @id = T.let(Nanoid.generate, String)
- @event = T.let(event.to_s, String)
- @data = data
- end
-
- sig { returns([String, String, T.untyped]) }
- def to_a
- [@id, @event, @data]
- end
- end
-
- class Log
- extend T::Sig
-
- sig { void }
- def initialize
- @history = T.let([], T::Array[Message])
- @queue = T.let(Async::Queue.new, Async::Queue)
- @wrapper = T.let(Wrapper.new, Wrapper)
- end
-
- sig { returns(T::Boolean) }
- def empty? = @queue.empty?
-
- sig { returns(Integer) }
- def size = @queue.size
-
- sig { params(event: Symbol, data: T.untyped).void }
- def push(event, data = {})
- @queue.enqueue(Message.new(event, data))
- end
-
- sig { params(id: String).void }
- def ack(id)
- if index = @history.map(&:id).index(id)
- @history.slice!(0..index)
- end
- end
-
- sig { params(last_id: String).returns(T::Array[Message]) }
- def replay(last_id)
- ack(last_id)
- @history.dup
- end
-
- sig { returns(Message) }
- def pop
- message = @queue.dequeue
- # There is no ack-functionality in the client so this will just grow anyways..
- # @history.push(message)
- message
- end
-
- sig { params(message: Message).returns(String) }
- def pack(message)
- data = @wrapper.pack(message.to_a)
- # N = 32-bit unsigned, network (big-endian) byte order
- # a = arbitrary binary string (null padded, count is width)
- [data.bytesize, data].pack("N a*")
- end
- end
-
- class Stream
- end
- end
-end
diff --git a/lib/mayu/fetch.rb b/lib/mayu/fetch.rb
deleted file mode 100644
index 9ec63222..00000000
--- a/lib/mayu/fetch.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# typed: strict
-
-require "bundler/setup"
-require "sorbet-runtime"
-require "async"
-require "async/http/internet"
-require "rack/utils"
-require "pry"
-require "uri"
-
-module Mayu
- # This class implements a simplified version of the Fetch API.
- # https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- class Fetch
- class Response < T::Struct
- extend T::Sig
-
- const :url, String
- const :body, String
- const :headers, T::Hash[String, T.any(String, T::Array[String])]
- const :status, Integer
- const :status_text, String
- const :ok, T::Boolean
- const :redirected, T::Boolean
-
- alias ok? ok
- alias redirected? redirected
-
- sig { params(symbolize_names: T::Boolean).returns(T.untyped) }
- def json(symbolize_names: false)
- JSON.parse(body, symbolize_names:)
- end
-
- sig { returns(String) }
- def content_type
- headers.fetch("content-type").to_s
- end
-
- sig { returns(String) }
- def inspect
- "<##{self.class.name} url=#{url.inspect} status=#{status.inspect} status_text=#{status_text.inspect} content_type=#{content_type.inspect} body=#{body.bytesize}b>"
- end
- end
-
- extend T::Sig
-
- sig { void }
- def initialize
- @internet = T.let(Async::HTTP::Internet.new, Async::HTTP::Internet)
- end
-
- sig do
- params(
- url: String,
- method: Symbol,
- headers: T::Hash[String, String],
- body: T.nilable(String)
- ).returns(Response)
- end
- def fetch(url, method: :GET, headers: {}, body: nil)
- puts "\e[35mFETCHING #{url}\e[0m"
- res = @internet.call(method, url, headers.to_a, body)
- puts "\e[34mFETCHED #{url}\e[0m"
-
- Response.new(
- url:,
- body: res.read,
- headers: res.headers.to_h,
- status: res.status,
- status_text: Rack::Utils::HTTP_STATUS_CODES[res.status],
- ok: res.success?,
- redirected: res.redirection?
- )
- rescue => e
- puts "\e[32mFAILED ON #{url}\e[0m"
- raise
- end
- end
-end
-#
-# Async do
-# fetch = Mayu::Fetch.new
-# res =
-# fetch.fetch(
-# "https://raw.githubusercontent.com/rack/rack/main/lib/rack/utils.rb"
-# )
-# p res
-# end
diff --git a/lib/mayu/html.rb b/lib/mayu/html.rb
deleted file mode 100644
index 0ffe3dd0..00000000
--- a/lib/mayu/html.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# typed: strict
-
-require "yaml"
-
-module Mayu
- module HTML
- extend T::Sig
-
- data = YAML.load_file(File.join(File.dirname(__FILE__), "html.yaml"))
- # Source:
- # https://raw.githubusercontent.com/sindresorhus/html-tags/ff16c695dcf77e1973d17941c36af6ceda4bda10/html-tags-void.json
- VOID_TAGS = T.let(data.fetch(:VOID_TAGS).freeze, T::Array[Symbol])
- # Source:
- # https://raw.githubusercontent.com/sindresorhus/html-tags/ff16c695dcf77e1973d17941c36af6ceda4bda10/html-tags.json
- TAGS = T.let(data.fetch(:TAGS).freeze, T::Array[Symbol])
- # Source:
- # https://raw.githubusercontent.com/wooorm/html-element-attributes/270d8cec96afc251e1501ea5b8e16ad52b8bf875/index.js
- GLOBAL_ATTRIBUTES =
- T.let(data.fetch(:GLOBAL_ATTRIBUTES).freeze, T::Array[Symbol])
- # Source:
- # https://raw.githubusercontent.com/wooorm/html-event-attributes/b6ee29864ca378f5084980445abed418ef0f1ab9/index.js
- EVENT_HANDLER_ATTRIBUTES =
- T.let(data.fetch(:EVENT_HANDLER_ATTRIBUTES).freeze, T::Array[Symbol])
- # Source:
- # https://raw.githubusercontent.com/wooorm/html-element-attributes/270d8cec96afc251e1501ea5b8e16ad52b8bf875/index.js
- ATTRIBUTES =
- T.let(data.fetch(:ATTRIBUTES).freeze, T::Hash[Symbol, T::Array[Symbol]])
- # Source:
- # https://gist.githubusercontent.com/ArjanSchouten/0b8574a6ad7f5065a5e7/raw/bf4d4a6becc3bd8e9840839971011db87e5ec68c/HTML%2520boolean%2520attributes%2520list
- BOOLEAN_ATTRIBUTES =
- T.let(data.fetch(:BOOLEAN_ATTRIBUTES).freeze, T::Array[Symbol])
-
- sig { params(tag: Symbol).returns(T::Boolean) }
- def self.void_tag?(tag)
- VOID_TAGS.include?(tag)
- end
-
- sig { params(tag: Symbol).returns(T::Array[Symbol]) }
- def self.attributes_for(tag)
- GLOBAL_ATTRIBUTES + EVENT_HANDLER_ATTRIBUTES + ATTRIBUTES.fetch(tag, [])
- end
-
- sig { params(attribute: Symbol).returns(T::Boolean) }
- def self.boolean_attribute?(attribute)
- BOOLEAN_ATTRIBUTES.include?(attribute)
- end
-
- sig { params(attribute: Symbol).returns(T::Boolean) }
- def self.event_handler_attribute?(attribute)
- EVENT_HANDLER_ATTRIBUTES.include?(attribute)
- end
- end
-end
diff --git a/lib/mayu/html.yaml b/lib/mayu/html.yaml
deleted file mode 100644
index 79a6e588..00000000
--- a/lib/mayu/html.yaml
+++ /dev/null
@@ -1,767 +0,0 @@
----
-:TAGS:
- - :a
- - :abbr
- - :address
- - :area
- - :article
- - :aside
- - :audio
- - :b
- - :base
- - :bdi
- - :bdo
- - :blockquote
- - :body
- - :br
- - :button
- - :canvas
- - :caption
- - :cite
- - :code
- - :col
- - :colgroup
- - :data
- - :datalist
- - :dd
- - :del
- - :details
- - :dfn
- - :dialog
- - :div
- - :dl
- - :dt
- - :em
- - :embed
- - :fieldset
- - :figcaption
- - :figure
- - :footer
- - :form
- - :h1
- - :h2
- - :h3
- - :h4
- - :h5
- - :h6
- - :head
- - :header
- - :hgroup
- - :hr
- - :html
- - :i
- - :iframe
- - :img
- - :input
- - :ins
- - :kbd
- - :label
- - :legend
- - :li
- - :link
- - :main
- - :map
- - :mark
- - :math
- - :menu
- - :menuitem
- - :meta
- - :meter
- - :nav
- - :noscript
- - :object
- - :ol
- - :optgroup
- - :option
- - :output
- - :p
- - :param
- - :picture
- - :pre
- - :progress
- - :q
- - :rb
- - :rp
- - :rt
- - :rtc
- - :ruby
- - :s
- - :samp
- - :script
- - :section
- - :select
- - :slot
- - :small
- - :source
- - :span
- - :strong
- - :style
- - :sub
- - :summary
- - :sup
- - :svg
- - :table
- - :tbody
- - :td
- - :template
- - :textarea
- - :tfoot
- - :th
- - :thead
- - :time
- - :title
- - :tr
- - :track
- - :u
- - :ul
- - :var
- - :video
- - :wbr
- - :area
- - :base
- - :br
- - :col
- - :embed
- - :hr
- - :img
- - :input
- - :link
- - :menuitem
- - :meta
- - :param
- - :source
- - :track
- - :wbr
-:GLOBAL_ATTRIBUTES:
- - :accesskey
- - :autocapitalize
- - :autofocus
- - :class
- - :contenteditable
- - :dir
- - :draggable
- - :enterkeyhint
- - :hidden
- - :id
- - :inputmode
- - :is
- - :itemid
- - :itemprop
- - :itemref
- - :itemscope
- - :itemtype
- - :lang
- - :nonce
- - :slot
- - :spellcheck
- - :style
- - :tabindex
- - :title
- - :translate
-:EVENT_HANDLER_ATTRIBUTES:
- - :onabort
- - :onafterprint
- - :onauxclick
- - :onbeforeprint
- - :onbeforeunload
- - :onblur
- - :oncancel
- - :oncanplay
- - :oncanplaythrough
- - :onchange
- - :onclick
- - :onclose
- - :oncontextlost
- - :oncontextmenu
- - :oncontextrestored
- - :oncopy
- - :oncuechange
- - :oncut
- - :ondblclick
- - :ondrag
- - :ondragend
- - :ondragenter
- - :ondragleave
- - :ondragover
- - :ondragstart
- - :ondrop
- - :ondurationchange
- - :onemptied
- - :onended
- - :onerror
- - :onfocus
- - :onformdata
- - :onhashchange
- - :oninput
- - :oninvalid
- - :onkeydown
- - :onkeypress
- - :onkeyup
- - :onlanguagechange
- - :onload
- - :onloadeddata
- - :onloadedmetadata
- - :onloadstart
- - :onmessage
- - :onmessageerror
- - :onmousedown
- - :onmouseenter
- - :onmouseleave
- - :onmousemove
- - :onmouseout
- - :onmouseover
- - :onmouseup
- - :onoffline
- - :ononline
- - :onpagehide
- - :onpageshow
- - :onpaste
- - :onpause
- - :onplay
- - :onplaying
- - :onpopstate
- - :onprogress
- - :onratechange
- - :onrejectionhandled
- - :onreset
- - :onresize
- - :onscroll
- - :onsecuritypolicyviolation
- - :onseeked
- - :onseeking
- - :onselect
- - :onslotchange
- - :onstalled
- - :onstorage
- - :onsubmit
- - :onsuspend
- - :ontimeupdate
- - :ontoggle
- - :onunhandledrejection
- - :onunload
- - :onvolumechange
- - :onwaiting
- - :onwheel
-:ATTRIBUTES:
- :a:
- - :charset
- - :coords
- - :download
- - :href
- - :hreflang
- - :name
- - :ping
- - :referrerpolicy
- - :rel
- - :rev
- - :shape
- - :target
- - :type
- :applet:
- - :align
- - :alt
- - :archive
- - :code
- - :codebase
- - :height
- - :hspace
- - :name
- - :object
- - :vspace
- - :width
- :area:
- - :alt
- - :coords
- - :download
- - :href
- - :hreflang
- - :nohref
- - :ping
- - :referrerpolicy
- - :rel
- - :shape
- - :target
- - :type
- :audio:
- - :autoplay
- - :controls
- - :crossorigin
- - :loop
- - :muted
- - :preload
- - :src
- :base:
- - :href
- - :target
- :basefont:
- - :color
- - :face
- - :size
- :blockquote:
- - :cite
- :body:
- - :alink
- - :background
- - :bgcolor
- - :link
- - :text
- - :vlink
- :br:
- - :clear
- :button:
- - :disabled
- - :form
- - :formaction
- - :formenctype
- - :formmethod
- - :formnovalidate
- - :formtarget
- - :name
- - :type
- - :value
- :canvas:
- - :height
- - :width
- :caption:
- - :align
- :col:
- - :align
- - :char
- - :charoff
- - :span
- - :valign
- - :width
- :colgroup:
- - :align
- - :char
- - :charoff
- - :span
- - :valign
- - :width
- :data:
- - :value
- :del:
- - :cite
- - :datetime
- :details:
- - :open
- :dialog:
- - :open
- :dir:
- - :compact
- :div:
- - :align
- :dl:
- - :compact
- :embed:
- - :height
- - :src
- - :type
- - :width
- :fieldset:
- - :disabled
- - :form
- - :name
- :font:
- - :color
- - :face
- - :size
- :form:
- - :accept
- - :accept-charset
- - :action
- - :autocomplete
- - :enctype
- - :method
- - :name
- - :novalidate
- - :target
- :frame:
- - :frameborder
- - :longdesc
- - :marginheight
- - :marginwidth
- - :name
- - :noresize
- - :scrolling
- - :src
- :frameset:
- - :cols
- - :rows
- :h1:
- - :align
- :h2:
- - :align
- :h3:
- - :align
- :h4:
- - :align
- :h5:
- - :align
- :h6:
- - :align
- :head:
- - :profile
- :hr:
- - :align
- - :noshade
- - :size
- - :width
- :html:
- - :manifest
- - :version
- :iframe:
- - :align
- - :allow
- - :allowfullscreen
- - :allowpaymentrequest
- - :allowusermedia
- - :frameborder
- - :height
- - :loading
- - :longdesc
- - :marginheight
- - :marginwidth
- - :name
- - :referrerpolicy
- - :sandbox
- - :scrolling
- - :src
- - :srcdoc
- - :width
- :img:
- - :align
- - :alt
- - :border
- - :crossorigin
- - :decoding
- - :height
- - :hspace
- - :ismap
- - :loading
- - :longdesc
- - :name
- - :referrerpolicy
- - :sizes
- - :src
- - :srcset
- - :usemap
- - :vspace
- - :width
- :input:
- - :accept
- - :align
- - :alt
- - :autocomplete
- - :checked
- - :dirname
- - :disabled
- - :form
- - :formaction
- - :formenctype
- - :formmethod
- - :formnovalidate
- - :formtarget
- - :height
- - :ismap
- - :list
- - :max
- - :maxlength
- - :min
- - :minlength
- - :multiple
- - :name
- - :pattern
- - :placeholder
- - :readonly
- - :required
- - :size
- - :src
- - :step
- - :type
- - :usemap
- - :value
- - :width
- :ins:
- - :cite
- - :datetime
- :isindex:
- - :prompt
- :label:
- - :for
- - :form
- :legend:
- - :align
- :li:
- - :type
- - :value
- :link:
- - :as
- - :charset
- - :color
- - :crossorigin
- - :disabled
- - :href
- - :hreflang
- - :imagesizes
- - :imagesrcset
- - :integrity
- - :media
- - :referrerpolicy
- - :rel
- - :rev
- - :sizes
- - :target
- - :type
- :map:
- - :name
- :menu:
- - :compact
- :meta:
- - :charset
- - :content
- - :http-equiv
- - :media
- - :name
- - :scheme
- :meter:
- - :high
- - :low
- - :max
- - :min
- - :optimum
- - :value
- :object:
- - :align
- - :archive
- - :border
- - :classid
- - :codebase
- - :codetype
- - :data
- - :declare
- - :form
- - :height
- - :hspace
- - :name
- - :standby
- - :type
- - :typemustmatch
- - :usemap
- - :vspace
- - :width
- :ol:
- - :compact
- - :reversed
- - :start
- - :type
- :optgroup:
- - :disabled
- - :label
- :option:
- - :disabled
- - :label
- - :selected
- - :value
- :output:
- - :for
- - :form
- - :name
- :p:
- - :align
- :param:
- - :name
- - :type
- - :value
- - :valuetype
- :pre:
- - :width
- :progress:
- - :max
- - :value
- :q:
- - :cite
- :script:
- - :async
- - :charset
- - :crossorigin
- - :defer
- - :integrity
- - :language
- - :nomodule
- - :referrerpolicy
- - :src
- - :type
- :select:
- - :autocomplete
- - :disabled
- - :form
- - :multiple
- - :name
- - :required
- - :size
- :slot:
- - :name
- :source:
- - :height
- - :media
- - :sizes
- - :src
- - :srcset
- - :type
- - :width
- :style:
- - :media
- - :type
- :table:
- - :align
- - :bgcolor
- - :border
- - :cellpadding
- - :cellspacing
- - :frame
- - :rules
- - :summary
- - :width
- :tbody:
- - :align
- - :char
- - :charoff
- - :valign
- :td:
- - :abbr
- - :align
- - :axis
- - :bgcolor
- - :char
- - :charoff
- - :colspan
- - :headers
- - :height
- - :nowrap
- - :rowspan
- - :scope
- - :valign
- - :width
- :textarea:
- - :autocomplete
- - :cols
- - :dirname
- - :disabled
- - :form
- - :maxlength
- - :minlength
- - :name
- - :placeholder
- - :readonly
- - :required
- - :rows
- - :wrap
- :tfoot:
- - :align
- - :char
- - :charoff
- - :valign
- :th:
- - :abbr
- - :align
- - :axis
- - :bgcolor
- - :char
- - :charoff
- - :colspan
- - :headers
- - :height
- - :nowrap
- - :rowspan
- - :scope
- - :valign
- - :width
- :thead:
- - :align
- - :char
- - :charoff
- - :valign
- :time:
- - :datetime
- :tr:
- - :align
- - :bgcolor
- - :char
- - :charoff
- - :valign
- :track:
- - :default
- - :kind
- - :label
- - :src
- - :srclang
- :ul:
- - :compact
- - :type
- :video:
- - :autoplay
- - :controls
- - :crossorigin
- - :height
- - :loop
- - :muted
- - :playsinline
- - :poster
- - :preload
- - :src
- - :width
-:BOOLEAN_ATTRIBUTES:
- - :async
- - :autocomplete
- - :autofocus
- - :autoplay
- - :border
- - :challenge
- - :checked
- - :compact
- - :contenteditable
- - :controls
- - :default
- - :defer
- - :disabled
- - :formNoValidate
- - :frameborder
- - :hidden
- - :indeterminate
- - :ismap
- - :loop
- - :multiple
- - :muted
- - :nohref
- - :noresize
- - :noshade
- - :novalidate
- - :nowrap
- - :open
- - :readonly
- - :required
- - :reversed
- - :scoped
- - :scrolling
- - :seamless
- - :selected
- - :sortable
- - :spellcheck
- - :translate
-:VOID_TAGS:
- - :area
- - :base
- - :br
- - :col
- - :embed
- - :hr
- - :img
- - :input
- - :link
- - :menuitem
- - :meta
- - :param
- - :source
- - :track
- - :wbr
diff --git a/lib/mayu/image.rb b/lib/mayu/image.rb
new file mode 100644
index 00000000..39e49ba0
--- /dev/null
+++ b/lib/mayu/image.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ BREAKPOINTS = [120, 240, 320, 640, 768, 960, 1024, 1366, 1600, 1920, 3840]
+
+ ImageVersion =
+ Data.define(:filename, :width) do
+ def public_path
+ Kernel.format("/.mayu/assets/%s", filename)
+ end
+ end
+
+ Image =
+ Data.define(:versions, :width, :height, :blur_src) do
+ def public_path = versions.first.public_path
+
+ def to_s = public_path
+ def src = public_path
+ def blur_url = "url(#{blur_src})"
+
+ def sizes
+ # TODO: https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes
+ nil
+ end
+
+ def srcset
+ versions.map { |v| "#{v.public_path} #{v.width}w" }.join(",")
+ end
+ end
+end
diff --git a/lib/mayu/metrics.rb b/lib/mayu/metrics.rb
index b0e98115..3f17b089 100644
--- a/lib/mayu/metrics.rb
+++ b/lib/mayu/metrics.rb
@@ -1,32 +1,23 @@
-# typed: strict
-
-require "bundler/setup"
-require "async"
-require "async/container"
-require "async/semaphore"
-require "async/http"
-require "async/io/unix_endpoint"
-require "async/io/shared_endpoint"
-require "msgpack"
-require "nanoid"
-require "prometheus/client"
-require "prometheus/client/formats/text"
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+require "prometheus/client"
+require "msgpack"
+require "uri"
+require "io/endpoint"
+require "io/endpoint/unix_endpoint"
+require_relative "metrics/app_metrics"
require_relative "metrics/collector"
-require_relative "metrics/exporter"
require_relative "metrics/reporter"
+require_relative "metrics/server"
module Mayu
module Metrics
- InternalStore = T.type_alias { T::Hash[Symbol, MetricHash] }
- MetricHash = T.type_alias { T::Hash[Symbol, ValueHash] }
- ValueHash = T.type_alias { T::Hash[LabelsHash, Float] }
- LabelsHash = T.type_alias { T::Hash[Symbol, T.untyped] }
+ MAX_PORT_BIND_ATTEMPTS = 100
class Wrapper < MessagePack::Factory
- extend T::Sig
-
- sig { void }
def initialize
super()
@@ -34,49 +25,91 @@ def initialize
end
end
- extend T::Sig
-
- sig do
- params(
- container: Async::Container::Generic,
- exporter_endpoint: Async::HTTP::Endpoint,
- collector_endpoint: Async::IO::UNIXEndpoint,
- block: T.proc.params(arg0: Prometheus::Client::Registry).void
- ).void
+ def self.collector_endpoint(root)
+ IO::Endpoint.unix(File.join(root, "metrics.ipc"))
end
+
def self.start_collect_and_export(
container,
- exporter_endpoint:,
collector_endpoint:,
- &block
+ listen:,
+ &setup_registry
)
- collector = Metrics::Collector::Server.new(collector_endpoint)
- collector.start
+ container.run(name: "Mayu metrics", count: 1, restart: true) do |instance|
+ collector = nil
+ collector_task = nil
+ metrics_task = nil
+ internal_store = {}
- container.spawn(
- name: "Metrics collector/exporter",
- restart: true
- ) do |instance|
- Async do
- internal_store = {}
+ Async do |task|
+ collector = Collector::Server.new(collector_endpoint)
+ collector.start
Prometheus::Client.config.data_store =
- Metrics::Collector::DataStore.new(internal_store)
+ Collector::DataStore.new(internal_store)
registry = Prometheus::Client::Registry.new
+ setup_registry&.call(registry)
+
+ collector_task = task.async { collector.run(internal_store) }
+ metrics_task =
+ task.async do
+ run_metrics_server_with_port_retry(registry:, listen:)
+ end
+
+ instance.ready!
+ task.wait_all
+ rescue Interrupt
+ wait_for_reporters_to_disconnect(internal_store)
+ ensure
+ collector_task&.stop
+ metrics_task&.stop
+ collector&.stop
+ end
+ end
+ end
- yield registry
+ def self.run_metrics_server_with_port_retry(registry:, listen:)
+ uri = URI.parse(listen)
+ attempts = 0
- Metrics::Exporter::Server.setup(
- endpoint: exporter_endpoint,
- registry:
- ).run
+ loop do
+ metrics_server = Server.new(registry:, listen: uri.to_s)
- collector.run(internal_store)
+ begin
+ metrics_server.run.wait
+ return
+ rescue Errno::EADDRINUSE
+ attempts += 1
- instance.ready!
+ raise if attempts >= MAX_PORT_BIND_ATTEMPTS || !uri.port
+
+ uri.port += 1
+
+ Console.logger.warn(
+ self,
+ "Metrics port unavailable, retrying with #{uri}"
+ )
end
end
end
+
+ def self.wait_for_reporters_to_disconnect(internal_store, timeout: 5)
+ return if internal_store.nil? || internal_store.empty?
+
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+
+ Console.logger.info(
+ self,
+ "Waiting for metrics reporters to disconnect...",
+ active: internal_store.size,
+ timeout:
+ )
+
+ while internal_store.any?
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+ sleep(0.05)
+ end
+ end
end
end
diff --git a/lib/mayu/metrics/aggregation.test.rb b/lib/mayu/metrics/aggregation.test.rb
new file mode 100644
index 00000000..b06ea709
--- /dev/null
+++ b/lib/mayu/metrics/aggregation.test.rb
@@ -0,0 +1,143 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+require "socket"
+require "net/http"
+require "tmpdir"
+
+require "async"
+require "async/container"
+require "async/http"
+
+require_relative "../metrics"
+
+class Mayu::Metrics::AggregationTest < Minitest::Test
+ TEST_METRIC_NAME = :mayu_test_worker_boot_count
+
+ def test_aggregates_metrics_from_multiple_workers
+ Dir.mktmpdir("mayu-metrics-test") do |root|
+ collector_endpoint = Mayu::Metrics.collector_endpoint(root)
+ listen = "http://127.0.0.1:#{allocate_port}"
+
+ container = Async::Container::Forked.new
+
+ Mayu::Metrics.start_collect_and_export(
+ container,
+ collector_endpoint:,
+ listen:
+ ) do |registry|
+ registry.counter(TEST_METRIC_NAME, docstring: "Test worker boot count")
+ end
+
+ container.run(
+ name: "metrics-worker",
+ count: 2,
+ restart: false
+ ) do |instance|
+ session = nil
+
+ begin
+ Async do |task|
+ session =
+ Mayu::Metrics::Reporter.run(
+ collector_endpoint,
+ task:
+ ) do |registry|
+ {
+ boot_count:
+ registry.counter(
+ TEST_METRIC_NAME,
+ docstring: "Test worker boot count"
+ )
+ }
+ end
+
+ session.metrics.fetch(:boot_count).increment
+ instance.ready!
+
+ sleep
+ end
+ rescue Interrupt
+ ensure
+ session&.stop
+ end
+ end
+
+ container.wait_until_ready
+
+ wait_until(
+ timeout: 8,
+ message: "Timed out waiting for aggregated metrics"
+ ) do
+ metrics = fetch_metrics(listen)
+
+ metrics.match?(/#{TEST_METRIC_NAME}\s+2(?:\.0+)?\b/)
+ rescue Errno::ECONNREFUSED, EOFError
+ false
+ end
+ ensure
+ container&.stop(2)
+ end
+ end
+
+ def test_retries_metrics_port_when_unavailable
+ Dir.mktmpdir("mayu-metrics-test") do |root|
+ collector_endpoint = Mayu::Metrics.collector_endpoint(root)
+ reserved_socket = TCPServer.new("127.0.0.1", 0)
+ unavailable_port = reserved_socket.addr[1]
+
+ listen = "http://127.0.0.1:#{unavailable_port}"
+ fallback_uri = URI("http://127.0.0.1:#{unavailable_port + 1}/metrics")
+
+ container = Async::Container::Forked.new
+
+ Mayu::Metrics.start_collect_and_export(
+ container,
+ collector_endpoint:,
+ listen:
+ ) { |_registry| }
+
+ container.wait_until_ready
+
+ wait_until(
+ timeout: 8,
+ message: "Timed out waiting for metrics server fallback port"
+ ) do
+ response = Net::HTTP.get_response(fallback_uri)
+ response.is_a?(Net::HTTPSuccess)
+ rescue Errno::ECONNREFUSED, EOFError
+ false
+ end
+ ensure
+ reserved_socket&.close
+ container&.stop(2)
+ end
+ end
+
+ private
+
+ def allocate_port
+ TCPServer.open("127.0.0.1", 0) { |server| server.addr[1] }
+ end
+
+ def fetch_metrics(listen)
+ uri = URI("#{listen}/metrics")
+ Net::HTTP.get(uri)
+ end
+
+ def wait_until(timeout:, message:)
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+
+ loop do
+ return if yield
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+ sleep 0.1
+ end
+
+ flunk(message)
+ end
+end
diff --git a/lib/mayu/metrics/app_metrics.rb b/lib/mayu/metrics/app_metrics.rb
new file mode 100644
index 00000000..6ec7c2bf
--- /dev/null
+++ b/lib/mayu/metrics/app_metrics.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Metrics
+ AppMetrics =
+ Data.define(
+ :session_count,
+ :session_init_count,
+ :session_timeout_count,
+ :session_ping_count,
+ :session_callback_count,
+ :session_navigate_count,
+ :component_mount_count,
+ :component_patch_times,
+ :component_children_update_times,
+ :update_child_id_count,
+ :update_chunk_count,
+ :error_count
+ ) do
+ def self.setup(registry, **preset_labels)
+ gauge_store_settings =
+ if Prometheus::Client.config.data_store.is_a?(
+ Prometheus::Client::DataStores::Synchronized
+ )
+ {}
+ else
+ { aggregation: :sum }
+ end
+
+ new(
+ session_count:
+ registry.gauge(
+ :mayu_session_count,
+ docstring: "Number of active sessions",
+ labels: [*preset_labels.keys],
+ preset_labels:,
+ store_settings: gauge_store_settings
+ ),
+ session_init_count:
+ registry.counter(
+ :mayu_session_init_count,
+ docstring: "Total number of sessions created",
+ labels: [*preset_labels.keys],
+ preset_labels:
+ ),
+ session_timeout_count:
+ registry.counter(
+ :mayu_session_timeout_count,
+ docstring: "Total number of sessions timed out",
+ labels: [*preset_labels.keys],
+ preset_labels:
+ ),
+ session_ping_count:
+ registry.counter(
+ :mayu_session_ping_count,
+ docstring: "Total number of pings",
+ labels: [*preset_labels.keys],
+ preset_labels:
+ ),
+ session_callback_count:
+ registry.counter(
+ :mayu_session_callback_count,
+ docstring: "Total number of callbacks",
+ labels: [:component, :method, *preset_labels.keys],
+ preset_labels:
+ ),
+ session_navigate_count:
+ registry.counter(
+ :mayu_session_navigate_count,
+ docstring: "Total number of navigates",
+ labels: [:path, *preset_labels.keys],
+ preset_labels:
+ ),
+ error_count:
+ registry.counter(
+ :mayu_error_count,
+ docstring: "Total number errors",
+ labels: [*preset_labels.keys],
+ preset_labels:
+ ),
+ component_mount_count:
+ registry.counter(
+ :mayu_component_mount_times,
+ docstring: "Component mount count",
+ labels: [:component, *preset_labels.keys],
+ preset_labels:
+ ),
+ component_children_update_times:
+ registry.summary(
+ :mayu_component_children_update_times,
+ docstring: "Component patch times",
+ labels: [:component, *preset_labels.keys],
+ preset_labels:
+ ),
+ component_patch_times:
+ registry.summary(
+ :mayu_component_patch_times,
+ docstring: "Component patch times",
+ labels: [:component, *preset_labels.keys],
+ preset_labels:
+ ),
+ update_child_id_count:
+ registry.counter(
+ :mayu_update_child_id_count,
+ docstring: "Number of child IDs updated",
+ labels: [:tag_name, *preset_labels.keys],
+ preset_labels:
+ ),
+ update_chunk_count:
+ registry.counter(
+ :mayu_update_chunk_count,
+ docstring: "Number of chunked child update resumes",
+ labels: [:tag_name, *preset_labels.keys],
+ preset_labels:
+ )
+ )
+ end
+
+ def update_summary(summary, labels: {})
+ value = nil
+
+ summary.observe(measure_time { value = yield }, labels:)
+
+ value
+ end
+
+ def measure_time(unit = :float_millisecond)
+ start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, unit)
+ yield
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, unit) - start_at
+ end
+ end
+ end
+end
diff --git a/lib/mayu/metrics/collector.rb b/lib/mayu/metrics/collector.rb
index bc724656..c483ddb4 100644
--- a/lib/mayu/metrics/collector.rb
+++ b/lib/mayu/metrics/collector.rb
@@ -1,134 +1,104 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "fileutils"
module Mayu
module Metrics
module Collector
class DataStore
class MetricStore
- extend T::Sig
-
- sig { returns(DataStore) }
- attr_reader :store
-
- sig do
- params(
- store: DataStore,
- metric_name: Symbol,
- metric_type: Symbol,
- metric_settings: T::Hash[Symbol, T.untyped]
- ).void
- end
def initialize(store, metric_name, metric_type:, metric_settings: {})
@store = store
@metric_name = metric_name
@metric_type = metric_type
- @metric_settings = metric_settings
- @aggregation_mode =
- T.let(metric_settings.fetch(:aggregation, :sum), Symbol)
+ @aggregation =
+ metric_settings.fetch(
+ :aggregation,
+ metric_settings.fetch("aggregation", :sum)
+ ).to_sym
end
- sig do
- params(
- val: T.any(Integer, Float),
- labels: T::Hash[Symbol, T.untyped]
- ).void
+ def synchronize
+ yield
end
+
+ # Aggregated store is read-only from Prometheus perspective.
def set(val:, labels: {})
end
- sig { returns(T::Hash[T::Array[Symbol], Float]) }
+ # Aggregated store is read-only from Prometheus perspective.
+ def increment(by: 1, labels: {})
+ end
+
+ def get(labels:)
+ all_values.fetch(labels, 0.0)
+ end
+
def all_values
@store
.values_for_metric(@metric_name)
- .transform_values { aggregate(_1) }
+ .transform_values { |values| aggregate(values) }
end
private
- sig { params(values: T::Array[Float]).returns(Float) }
def aggregate(values)
- case @aggregation_mode
- when :min
- values.min
- when :max
- values.max
- when :sum
+ case @aggregation
+ in :sum
values.sum
+ in :max
+ values.max || 0.0
+ in :min
+ values.min || 0.0
else
- raise "Invalid aggregation setting"
+ raise "Invalid aggregation mode: #{@aggregation.inspect}"
end.to_f
end
end
- extend T::Sig
-
- sig { returns(InternalStore) }
- attr_reader :internal_store
-
- sig { params(internal_store: InternalStore).void }
def initialize(internal_store = {})
@internal_store = internal_store
end
- sig do
- params(metric_name: Symbol).returns(
- T::Hash[T::Array[Symbol], T::Array[Float]]
- )
- end
def values_for_metric(metric_name)
@internal_store
.values
.map { _1[metric_name] }
.compact
- .each_with_object(Hash.new { |h, k| h[k] = [] }) do |entries, obj|
- entries.each { |labels, value| obj[labels] << value }
+ .each_with_object(
+ Hash.new { |hash, key| hash[key] = [] }
+ ) do |metric_values, all_values|
+ metric_values.each do |labels, value|
+ all_values[labels] << value.to_f
+ end
end
end
- sig do
- params(
- metric_name: Symbol,
- metric_type: Symbol,
- metric_settings: T::Hash[Symbol, T.untyped]
- ).returns(MetricStore)
- end
def for_metric(metric_name, metric_type:, metric_settings: {})
- MetricStore.new(self, metric_name, metric_type:, metric_settings: {})
+ MetricStore.new(self, metric_name, metric_type:, metric_settings:)
end
end
class Server
- extend T::Sig
-
- sig { returns(Async::IO::Endpoint) }
- attr_reader :endpoint
-
- sig { params(endpoint: Async::IO::UNIXEndpoint).void }
def initialize(endpoint)
@endpoint = endpoint
end
- sig { params(metric_name: Symbol).void }
- def all_values(metric_name)
- raise NotImplementedError, "Should this even be implemented?"
- end
-
- sig { void }
def start
+ path = @endpoint.path
+
+ FileUtils.mkdir_p(File.dirname(path))
+ FileUtils.rm_f(path)
end
- sig { void }
def stop
+ FileUtils.rm_f(@endpoint.path)
end
- sig do
- params(
- internal_store: InternalStore,
- name: String,
- restart: T::Boolean
- ).void
- end
- def run(internal_store, name: self.class.name.to_s, restart: true)
+ def run(internal_store)
wrapper = Wrapper.new
Console.logger.info(
@@ -137,18 +107,17 @@ def run(internal_store, name: self.class.name.to_s, restart: true)
)
@endpoint.accept do |peer|
- store = internal_store.store(peer, {})
-
unpacker = wrapper.unpacker(peer)
unpacker.each do |message|
case message
- in [:store, data]
- store.merge!(data)
+ in [:store | "store", data]
+ internal_store[peer] = data
else
- Console
- .logger
- .warn(self) { "Unhandled mesage: #{message.inspect}" }
+ Console.logger.warn(
+ self,
+ "Unhandled message: #{message.inspect}"
+ )
end
end
ensure
diff --git a/lib/mayu/metrics/exporter.rb b/lib/mayu/metrics/exporter.rb
deleted file mode 100644
index dadea08a..00000000
--- a/lib/mayu/metrics/exporter.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# typed: strict
-
-module Mayu
- module Metrics
- class Exporter
- class Server
- extend T::Sig
-
- sig do
- params(
- endpoint: Async::HTTP::Endpoint,
- registry: Prometheus::Client::Registry
- ).returns(Async::HTTP::Server)
- end
- def self.setup(endpoint:, registry:)
- Console.logger.info(
- self,
- "Starting metrics exporter on #{endpoint.to_url}"
- )
-
- Async::HTTP::Server.for(
- endpoint,
- protocol: Async::HTTP::Protocol::HTTP11
- ) do |request|
- if request.path == "/favicon.ico"
- next(
- Protocol::HTTP::Response[
- 404,
- { "content-type": "text/plain" },
- ["Not found"]
- ]
- )
- end
-
- body = Prometheus::Client::Formats::Text.marshal(registry)
-
- Protocol::HTTP::Response[
- 200,
- { "content-type": "text/plain" },
- [body]
- ]
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/metrics/reporter.rb b/lib/mayu/metrics/reporter.rb
index c3e9f589..90165543 100644
--- a/lib/mayu/metrics/reporter.rb
+++ b/lib/mayu/metrics/reporter.rb
@@ -1,90 +1,85 @@
-# typed: strict
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "monitor"
+require "async"
module Mayu
module Metrics
module Reporter
- extend T::Sig
-
- sig do
- type_parameters(:M)
- .params(
- collector_endpoint: Async::IO::UNIXEndpoint,
- block:
- T
- .proc
- .params(arg0: Prometheus::Client::Registry)
- .returns(T.type_parameter(:M))
- )
- .returns(T.type_parameter(:M))
- end
- def self.run(collector_endpoint, &block)
+ Session =
+ Data.define(:metrics, :sync_task) do
+ def stop
+ sync_task&.stop
+ end
+ end
+
+ def self.run(
+ collector_endpoint,
+ task: Async::Task.current,
+ &setup_metrics
+ )
data_store = DataStore.new
+
Prometheus::Client.config.data_store = data_store
- metrics = yield(Prometheus::Client::Registry.new)
- Client.connect_and_sync(collector_endpoint:, data_store:, interval: 1)
- metrics
+
+ metrics = setup_metrics.call(Prometheus::Client::Registry.new)
+ sync_task =
+ Client.connect_and_sync(collector_endpoint:, data_store:, task:)
+
+ Session[metrics:, sync_task:]
end
class Client
- extend T::Sig
-
- sig do
- params(
- collector_endpoint: Async::IO::UNIXEndpoint,
- block: T.proc.params(arg0: Client).void
- ).void
- end
def self.connect(collector_endpoint, &block)
- Console.logger.info(
+ Console.logger.debug(
self,
"Connecting to #{File.expand_path(collector_endpoint.path)}"
)
- collector_endpoint.connect { |peer| yield new(peer) }
+ collector_endpoint.connect { |peer| block.call(new(peer)) }
end
- sig do
- params(
- collector_endpoint: Async::IO::UNIXEndpoint,
- data_store: DataStore,
- interval: Integer,
- task: Async::Task
- ).returns(Async::Task)
- end
def self.connect_and_sync(
collector_endpoint:,
data_store:,
interval: 1,
+ reconnect_delay: 0.25,
task: Async::Task.current
)
task.async do
- connect(collector_endpoint) do |client|
- loop do
- client.sync(data_store)
- sleep(interval)
+ loop do
+ begin
+ connect(collector_endpoint) do |client|
+ loop do
+ client.sync(data_store)
+ sleep(interval)
+ end
+ end
+ rescue Errno::ECONNREFUSED, Errno::EPIPE, Errno::ENOENT => error
+ break if task.stopped?
+ Console.logger.warn(self, "Metrics sync error: #{error.class}")
+ sleep(reconnect_delay)
+ rescue Interrupt, Async::Stop
+ break
end
end
- rescue Errno::EPIPE
- Console.logger.error(self, "Broken pipe")
- rescue Errno::ECONNREFUSED
- Console.logger.error(self, "Connection refused")
end
end
- sig { params(peer: Async::IO::Peer).void }
def initialize(peer)
wrapper = Wrapper.new
- @packer = T.let(wrapper.packer(peer), MessagePack::Packer)
+ @packer = wrapper.packer(peer)
end
- sig { params(data_store: DataStore, task: Async::Task).void }
- def sync(data_store, task: Async::Task.current)
- send(:store, data_store.store)
+ def sync(data_store)
+ send(:store, data_store.snapshot)
end
private
- sig { params(args: T.untyped).void }
def send(*args)
@packer.write(args)
@packer.flush
@@ -93,93 +88,79 @@ def send(*args)
class DataStore
class MetricStore
- extend T::Sig
-
- sig do
- params(
- store: ValueHash,
- metric_name: Symbol,
- metric_type: Symbol,
- metric_settings: T::Hash[Symbol, T.untyped]
- ).void
- end
- def initialize(store, metric_name:, metric_type:, metric_settings:)
- @store = store
+ def initialize(
+ value_store,
+ lock,
+ metric_name:,
+ metric_type:,
+ metric_settings:
+ )
+ @value_store = value_store
+ @lock = lock
@metric_name = metric_name
- @semaphore = T.let(Async::Semaphore.new, Async::Semaphore)
+ @metric_type = metric_type
+ @metric_settings = metric_settings
end
- sig do
- type_parameters(:T)
- .params(block: T.proc.returns(T.type_parameter(:T)))
- .returns(T.type_parameter(:T))
- end
- def synchronize(&block)
- @semaphore.async { yield }.wait
+ def synchronize
+ @lock.synchronize { yield }
end
- sig do
- params(
- val: T.any(Integer, Float),
- labels: T::Hash[Symbol, T.untyped]
- ).void
- end
- def set(val:, labels: {})
- @store.store(labels, val.to_f)
+ def set(labels:, val:)
+ synchronize { @value_store[label_key(labels)] = val.to_f }
end
- sig do
- params(
- by: T.any(Integer, Float),
- labels: T::Hash[Symbol, T.untyped]
- ).void
- end
- def increment(by: 1, labels: {})
- @store.store(labels, @store.fetch(labels, 0.0) + by.to_f)
+ def increment(labels:, by: 1)
+ synchronize { @value_store[label_key(labels)] += by.to_f }
end
- sig { params(labels: T::Hash[Symbol, T.untyped]).void }
def get(labels:)
- @store.fetch(labels)
+ synchronize { @value_store.fetch(label_key(labels), 0.0) }
end
- end
- extend T::Sig
+ def all_values
+ synchronize { @value_store.dup }
+ end
- sig { returns(MetricHash) }
- attr_reader :store
+ private
- sig { void }
- def initialize
- @store = T.let(init_metric_hash, MetricHash)
+ def label_key(labels)
+ labels.frozen? ? labels : labels.dup.freeze
+ end
end
- sig do
- params(
- metric_name: Symbol,
- metric_type: Symbol,
- metric_settings: T::Hash[Symbol, T.untyped]
- ).returns(MetricStore)
+ def initialize
+ @store =
+ Hash.new do |hash, metric_name|
+ hash[metric_name] = init_value_store
+ end
+ @lock = Monitor.new
end
+
def for_metric(metric_name, metric_type:, metric_settings: {})
MetricStore.new(
- T.must(@store[metric_name]),
+ @store[metric_name],
+ @lock,
metric_name:,
metric_type:,
metric_settings:
)
end
- private
-
- sig { returns(MetricHash) }
- def init_metric_hash
- Hash.new { |hash, metric_name| hash[metric_name] = init_value_hash }
+ def snapshot
+ @lock.synchronize do
+ @store.each_with_object(
+ {}
+ ) do |(metric_name, value_store), snapshot|
+ snapshot[metric_name] = value_store.dup
+ end
+ end
end
- sig { returns(ValueHash) }
- def init_value_hash
- Hash.new { |hash, labels| hash[labels] = 0 }
+ private
+
+ def init_value_store
+ Hash.new(0.0)
end
end
end
diff --git a/lib/mayu/metrics/server.rb b/lib/mayu/metrics/server.rb
new file mode 100644
index 00000000..48da7f35
--- /dev/null
+++ b/lib/mayu/metrics/server.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "prometheus/client/formats/text"
+
+module Mayu
+ module Metrics
+ class Server
+ def self.run(listen:)
+ end
+
+ def initialize(registry: Prometheus::Client.registry, listen:)
+ @registry = registry
+
+ @server =
+ Async::HTTP::Server.new(
+ self,
+ Async::HTTP::Endpoint.new(URI.parse(listen)),
+ protocol: Async::HTTP::Protocol::HTTP11
+ )
+ end
+
+ def run
+ puts "\e[32mStarting metrics server on \e[34m#{@server.endpoint.url}\e[0m"
+ @server.run
+ end
+
+ def call(request)
+ case request.path
+ in "/" | "/metrics"
+ render_metrics
+ else
+ render_404
+ end
+ end
+
+ private
+
+ def render_metrics
+ body = Prometheus::Client::Formats::Text.marshal(@registry)
+
+ Protocol::HTTP::Response[200, { "content-type": "text/plain" }, [body]]
+ end
+
+ def render_404
+ Protocol::HTTP::Response[
+ 404,
+ { "content-type": "text/plain" },
+ ["Not found"]
+ ]
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules.rb b/lib/mayu/modules.rb
new file mode 100644
index 00000000..724bd7bb
--- /dev/null
+++ b/lib/mayu/modules.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "modules/system"
diff --git a/lib/mayu/modules/README.md b/lib/mayu/modules/README.md
new file mode 100644
index 00000000..ace05989
--- /dev/null
+++ b/lib/mayu/modules/README.md
@@ -0,0 +1,108 @@
+# Mayu Modules System
+
+This directory contains the module system that powers `import(...)`, transforms
+files (Haml, CSS, JS, etc.), builds dependency graphs, supports hot‑reload, and
+provides source maps/backtrace rewriting.
+
+## High‑level flow
+
+1. `Modules::System` is created with an app root and rules.
+2. `import(path, source)` resolves a file, loads/transforms it, and creates a
+ `Modules::Mod` instance.
+3. Each `Mod` evaluates its generated Ruby into an `Exports` module.
+4. Dependencies are tracked for reload and asset management.
+5. Source maps are kept so errors and backtraces map to original sources.
+
+## Core types
+
+### `Modules::System` (`system.rb`)
+
+- **Thread‑local singleton:** `System.use { ... }` sets `System.current`.
+- **Resolution:** uses `Resolver` to map imports to real file paths.
+- **Loading:** applies `Rules::Rule` pipeline to build transformed Ruby.
+- **Imports:** `import(path, source)` returns `mod::Exports::Default`.
+- **Dependency graph:** `mods` store `dependencies` and `dependants`, used by
+ `TSort` for update order.
+- **Assets:** holds a `Mayu::Assets::Storage` queue; components can add assets.
+- **Reload:** `handle_watch_events` invalidates modules + dependants, reloads,
+ and signals `@on_reload`.
+- **Source maps:** `read_source` returns `[transformed, source_map]`, used for
+ error formatting.
+
+### `Modules::Mod` (`mod.rb`)
+
+- A module instance representing one file.
+- Maintains:
+ - `path`, `dependencies`, `dependants`, `assets`, `source_map`.
+ - `order` (topological order for reload).
+- `reload` re‑evaluates the module and regenerates `Exports`.
+- `Exports` module is nested; `Exports::Default` is the value returned from
+ `import`.
+- `import` forwards to the System, and records dependency edges.
+
+### `Modules::Resolver` (`resolver.rb`)
+
+- Resolves `import` paths relative to the source file.
+- Supports extension fallbacks and directory entry points.
+- Caches resolutions for repeat imports.
+
+### `Modules::Rules` (`rules.rb`)
+
+- A rule is `Rule[test, loader, **options]`.
+- Rules are matched by path and run in order.
+- Loaders are responsible for transforming a `LoadingFile`.
+
+### `Modules::Loaders` (`loaders/`)
+
+- `Loaders::LoadingFile` wraps path + source + digest.
+- Common loaders:
+ - `Haml` → Haml AST → Ruby, then wrap in a component class.
+ - `CSS`, `JavaScript`, `SVG`, `Image`, `StaticFile`, `JSON` etc.
+
+### `Modules::Import` (`import.rb`)
+
+- Provides `import(...)` objects that forward method/const access to the default
+ export (`Exports::Default`).
+
+### `Modules::Registry` (`registry.rb`)
+
+- Stores module instances in constants for stable `module_path` resolution.
+- Used by backtrace rewrite and source maps.
+
+### `Modules::BacktraceRewriter` (`backtrace_rewriter.rb`)
+
+- Rewrites backtraces using source maps so errors point to original Haml/Ruby.
+- Also formats exceptions with colored context and source snippets.
+
+## Load/transform pipeline (overview)
+
+1. `System#import` calls `get_or_load_mod`.
+2. `Resolver` chooses an absolute path.
+3. `System#read_source`:
+ - reads file
+ - applies `Rules::Rule` transformations
+ - builds a `SourceMap` from original → transformed
+4. `Mod#reload` evaluates the Ruby into `Exports` and clears assets.
+5. `Exports::Default` is returned from `import`.
+
+## HMR / reload
+
+- File watch events call `System#handle_watch_events`.
+- Dirty modules + dependants are reloaded in topological order.
+- `@on_reload` is signaled; sessions can listen and refresh routes.
+
+## Assets
+
+- Components can `add_asset` (e.g., CSS or generated bundles).
+- The system collects assets for streaming to clients.
+
+## Context + Haml notes
+
+Haml transforms `@@var` to `@__context[:var]` in generated Ruby. This is not
+module-system logic per se, but is part of the Haml transform pipeline.
+
+## Useful entry points
+
+- `Mayu::Modules::System.current`
+- `Mayu::Modules::System.import(path, source)`
+- `Mayu::Modules::System.current.format_exception(error)`
diff --git a/lib/mayu/modules/backtrace_rewriter.rb b/lib/mayu/modules/backtrace_rewriter.rb
new file mode 100644
index 00000000..584ef649
--- /dev/null
+++ b/lib/mayu/modules/backtrace_rewriter.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ class BacktraceRewriter
+ class BacktraceString < String
+ attr_reader :parsed_backtrace_entry
+
+ def initialize(entry)
+ super(entry.to_s)
+ @parsed_backtrace_entry = entry
+ end
+ end
+
+ ParsedBacktraceEntry =
+ Data.define(:file, :line, :description) do
+ def self.parse(line)
+ case line
+ in BacktraceString
+ line
+ in /\A(?.*):(?\d+):in [`'](?.*)'\z/
+ new($~[:file], $~[:line].to_i, $~[:description])
+ end
+ end
+
+ def to_s
+ "#{file}:#{line}:in `#{description.sub(/^Mayu::Modules::Registry::.+::/, "")}'"
+ end
+
+ def to_backtrace_string
+ BacktraceString.new(self)
+ end
+ end
+
+ def initialize(mods)
+ @source_map_cache =
+ Hash.new { |h, path| h[path] = mods[path]&.source_map }
+ end
+
+ def format_exception(e, source_path: nil)
+ reset = "\e[0;48;5;52m"
+ rewrite_exception(e)
+
+ [
+ "\e[1;31;47m ERROR \e[3;31;47m #{e.class.name}: #{e.message} #{reset}",
+ "\e[1;34mBacktrace:#{reset}",
+ e
+ .backtrace
+ .map do |line|
+ if line in BacktraceString
+ format(
+ "#{reset}\e[2mfrom #{reset}\e[1m%s:%d#{reset}\e[2m:in `#{reset}\e[1m%s#{reset}\e[2m`#{reset}",
+ line.parsed_backtrace_entry.to_h
+ )
+ else
+ "from #{line}"
+ end
+ end
+ .join("\n"),
+ "\e[1;34mSources:#{reset}",
+ format_sources(e.backtrace)
+ .map do |file, formatted_source|
+ "\e[1m#{file}\e[0m\n#{formatted_source}"
+ end
+ .join("\n")
+ ].join("\n") + "\e[0m"
+ end
+
+ def rewrite_exception(e)
+ e.set_backtrace(rewrite_backtrace(e.backtrace))
+ end
+
+ def rewrite_backtrace(backtrace)
+ backtrace.map do |line|
+ if entry = ParsedBacktraceEntry.parse(line)
+ rewrite_backtrace_entry(entry).to_backtrace_string
+ else
+ line
+ end
+ end
+ end
+
+ private
+
+ def rewrite_backtrace_entry(entry)
+ if original_line_no = find_original_line_no(entry.file, entry.line)
+ entry.with(line: original_line_no)
+ else
+ entry
+ end
+ end
+
+ def find_original_line_no(file, line_no)
+ @source_map_cache[file]&.find_original_line_no(line_no)
+ end
+
+ def format_sources(backtrace)
+ backtrace
+ .select { |line| line.is_a?(BacktraceString) }
+ .map(&:parsed_backtrace_entry)
+ .group_by(&:file)
+ .map do |file, entries|
+ if source_map = @source_map_cache[file]
+ [file, format_source(source_map.input, entries.map(&:line))]
+ end
+ end
+ .compact
+ .to_h
+ end
+
+ def format_source(source, interesting_lines)
+ ranges =
+ merge_overlapping_ranges(interesting_lines.map { (_1 - 2)..(_1 + 2) })
+ lines = source.each_line.to_a
+
+ ranges
+ .map do |range|
+ range
+ .map do |i|
+ next if i < 0
+ str = format("%3d: %s", i, lines[i - 1].chomp)
+ interesting_lines.include?(i) ? "\e[1;31m#{str}\e[0m" : str
+ end
+ .compact
+ .join("\n")
+ end
+ .join("\n\e[37;44m ... \e[0m\n")
+ end
+
+ def merge_overlapping_ranges(ranges)
+ ranges.each_with_object([]) do |range, merged|
+ if idx = merged.find_index { |r| r.overlap?(range) }
+ overlapping = merged[idx]
+ merged[idx] = [overlapping.begin, range.begin].min..[
+ overlapping.end,
+ range.end
+ ].max
+ else
+ merged << range
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/backtrace_rewriter.test.rb b/lib/mayu/modules/backtrace_rewriter.test.rb
new file mode 100644
index 00000000..b928aef3
--- /dev/null
+++ b/lib/mayu/modules/backtrace_rewriter.test.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+
+require_relative "backtrace_rewriter"
+require_relative "source_map"
+
+class Mayu::Modules::BacktraceRewriter::Test < Minitest::Test
+ BacktraceRewriter = Mayu::Modules::BacktraceRewriter
+ SourceMap = Mayu::Modules::SourceMap
+
+ FakeMod = Data.define(:source_map)
+
+ def test_rewrite_exception
+ source_map = SourceMap::SourceMap.parse(<<~INPUT, <<~OUTPUT)
+ :ruby
+ def hello
+ raise "asd"
+ end
+ %div
+ %p= hello
+ INPUT
+ class MyComponent
+ # #{SourceMap::Mark[2, "def hello"]}
+ def hello
+ # #{SourceMap::Mark[3, 'raise "asd"']}
+ raise "asd"
+ end
+ def render
+ H[:div,
+ H[:p
+ # #{SourceMap::Mark[6, "hello"]}
+ hello
+ ]
+ ]
+ end
+ end
+ OUTPUT
+
+ expected = <<~BACKTRACE.lines.map(&:strip)
+ /app/components/MyComponent.haml:3:in `render'
+ /app/components/MyComponent.haml:6:in `render'
+ /vendor/mayu/hello.rb:123:in `update'
+ BACKTRACE
+
+ backtrace_rewriter =
+ BacktraceRewriter.new(
+ { "/app/components/MyComponent.haml" => FakeMod.new(source_map) }
+ )
+
+ actual =
+ backtrace_rewriter.rewrite_backtrace(<<~BACKTRACE.lines.map(&:strip))
+ /app/components/MyComponent.haml:5:in `render'
+ /app/components/MyComponent.haml:11:in `render'
+ /vendor/mayu/hello.rb:123:in `update'
+ BACKTRACE
+
+ assert_equal(expected, actual)
+ end
+
+ def test_format_exception
+ source_map = SourceMap::SourceMap.parse(<<~INPUT, <<~OUTPUT)
+ :ruby
+ def hello
+ raise "asd"
+ end
+ %div
+ %p= hello
+ INPUT
+ class MyComponent
+ # #{SourceMap::Mark[2, "def hello"]}
+ def hello
+ # #{SourceMap::Mark[3, 'raise "asd"']}
+ raise "asd"
+ end
+ def render
+ H[:div,
+ H[:p
+ # #{SourceMap::Mark[6, "hello"]}
+ hello
+ ]
+ ]
+ end
+ end
+ OUTPUT
+
+ e = StandardError.new("Something went wrong")
+
+ e.set_backtrace(
+ [
+ "/app/components/MyComponent.haml:3:in `render'",
+ "/app/components/MyComponent.haml:6:in `render'",
+ "/vendor/mayu/hello.rb:123:in `update'"
+ ]
+ )
+
+ backtrace_rewriter =
+ BacktraceRewriter.new(
+ { "/app/components/MyComponent.haml" => FakeMod.new(source_map) }
+ )
+
+ puts backtrace_rewriter.format_exception(e)
+ end
+end
diff --git a/lib/mayu/modules/dependency.rb b/lib/mayu/modules/dependency.rb
new file mode 100644
index 00000000..9e23ed2a
--- /dev/null
+++ b/lib/mayu/modules/dependency.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ Dependency =
+ Data.define(:source_path, :import_hash, :resolved_path) do
+ def to_s
+ "#{source_path} --#{import_hash}--> #{resolved_path}"
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/import.rb b/lib/mayu/modules/import.rb
new file mode 100644
index 00000000..8fda2dce
--- /dev/null
+++ b/lib/mayu/modules/import.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ class Import < BasicObject
+ def initialize(mod)
+ @mod = mod
+ end
+
+ def method_missing(...)
+ __default_export.send(...)
+ end
+
+ def const_missing(const)
+ __default_export.const_get(const)
+ end
+
+ private
+
+ def __default_export
+ @mod::Exports::Default
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/import_rewriter.rb b/lib/mayu/modules/import_rewriter.rb
new file mode 100644
index 00000000..2df590b2
--- /dev/null
+++ b/lib/mayu/modules/import_rewriter.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "digest/sha2"
+require "syntax_tree"
+
+module Mayu
+ module Modules
+ class ImportRewriter
+ PREFIX = "hash-"
+ Result = Data.define(:source, :imports)
+ Replacement = Data.define(:start_char, :end_char, :replacement, :resolved)
+
+ class Visitor < SyntaxTree::Visitor
+ attr_reader :replacements
+
+ def initialize(rewriter)
+ @rewriter = rewriter
+ @replacements = []
+ end
+
+ def visit_call(node)
+ if replacement = @rewriter.rewrite_node(node)
+ @replacements << replacement
+ end
+
+ super
+ end
+
+ def visit_command(node)
+ if replacement = @rewriter.rewrite_node(node)
+ @replacements << replacement
+ end
+
+ super
+ end
+ end
+
+ def self.hash_for(path) = PREFIX + Digest::SHA256.hexdigest(path)
+
+ def initialize(resolver:, source_path:)
+ @resolver = resolver
+ @source_path = source_path
+ end
+
+ def call(source)
+ ast = SyntaxTree.parse(source)
+ visitor = Visitor.new(self)
+ ast.accept(visitor)
+
+ imports =
+ visitor
+ .replacements
+ .each_with_object({}) do |replacement, result|
+ hash = self.class.hash_for(replacement.resolved)
+
+ if result.key?(hash) && result[hash] != replacement.resolved
+ raise "Import hash collision for #{replacement.resolved.inspect}"
+ end
+
+ result[hash] = replacement.resolved
+ end
+
+ rewritten_source = apply_replacements(source, visitor.replacements)
+
+ Result[rewritten_source, imports]
+ end
+
+ def rewrite_node(node)
+ return unless import_call?(node)
+
+ import_path = import_path_from_node(node)
+ return unless import_path
+
+ resolved_path = @resolver.resolve(import_path, source_dir)
+ import_hash = self.class.hash_for(resolved_path)
+
+ Replacement[
+ node.start_char,
+ node.end_char,
+ "import(#{import_hash.inspect})",
+ resolved_path
+ ]
+ end
+
+ private
+
+ def source_dir
+ File.dirname(@source_path)
+ end
+
+ def apply_replacements(source, replacements)
+ return source if replacements.empty?
+
+ output = source.dup
+ replacements
+ .sort_by(&:start_char)
+ .reverse_each do |replacement|
+ output[
+ replacement.start_char...replacement.end_char
+ ] = replacement.replacement
+ end
+ output
+ end
+
+ def import_call?(node)
+ case node
+ in SyntaxTree::CallNode[
+ receiver: nil,
+ operator: nil,
+ message: SyntaxTree::Ident[value: "import"]
+ ]
+ true
+ in SyntaxTree::Command[message: SyntaxTree::Ident[value: "import"]]
+ true
+ else
+ false
+ end
+ end
+
+ def import_path_from_node(node)
+ case node
+ in SyntaxTree::CallNode
+ extract_static_string(import_arg_from_call(node))
+ in SyntaxTree::Command
+ extract_static_string(import_arg_from_command(node))
+ end
+ end
+
+ def import_arg_from_call(node)
+ args =
+ case node.arguments
+ in SyntaxTree::ArgParen[arguments:]
+ arguments
+ in SyntaxTree::Args
+ node.arguments
+ else
+ return
+ end
+
+ return unless args.parts.length == 1
+
+ args.parts.first
+ end
+
+ def import_arg_from_command(node)
+ args = node.arguments
+ return unless args.is_a?(SyntaxTree::Args)
+ return unless args.parts.length == 1
+
+ args.parts.first
+ end
+
+ def extract_static_string(node)
+ case node
+ in SyntaxTree::StringLiteral[
+ parts: [SyntaxTree::TStringContent[value:]]
+ ]
+ value
+ else
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/import_rewriter.test.rb b/lib/mayu/modules/import_rewriter.test.rb
new file mode 100644
index 00000000..5207e271
--- /dev/null
+++ b/lib/mayu/modules/import_rewriter.test.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+require "fileutils"
+require "tmpdir"
+
+require_relative "import_rewriter"
+require_relative "resolver"
+
+class Mayu::Modules::ImportRewriter::Test < Minitest::Test
+ ImportRewriter = Mayu::Modules::ImportRewriter
+ Resolver = Mayu::Modules::Resolver
+
+ def test_rewrites_static_imports_and_returns_mapping
+ Dir.mktmpdir("mayu-import-rewriter-test") do |root|
+ FileUtils.mkdir_p(File.join(root, "app"))
+ File.write(File.join(root, "app/dep.rb"), "Default = :dep\n")
+ File.write(File.join(root, "app/dep2.rb"), "Default = :dep2\n")
+
+ resolver = Resolver.new(root, extensions: ["", ".rb"])
+ result =
+ ImportRewriter.new(resolver:, source_path: "/app/page.rb").call(<<~RUBY)
+ A = import "./dep"
+ B = import("./dep2")
+ C = import?("./maybe")
+ D = something.import("./dep")
+ E = import(path)
+ RUBY
+
+ dep_hash = ImportRewriter.hash_for("/app/dep.rb")
+ dep2_hash = ImportRewriter.hash_for("/app/dep2.rb")
+
+ assert_includes(result.source, %(A = import("#{dep_hash}")))
+ assert_includes(result.source, %(B = import("#{dep2_hash}")))
+ assert_includes(result.source, %(C = import?("./maybe")))
+ assert_includes(result.source, %(D = something.import("./dep")))
+ assert_includes(result.source, "E = import(path)")
+ assert_equal(
+ { dep_hash => "/app/dep.rb", dep2_hash => "/app/dep2.rb" },
+ result.imports
+ )
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders.rb b/lib/mayu/modules/loaders.rb
new file mode 100644
index 00000000..9cea3e45
--- /dev/null
+++ b/lib/mayu/modules/loaders.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ LoadingFile =
+ Data.define(:root, :path, :source, :digest) do
+ def self.[](root, path, source = nil, digest = nil)
+ new(root, path, source, digest)
+ end
+
+ def with_digest
+ if digest
+ self
+ else
+ with(digest: Digest::SHA256.file(absolute_path).digest)
+ end
+ end
+
+ def absolute_path
+ File.join(root, path)
+ end
+
+ def transform(&)
+ with(source: yield(self))
+ end
+
+ def maybe_load_source
+ source ? self : load_source
+ end
+
+ def load_source
+ with(source: File.read(absolute_path))
+ end
+ end
+
+ autoload :CSS, File.join(__dir__, "loaders", "css")
+ autoload :Haml, File.join(__dir__, "loaders", "haml")
+ autoload :Image, File.join(__dir__, "loaders", "image")
+ autoload :JavaScript, File.join(__dir__, "loaders", "java_script")
+ autoload :Ruby, File.join(__dir__, "loaders", "ruby")
+ autoload :SVG, File.join(__dir__, "loaders", "svg")
+ autoload :StaticFile, File.join(__dir__, "loaders", "static_file")
+ autoload :JSON, File.join(__dir__, "loaders", "json")
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/__test__/haml/HelloWorld.haml b/lib/mayu/modules/loaders/__test__/haml/HelloWorld.haml
new file mode 100644
index 00000000..5c59dd3d
--- /dev/null
+++ b/lib/mayu/modules/loaders/__test__/haml/HelloWorld.haml
@@ -0,0 +1,22 @@
+:ruby
+ def initialize
+ @count = $initial_count || 0
+ end
+
+ def handle_increment
+ @count += 1
+ end
+
+%section
+ %h2 Counter
+ %button(onclick=handle_increment)
+ Increment
+ %button(onclick=handle_decrement)
+ Decrement
+
+:css
+ button {
+ background: #ccc;
+ border: 1px solid #000;
+ border-radius: 3px;
+ }
diff --git a/lib/mayu/modules/loaders/__test__/haml/HelloWorld.rb b/lib/mayu/modules/loaders/__test__/haml/HelloWorld.rb
new file mode 100644
index 00000000..4bdb080c
--- /dev/null
+++ b/lib/mayu/modules/loaders/__test__/haml/HelloWorld.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+class HelloWorld < Mayu::Component::Base
+ def self.module_path
+ __FILE__
+ end
+ Self = self
+ INLINE_STYLES = [
+ begin
+ Mayu::StyleSheet[
+ source_filename: "HelloWorld.haml.css",
+ content_hash: "mJspXVoY5P2oKgeSOglNdPcM8Woi0kqUxHo3h1Og7kg",
+ classes: {
+ __button: "HelloWorld.haml_button?N2Q7U-wl"
+ },
+ content: <
+# License: AGPL-3.0
+
+require_relative "transformers/css"
+
+module Mayu
+ module Modules
+ module Loaders
+ CSS =
+ Data.define do
+ def call(loading_file)
+ loading_file.maybe_load_source.transform do
+ Transformers::CSS.transform(_1.path, _1.source)
+ end
+ # .tap { puts _1.source }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/haml.rb b/lib/mayu/modules/loaders/haml.rb
new file mode 100644
index 00000000..db6cdd87
--- /dev/null
+++ b/lib/mayu/modules/loaders/haml.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "transformers/ruby"
+require_relative "transformers/haml"
+
+module Mayu
+ module Modules
+ module Loaders
+ Haml =
+ Data.define(:component_base_class, :using, :factory) do
+ def self.[](component_base_class:, using: [], factory: "H")
+ new(component_base_class:, using:, factory:)
+ end
+
+ def call(loading_file)
+ loading_file
+ .maybe_load_source
+ .transform do
+ Transformers::Haml.transform(
+ _1.source,
+ _1.path,
+ factory:
+ ).output
+ end
+ .transform do
+ Transformers::Ruby.transform(
+ _1.source,
+ _1.path,
+ base_class: component_base_class,
+ using:,
+ enable_assets: true
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/haml.test.rb b/lib/mayu/modules/loaders/haml.test.rb
new file mode 100755
index 00000000..559e2d6c
--- /dev/null
+++ b/lib/mayu/modules/loaders/haml.test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+#!/usr/bin/env ruby -rbundler/setup
+
+require "minitest/autorun"
+
+require_relative "../loaders"
+require_relative "haml"
+
+class Mayu::Modules::Loaders::Haml::Test < Minitest::Test
+ Dir[File.join(__dir__, "__test__", "haml", "*.haml")].each do |haml|
+ basename = File.basename(haml, ".*")
+ ruby = File.join(File.dirname(haml), basename + ".rb")
+
+ root = File.join(__dir__, "__test__", "haml")
+
+ define_method :"test_#{basename}" do
+ output =
+ Mayu::Modules::Loaders::Haml[
+ component_base_class: "Mayu::Component::Base",
+ factory: "Mayu::Descriptors::H"
+ ].call(
+ Mayu::Modules::Loaders::LoadingFile[root, File.basename(haml)]
+ ).source
+
+ if File.exist?(ruby)
+ assert_equal(
+ File.read(ruby),
+ output,
+ "#{ruby} does not match transformed output"
+ )
+ else
+ puts "\e[33mWriting #{ruby}\e[0m"
+ File.write(ruby, output)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/image.rb b/lib/mayu/modules/loaders/image.rb
new file mode 100644
index 00000000..60b06d45
--- /dev/null
+++ b/lib/mayu/modules/loaders/image.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "image_size"
+require "syntax_tree"
+
+require_relative "../../image"
+require_relative "transformers/frozen_string_literal_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ Image =
+ Data.define(:sizes) do
+ include SyntaxTree::DSL
+
+ def call(loading_file)
+ loading_file.with_digest.transform do
+ image_size = ImageSize.path(_1.absolute_path)
+
+ SyntaxTree::Formatter.format(
+ "",
+ build_code(
+ _1.absolute_path,
+ image_size,
+ Base64.urlsafe_encode64(_1.digest)[0..10]
+ )
+ )
+ # .tap { |source| puts source }
+ end
+ end
+
+ private
+
+ def build_code(absolute_path, image_size, digest)
+ Statements(
+ [
+ Assign(
+ VarField(Const("Default")),
+ ARef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("Image")),
+ Args(
+ [
+ BareAssocHash(
+ [
+ Assoc(
+ Label("versions:"),
+ build_versions(absolute_path, image_size, digest)
+ ),
+ Assoc(
+ Label("width:"),
+ Int((image_size.width || 1).to_s)
+ ),
+ Assoc(
+ Label("height:"),
+ Int((image_size.height || 1).to_s)
+ ),
+ Assoc(
+ Label("blur_src:"),
+ StringLiteral(
+ [
+ TStringContent(
+ build_blur_image_src(absolute_path)
+ )
+ ],
+ '"'
+ )
+ )
+ ]
+ )
+ ]
+ )
+ )
+ ),
+ build_assets(absolute_path)
+ ]
+ ).accept(Transformers::FrozenStringLiteralVisitor.new)
+ end
+
+ def build_blur_image_src(absolute_path)
+ Ractor
+ .new(absolute_path) do |absolute_path|
+ format = "webp"
+
+ Magick::Image.read(absolute_path) => [image]
+
+ image.resize_to_fit!(16)
+
+ blob =
+ image.to_blob do |options|
+ options.quality = 80
+ options.format = format
+ end
+
+ image.destroy!
+
+ "data:image/#{format};base64,#{Base64.strict_encode64(blob)}"
+ end
+ .join
+ .value
+ end
+
+ def build_versions(absolute_path, image_size, hash)
+ widths = sizes.select { _1 < image_size.width }.sort.reverse
+ basename = File.basename(absolute_path, ".*")
+ format = "webp"
+
+ ArrayLiteral(
+ LBracket("["),
+ Args(
+ [image_size.width, *widths].uniq.map do |width|
+ filename =
+ format("%s-%dw.%s?%s", basename, width, format, hash)
+ ARef(
+ VarRef(Const("ImageVersion")),
+ Args(
+ [
+ StringLiteral([TStringContent(filename)], '"'),
+ Int(width.to_s)
+ ]
+ )
+ )
+ end
+ )
+ )
+ end
+
+ def build_assets(absolute_path)
+ MethodAddBlock(
+ CallNode(
+ CallNode(
+ VarRef(Const("Default")),
+ Period("."),
+ Ident("versions"),
+ nil
+ ),
+ Period("."),
+ Ident("each"),
+ nil
+ ),
+ BlockNode(
+ BlockVar(Params([], [], [], [], [], [], nil), nil),
+ nil,
+ Statements(
+ [
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("Assets")
+ ),
+ Const("Generators")
+ ),
+ Const("Image")
+ ),
+ Args(
+ [
+ CallNode(
+ VarRef(Ident("_1")),
+ Period("."),
+ Ident("filename"),
+ nil
+ ),
+ StringLiteral(
+ [TStringContent(absolute_path)],
+ '"'
+ ),
+ CallNode(
+ VarRef(Ident("_1")),
+ Period("."),
+ Ident("width"),
+ nil
+ )
+ ]
+ )
+ )
+ ]
+ )
+ )
+ )
+ ]
+ )
+ )
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/java_script.rb b/lib/mayu/modules/loaders/java_script.rb
new file mode 100644
index 00000000..25763e00
--- /dev/null
+++ b/lib/mayu/modules/loaders/java_script.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "transformers/css"
+require_relative "../../custom_element"
+
+module Mayu
+ module Modules
+ module Loaders
+ JavaScript =
+ Data.define do
+ include SyntaxTree::DSL
+
+ def call(loading_file)
+ # TODO: Use swc or something to support TypeScript and minification.
+
+ loading_file.maybe_load_source.with_digest.transform do
+ SyntaxTree::Formatter.format("", build_code(_1)).+("\n")
+ # .tap { |x| puts "\e[93m#{x}\e[0m" }
+ end
+ end
+
+ private
+
+ def build_code(loading_file)
+ basename =
+ loading_file
+ .path
+ .then { File.join(File.dirname(_1), File.basename(_1, ".*")) }
+ .sub(%r{\A\./}, "")
+ .sub(%r{\A/}, "")
+ .gsub(%r{/}, "-")
+ .gsub(/([[:lower:]])([[:upper:]])/) { $~.captures.join("--") }
+
+ custom_element_name =
+ format(
+ "%s--%s",
+ basename,
+ Base64.urlsafe_encode64(loading_file.digest, padding: false)[
+ 0..10
+ ]
+ ).downcase.sub(/_+$/, "").tr("_", "-")
+
+ filename = "#{custom_element_name}.js"
+
+ Statements(
+ [
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("Assets")
+ ),
+ Const("Generators")
+ ),
+ Const("Text")
+ ),
+ Args(
+ [
+ StringLiteral([TStringContent(filename)], '"'),
+ CallNode(
+ VarRef(Const("Base64")),
+ Period("."),
+ Ident("decode64"),
+ ArgParen(
+ StringLiteral(
+ [
+ TStringContent(
+ Base64.encode64(
+ loading_file.source
+ ).strip
+ )
+ ],
+ '"'
+ )
+ )
+ )
+ ]
+ )
+ )
+ ]
+ )
+ )
+ ),
+ Assign(
+ VarField(Const("Default")),
+ ARef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("CustomElement")),
+ Args(
+ [
+ Args(
+ [
+ DynaSymbol(
+ [TStringContent(custom_element_name)],
+ '"'
+ )
+ ]
+ ),
+ StringLiteral([TStringContent(filename)], '"')
+ ]
+ )
+ )
+ )
+ ]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/java_script.test.rb b/lib/mayu/modules/loaders/java_script.test.rb
new file mode 100755
index 00000000..8064ede7
--- /dev/null
+++ b/lib/mayu/modules/loaders/java_script.test.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+#!/usr/bin/env ruby -rbundler/setup
+
+require "minitest/autorun"
+
+require_relative "../loaders"
+require_relative "java_script"
+
+class Mayu::Modules::Loaders::Haml::Test < Minitest::Test
+ Dir[File.join(__dir__, "__test__", "js", "*.js")].each do |js|
+ basename = File.basename(js, ".*")
+ ruby = File.join(File.dirname(js), basename + ".rb")
+ root = File.join(__dir__, "__test__", "js")
+
+ define_method :"test_#{basename}" do
+ output =
+ Mayu::Modules::Loaders::JavaScript[].call(
+ Mayu::Modules::Loaders::LoadingFile[root, File.basename(js)]
+ ).source
+
+ if File.exist?(ruby)
+ assert_equal(
+ File.read(ruby),
+ output,
+ "#{ruby} doesn't match transformed output"
+ )
+ else
+ puts "\e[33mWriting #{ruby}\e[0m"
+ File.write(ruby, output)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/json.rb b/lib/mayu/modules/loaders/json.rb
new file mode 100644
index 00000000..7b5dc82c
--- /dev/null
+++ b/lib/mayu/modules/loaders/json.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "json"
+require_relative "transformers/frozen_string_literal_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ JSON =
+ Data.define do
+ include SyntaxTree::DSL
+
+ def call(loading_file)
+ loading_file.maybe_load_source.transform do |loading_file|
+ SyntaxTree::Formatter.format("", build_code(loading_file.source))
+ # .tap { |source| puts source }
+ end
+ end
+
+ private
+
+ def build_code(source)
+ ::JSON
+ .parse(source)
+ .inspect
+ .then { SyntaxTree.parse(_1) }
+ .statements
+ .body => [content_ast]
+
+ Statements(
+ [Assign(VarField(Const("Default")), deep_freeze(content_ast))]
+ ).accept(Transformers::FrozenStringLiteralVisitor.new)
+ end
+
+ def deep_freeze(ast)
+ CallNode(
+ ConstPathRef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("Utils")),
+ Const("DeepFreeze")
+ ),
+ Period("."),
+ Ident("deep_freeze"),
+ ArgParen(Args([ast]))
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/ruby.rb b/lib/mayu/modules/loaders/ruby.rb
new file mode 100644
index 00000000..a283314e
--- /dev/null
+++ b/lib/mayu/modules/loaders/ruby.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "transformers/ruby"
+
+module Mayu
+ module Modules
+ module Loaders
+ Ruby =
+ Data.define do
+ def call(loading_file)
+ loading_file.maybe_load_source.transform do
+ Transformers::Ruby.transform(
+ _1.source,
+ _1.path,
+ enable_assets: false
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/static_file.rb b/lib/mayu/modules/loaders/static_file.rb
new file mode 100644
index 00000000..fb0765af
--- /dev/null
+++ b/lib/mayu/modules/loaders/static_file.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ StaticFile =
+ Data.define do
+ include SyntaxTree::DSL
+
+ def call(loading_file)
+ loading_file.with_digest.transform do
+ SyntaxTree::Formatter.format("", build_code(_1))
+ end
+ end
+
+ private
+
+ def build_code(loading_file)
+ hash = Base64.urlsafe_encode64(loading_file.digest)[0..10]
+ filename =
+ format("%s?%s", loading_file.path.delete_prefix("/"), hash)
+ asset_path = File.join("/.mayu/assets", filename)
+
+ Statements(
+ [
+ Assign(
+ VarField(Const("Default")),
+ StringLiteral([TStringContent(asset_path)], '"')
+ ),
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("Assets")
+ ),
+ Const("Generators")
+ ),
+ Const("WriteFile")
+ ),
+ Args(
+ [
+ StringLiteral([TStringContent(filename)], '"'),
+ StringLiteral(
+ [TStringContent(loading_file.absolute_path)],
+ '"'
+ )
+ ]
+ )
+ )
+ ]
+ )
+ )
+ )
+ ]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/svg.rb b/lib/mayu/modules/loaders/svg.rb
new file mode 100644
index 00000000..40f0200a
--- /dev/null
+++ b/lib/mayu/modules/loaders/svg.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "../../svg"
+require_relative "transformers/frozen_string_literal_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ SVG =
+ Data.define do
+ include SyntaxTree::DSL
+
+ def call(loading_file)
+ loading_file.maybe_load_source.with_digest.transform do
+ SyntaxTree::Formatter.format("", build_code(_1))
+ end
+ end
+
+ private
+
+ def build_code(file)
+ filename =
+ format(
+ "%s.%s.svg",
+ File.basename(file.path, ".*"),
+ Base64.urlsafe_encode64(file.digest)[0..10]
+ )
+
+ require "rmagick"
+ image = Magick::Image.ping(file.absolute_path).first
+ width = image.columns
+ height = image.rows
+
+ Statements(
+ [
+ Assign(
+ VarField(Const("Default")),
+ ARef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("SVG")),
+ Args(
+ [
+ BareAssocHash(
+ [
+ Assoc(
+ Label("filename:"),
+ StringLiteral([TStringContent(filename)], '"')
+ ),
+ Assoc(Label("width:"), Int(width.to_s)),
+ Assoc(Label("height:"), Int(height.to_s))
+ ]
+ )
+ ]
+ )
+ )
+ ),
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("Assets")
+ ),
+ Const("Generators")
+ ),
+ Const("Text")
+ ),
+ Args(
+ [
+ StringLiteral([TStringContent(filename)], '"'),
+ StringLiteral([TStringContent(file.source)], '"')
+ ]
+ )
+ )
+ ]
+ )
+ )
+ )
+ ]
+ ).accept(Transformers::FrozenStringLiteralVisitor.new)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/__test__/css/basic.in.css b/lib/mayu/modules/loaders/transformers/__test__/css/basic.in.css
new file mode 100644
index 00000000..cfe84998
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/__test__/css/basic.in.css
@@ -0,0 +1,15 @@
+ul {
+ background: rgb(0 128 255 / 50%);
+}
+li {
+ border: 1px solid #f0f;
+}
+.foo {
+ border: 1px solid #f0f;
+}
+.bar {
+ background: url("./bar.png");
+}
+.foo-bar {
+ font-weight: bold;
+}
diff --git a/lib/mayu/modules/loaders/transformers/__test__/css/basic.out.rb b/lib/mayu/modules/loaders/transformers/__test__/css/basic.out.rb
new file mode 100644
index 00000000..dca655d9
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/__test__/css/basic.out.rb
@@ -0,0 +1,15 @@
+Dep_ZuJKJG = import "./bar.png"
+Mayu::StyleSheet[
+ source_filename: "app/components/Test.css",
+ content_hash: "WjdqSJUhC23gOH-jtXdrLoIxIiKTcg3fDEOuw2Eo3XU",
+ classes: {
+ __li: "/app/components/Test_li?R-Dxlzoq",
+ __ul: "/app/components/Test_ul?R-Dxlzoq",
+ bar: "/app/components/Test.bar?R-Dxlzoq",
+ foo: "/app/components/Test.foo?R-Dxlzoq",
+ "foo-bar": "/app/components/Test.foo-bar?R-Dxlzoq"
+ },
+ content: <Bar\nBaz"
diff --git a/lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.rb b/lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.rb
new file mode 100644
index 00000000..b407eee9
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+class Whitespace_preservation < Mayu::Component::Base
+ def self.module_path
+ __FILE__
+ end
+ Self = self
+ INLINE_STYLES = []
+ Styles =
+ Mayu::Component::StyleSheets.new(
+ self,
+ [*INLINE_STYLES, import?("./whitespace_preservation.css")].compact
+ )
+ public def render
+ # SourceMapMark:1:IkZvb1xuPHByZT5CYXJcbkJhejwvcHJlPiI=
+ "Foo\n
Bar\nBaz
"
+ end
+end
+Default = Whitespace_preservation
+Default::INLINE_STYLES.each do
+ add_asset(Mayu::Assets::Generators::Text[_1.filename, _1.content])
+end
diff --git a/lib/mayu/modules/loaders/transformers/css.rb b/lib/mayu/modules/loaders/transformers/css.rb
new file mode 100644
index 00000000..8b4746af
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/css.rb
@@ -0,0 +1,275 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# Released under AGPL-3.0
+
+require "base64"
+require "digest/sha2"
+require "mayu/css"
+require "syntax_tree"
+require_relative "../../../style_sheet"
+require_relative "frozen_string_literal_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ class CSS
+ include SyntaxTree::DSL
+
+ def self.transform(source_path, source)
+ Mayu::CSS
+ .transform(source_path, source)
+ .then do
+ new(source_path, _1).build_inline_ast(assign_default: true)
+ end
+ .accept(FrozenStringLiteralVisitor.new)
+ .then { SyntaxTree::Formatter.format("", _1).rstrip + "\n" }
+ end
+
+ def self.transform_inline(source_path, source, **options)
+ Mayu::CSS
+ .transform(source_path, source)
+ .then { new(source_path, _1, **options).build_inline_ast }
+ end
+
+ def initialize(
+ source_path,
+ parse_result,
+ dependency_const_prefix: "Dep_",
+ code_const_name: "CODE",
+ content_hash_const_name: "CONTENT_HASH"
+ )
+ @source_path = source_path
+ @parse_result = parse_result
+ @dependency_const_prefix = dependency_const_prefix
+ @code_const_name = code_const_name
+ @content_hash_const_name = content_hash_const_name
+ end
+
+ def build_inline_ast(assign_default: false)
+ new_style_sheet =
+ ARef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("StyleSheet")),
+ Args(
+ [
+ BareAssocHash(
+ [
+ Assoc(
+ Label("source_filename:"),
+ StringLiteral(
+ [TStringContent(@source_path.delete_prefix("/"))],
+ '"'
+ )
+ ),
+ Assoc(
+ Label("content_hash:"),
+ build_content_hash_string
+ ),
+ Assoc(Label("classes:"), build_classes_hash),
+ Assoc(Label("content:"), build_code_heredoc)
+ ]
+ )
+ ]
+ )
+ )
+
+ Statements(
+ [
+ *build_imports,
+ if assign_default
+ [
+ Assign(VarField(Const("Default")), VarRef(new_style_sheet)),
+ build_assets_code
+ ]
+ else
+ new_style_sheet
+ end
+ ].flatten.compact
+ )
+ end
+
+ private
+
+ def build_imports
+ @parse_result.dependencies.map do |dep|
+ dep => { placeholder:, url: }
+
+ Assign(
+ VarField(placeholder_const(placeholder)),
+ build_import(url)
+ )
+ end
+ end
+
+ def placeholder_const(placeholder)
+ Const(@dependency_const_prefix + placeholder.tr("-", "_"))
+ end
+
+ def build_import(url)
+ Command(
+ Ident("import"),
+ Args([StringLiteral([TStringContent(url)], '"')]),
+ nil
+ )
+ end
+
+ def build_content_hash_string
+ StringLiteral(
+ [
+ TStringContent(
+ @parse_result
+ .code
+ .then { Digest::SHA256.digest(_1) }
+ .then { Base64.urlsafe_encode64(_1, padding: false) }
+ )
+ ],
+ '"'
+ )
+ end
+
+ def build_classes_hash
+ HashLiteral(LBrace("{"), build_classes_assocs)
+ end
+
+ def build_classes_assocs
+ {
+ **@parse_result.classes,
+ **@parse_result.elements.transform_keys { "__#{_1}" }
+ }.transform_keys(&:to_s)
+ .sort_by(&:first)
+ .map do |key, value|
+ Assoc(
+ if key.match(/\A[A-Za-z0-9_]\z/)
+ Label("#{key}:")
+ else
+ DynaSymbol([TStringContent(key)], '"')
+ end,
+ StringLiteral(
+ [
+ TStringContent(
+ join_class(
+ value.to_s,
+ @parse_result.exports,
+ @parse_result.classes
+ )
+ )
+ ],
+ '"'
+ )
+ )
+ end
+ end
+
+ def join_class(klass, exports, classes)
+ if composes = exports[klass]&.composes
+ [
+ klass,
+ *composes.map do |compose|
+ case compose
+ in Mayu::CSS::ComposeLocal
+ classes[compose.name.to_sym]
+ end
+ end
+ ].join(" ")
+ else
+ klass
+ end
+ end
+
+ def build_code_heredoc
+ Heredoc(
+ HeredocBeg("< { placeholder: }
+ remains.split(placeholder, 2) => [part, remains]
+
+ parts.push(
+ TStringContent(part),
+ StringEmbExpr(
+ Statements(
+ [
+ CallNode(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("StyleSheet")
+ ),
+ Period("."),
+ Ident("encode_url"),
+ ArgParen(
+ Args(
+ [
+ CallNode(
+ VarRef(placeholder_const(placeholder)),
+ Period("."),
+ Ident("to_s"),
+ nil
+ )
+ ]
+ )
+ )
+ )
+ ]
+ )
+ )
+ )
+ end
+
+ parts.push(TStringContent(remains)) unless remains.empty?
+
+ parts
+ end
+
+ def build_assets_code
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("Assets")),
+ Const("Generators")
+ ),
+ Const("Text")
+ ),
+ Args(
+ [
+ CallNode(
+ VarField(Const("Default")),
+ Period("."),
+ Ident("filename"),
+ nil
+ ),
+ CallNode(
+ VarField(Const("Default")),
+ Period("."),
+ Ident("content"),
+ nil
+ )
+ ]
+ )
+ )
+ ]
+ )
+ )
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/css.test.rb b/lib/mayu/modules/loaders/transformers/css.test.rb
new file mode 100755
index 00000000..dd0cafa5
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/css.test.rb
@@ -0,0 +1,40 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "pry"
+require "minitest/autorun"
+require_relative "css"
+
+class Mayu::Modules::Loaders::Transformers::CSS::Test < Minitest::Test
+ Dir[File.join(__dir__, "__test__", "css", "*.in.css")].each do |haml|
+ basename = File.basename(haml).split(".", 2).first
+ ruby = File.join(File.dirname(haml), basename + ".out.rb")
+
+ define_method :"test_#{basename}" do
+ output =
+ File
+ .read(haml)
+ .then do
+ Mayu::Modules::Loaders::Transformers::CSS.transform_inline(
+ "/app/components/Test.css",
+ _1
+ )
+ end
+ .then { SyntaxTree::Formatter.format("", _1) }
+
+ if File.exist?(ruby)
+ assert_equal(
+ File.read(ruby).strip,
+ output.strip,
+ "#{ruby} doesn't match transformed output"
+ )
+ else
+ puts "\e[33mWriting #{ruby}\e[0m"
+ File.write(ruby, output)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/frozen_string_literal_visitor.rb b/lib/mayu/modules/loaders/transformers/frozen_string_literal_visitor.rb
new file mode 100644
index 00000000..f8c439cf
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/frozen_string_literal_visitor.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ class FrozenStringLiteralVisitor < SyntaxTree::Visitor
+ def visit_program(node)
+ node.copy(statements: visit(node.statements))
+ end
+
+ def visit_statements(node)
+ node.copy(
+ body: [
+ SyntaxTree::Comment.new(
+ value: "# frozen_string_literal: true",
+ inline: false,
+ location: node.location
+ ),
+ *node.body
+ ]
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml.rb b/lib/mayu/modules/loaders/transformers/haml.rb
new file mode 100644
index 00000000..e1d5182a
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml.rb
@@ -0,0 +1,253 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "pry"
+require "ripper"
+require "securerandom"
+require "syntax_suggest"
+require "syntax_suggest/api"
+require "syntax_suggest/code_line"
+require "syntax_suggest/explain_syntax"
+require "syntax_suggest/lex_all"
+require "syntax_suggest/ripper_errors"
+require "syntax_tree"
+require "syntax_tree/haml"
+require "rbnacl"
+require_relative "mutation_visitor"
+require_relative "haml/ruby_builder"
+require_relative "haml/source_map_mark_ruby_visitor"
+require_relative "haml/transform_single_expression_methods_visitor"
+require_relative "haml/state_and_props_transformer"
+require_relative "haml/hash_key_extractor"
+require_relative "haml/transformer_helpers"
+require_relative "../../source_map"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ TransformResult =
+ Data.define(:filename, :output, :content_hash, :css, :source_map)
+
+ TransformOptions =
+ Data.define(
+ :source,
+ :source_path,
+ :source_line,
+ :content_hash,
+ :factory,
+ :transform_elements_to_classes,
+ :enable_new_helper_ident
+ ) do
+ def source_path_without_extension
+ File.join(
+ File.dirname(source_path),
+ File.basename(source_path, ".*")
+ ).delete_prefix("./")
+ end
+ end
+
+ def self.transform(source, relative_path, factory: "H")
+ SyntaxTree.parse(factory).statements.body => [factory]
+
+ options =
+ TransformOptions[
+ source:,
+ source_path: relative_path,
+ source_line: 1,
+ content_hash: RbNaCl::Hash.sha256(source),
+ factory:,
+ transform_elements_to_classes: false,
+ enable_new_helper_ident: false
+ ]
+
+ result =
+ SyntaxTree::Haml.parse(source).accept(Transformer.new(options))
+
+ TransformResult.new(
+ filename: options.source_path,
+ output: result.source,
+ content_hash: Digest::SHA256.digest(result.source),
+ css: result.styles.first,
+ source_map: {
+ }
+ )
+ end
+
+ class ParseError < StandardError
+ end
+
+ class Transformer < SyntaxTree::Haml::Visitor
+ include TransformerHelpers
+ Result =
+ Data.define(:program, :styles) do
+ def source
+ SyntaxTree::Formatter.format("", program)
+ end
+ end
+
+ def initialize(options)
+ @options = options
+ @builder = RubyBuilder.new(options)
+ @state = {}
+ @sourcemap = []
+ @provides_context = Set.new
+ end
+
+ def visit_haml_comment(node)
+ end
+
+ def visit_root(node)
+ setup = []
+ styles = []
+ render = []
+
+ node.children.each do |child|
+ case child
+ in { type: :filter, value: { name: "ruby" } }
+ if setup.empty? && styles.empty?
+ setup.push(child)
+ else
+ render.push(child)
+ end
+ in type: :script | :silent_script
+ render.push(child)
+ in { type: :filter, value: { name: "css" } }
+ styles.push(child.accept(self))
+ in { type: :filter, value: { name: "plain" } }
+ render.push(child)
+ in type: :tag
+ render.push(child)
+ else
+ render.push(child)
+ end
+ end
+
+ setup = setup.map { _1.accept(self) }
+ render =
+ render
+ .then { group_control_statements(_1) }
+ .then { wrap_multiple_expressions_in_array(_1) }
+
+ Result.new(
+ program:
+ @builder.create_program(
+ @provides_context.to_a,
+ setup,
+ styles,
+ render
+ ),
+ styles:
+ )
+ end
+
+ def visit_comment(node)
+ return node if node.is_a?(SyntaxTree::Comment)
+
+ @builder.comment(
+ if node.children
+ node
+ .children
+ .map do |child|
+ formatter =
+ SyntaxTree::Haml::Format::Formatter.new("", +"", 80)
+ child.format(formatter)
+ formatter.flush
+ formatter.output
+ end
+ .join("\n")
+ else
+ @builder.comment(node.value[:text])
+ end
+ )
+ end
+
+ def visit_slot_tag(node)
+ node.value => { attributes:, dynamic_attributes: }
+
+ name = nil
+
+ if new = dynamic_attributes.new
+ parse_ruby(dynamic_attributes.new) => [parsed_attributes]
+ hash = parsed_attributes.accept(HashKeyExtractorVisitor.new)
+
+ name = hash[:name] || hash["name"]
+ end
+
+ if attr = attributes["name"]
+ name ||= @builder.string_literal(attr)
+ end
+
+ return(
+ @builder.slot(
+ name,
+ fallback: node.children.map { _1.accept(self) }
+ )
+ )
+ end
+
+ def visit_tag(node)
+ node.value => {
+ name:, attributes:, dynamic_attributes:, self_closing:, value:
+ }
+
+ return visit_slot_tag(node) if name == "slot"
+
+ attrs = []
+
+ attrs.push(@builder.props_hash(class: :"__#{name}"))
+
+ unless attributes.empty?
+ attrs.push(@builder.props_hash(attributes))
+ end
+
+ if old = dynamic_attributes.old
+ attrs.push(
+ *source_map_mark(node.line, old.strip) { parse_ruby(old) }
+ )
+ end
+
+ if new = dynamic_attributes.new
+ attrs.push(
+ *source_map_mark(node.line, new.strip) do
+ parse_ruby(new)
+ .map { _1.accept(string_keys_to_labels_mutation_visitor) }
+ .map { _1.accept(wrap_handler_mutation_visitor) }
+ end
+ )
+ end
+
+ if object_ref = node.value[:object_ref]
+ unless object_ref == :nil
+ parse_ruby(object_ref) => [key]
+ attrs.push(@builder.props_hash(key:))
+ end
+ end
+
+ children = [
+ if value
+ if node.value[:parse]
+ parse_ruby(value, fix: false) => statements
+
+ source_map_mark(node.line, value.strip) do
+ @builder.ruby_script(statements)
+ end
+ elsif !value.empty?
+ @builder.string_literal(value.to_s)
+ end
+ else
+ visit_tag_children(node.children)
+ end
+ ].flatten
+
+ @builder.tag(name, children, attrs)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml.test.rb b/lib/mayu/modules/loaders/transformers/haml.test.rb
new file mode 100755
index 00000000..716162f8
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml.test.rb
@@ -0,0 +1,47 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+require_relative "haml"
+require_relative "ruby"
+
+class Mayu::Modules::Loaders::Transformers::Haml::Test < Minitest::Test
+ Dir[File.join(__dir__, "__test__", "haml", "*.haml")].each do |haml|
+ basename = File.basename(haml, ".*")
+ ruby = File.join(File.dirname(haml), basename + ".rb")
+
+ define_method :"test_#{basename}" do
+ output =
+ File
+ .read(haml)
+ .then do
+ Mayu::Modules::Loaders::Transformers::Haml.transform(
+ _1,
+ basename
+ ).output
+ end
+ .then do
+ Mayu::Modules::Loaders::Transformers::Ruby.transform(
+ _1,
+ basename,
+ base_class: "Mayu::Component::Base",
+ enable_assets: true
+ )
+ end
+
+ if File.exist?(ruby)
+ assert_equal(
+ File.read(ruby),
+ output,
+ "#{ruby} does not match transformed output"
+ )
+ else
+ puts "\e[33mWriting #{ruby}\e[0m"
+ File.write(ruby, output)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/hash_key_extractor.rb b/lib/mayu/modules/loaders/transformers/haml/hash_key_extractor.rb
new file mode 100644
index 00000000..ecaa1f27
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/hash_key_extractor.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ class HashKeyExtractorVisitor
+ def visit_hash(node)
+ hash = {}
+
+ node.assocs.each do |child|
+ if extract_key(child.key) in key
+ hash[key] = extract_value(child.value)
+ end
+ end
+
+ hash
+ end
+
+ def extract_key(node)
+ case node
+ when SyntaxTree::StringLiteral
+ node.parts => [{ value: }]
+ value
+ when SyntaxTree::Label
+ node.value
+ end
+ end
+
+ def extract_value(node)
+ node
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/ruby_builder.rb b/lib/mayu/modules/loaders/transformers/haml/ruby_builder.rb
new file mode 100644
index 00000000..4658196a
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/ruby_builder.rb
@@ -0,0 +1,412 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "../css"
+require_relative "../../../../style_sheet"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ class RubyBuilder
+ include SyntaxTree::DSL
+
+ def initialize(options)
+ @options = options
+ end
+
+ def assign_const(name, value) = Assign(VarField(Const(name)), value)
+ def self_var_ref = VarRef(Kw("self"))
+
+ def create_program(provides_context, setup, styles, render)
+ Program(
+ Statements(
+ [
+ assign_const("Self", VarRef(Kw("self"))),
+ # assign_const("FILENAME", VarRef(Kw("__FILE__"))),
+ # assign_const(
+ # "PROVIDES_CONTEXT",
+ # CallNode(
+ # QSymbols(
+ # QSymbolsBeg("i"),
+ # provides_context.map { TStringContent(_1) }
+ # ),
+ # Period("."),
+ # Ident("freeze"),
+ # nil
+ # )
+ # ),
+ *assign_styles(styles),
+ *setup,
+ create_render(render)
+ ].select { !!_1 }
+ )
+ )
+ end
+
+ def assign_styles(styles)
+ [
+ assign_const(
+ "INLINE_STYLES",
+ ArrayLiteral(
+ LBracket("["),
+ Args(
+ [
+ unless styles.empty?
+ Begin(
+ BodyStmt(
+ CSS.transform_inline(
+ @options.source_path_without_extension +
+ ".haml.css",
+ styles.join("\n"),
+ dependency_const_prefix: "CSS_Dep_"
+ ),
+ nil,
+ nil,
+ nil,
+ nil
+ )
+ )
+ end
+ ].compact
+ )
+ )
+ ),
+ assign_const(
+ "Styles",
+ CallNode(
+ ConstPathRef(
+ ConstPathRef(VarRef(Const("Mayu")), Const("Component")),
+ Const("StyleSheets")
+ ),
+ Period("."),
+ Ident("new"),
+ ArgParen(
+ Args(
+ [
+ VarRef(Kw("self")),
+ CallNode(
+ ArrayLiteral(
+ LBracket("["),
+ Args(
+ [
+ ArgStar(VarRef(Const("INLINE_STYLES"))),
+ CallNode(
+ nil,
+ nil,
+ Ident("import?"),
+ ArgParen(
+ Args(
+ [
+ StringLiteral(
+ [
+ TStringContent(
+ File.join(
+ ".",
+ File.basename(
+ @options.source_path_without_extension
+ ) + ".css"
+ )
+ )
+ ],
+ '"'
+ )
+ ]
+ )
+ )
+ )
+ ].compact
+ )
+ ),
+ Period("."),
+ Ident("compact"),
+ nil
+ )
+ ]
+ )
+ )
+ )
+ )
+ ]
+ end
+
+ def const_path(*names)
+ names.reduce(nil) do |parent, name|
+ const = Const(name)
+
+ if T.cast(parent, T.untyped)
+ ConstPathRef(parent, const)
+ else
+ TopConstRef(const)
+ end
+ end
+ end
+
+ def wrap_in_begin_end(statements)
+ if statements in SyntaxTree::Begin
+ statements = statements.bodystmt.statements
+ end
+
+ Begin(
+ BodyStmt(
+ Statements([*Array(statements), VarRef(Kw("nil"))]),
+ nil,
+ nil,
+ nil,
+ nil
+ )
+ )
+ end
+
+ # def assocs(**kwargs)
+ # kwargs.map { |key, value| Assoc(Label("#{key}:"), value) }
+ # end
+ #
+ def array(elems)
+ ArrayLiteral(LBracket("["), Args(elems))
+ end
+
+ def flattened_array(elems)
+ CallNode(array(elems), Period("."), Ident("flatten"), nil)
+ end
+
+ def create_render(statements)
+ Command(
+ Ident("public"),
+ Args(
+ [
+ DefNode(
+ nil,
+ nil,
+ Ident("render"),
+ nil,
+ BodyStmt(Statements(statements), nil, nil, nil, nil)
+ )
+ ]
+ ),
+ nil
+ )
+ end
+
+ def slot(name = nil, fallback: nil)
+ if fallback in [_, *]
+ return(
+ MethodAddBlock(
+ slot(name, fallback: nil),
+ BlockNode(
+ Kw("do"),
+ nil,
+ BodyStmt(Statements(Array(fallback)), nil, nil, nil, nil)
+ )
+ )
+ )
+ end
+
+ CallNode(
+ factory,
+ Period("."),
+ Ident("slot"),
+ wrap_args([Kw("self"), name].compact)
+ )
+ end
+
+ def ruby_comment(content)
+ Comment("# #{content}", false)
+ end
+
+ def comment(content)
+ CallNode(
+ factory,
+ Period("."),
+ Ident("comment"),
+ ArgParen(Args([StringLiteral([TStringContent(content)], '"')]))
+ )
+ end
+
+ def factory = @options.factory
+
+ def tag(name, children, attrs_to_merge)
+ ARef(
+ factory,
+ Args(
+ [
+ tag_name_or_class(name),
+ *children,
+ merge_props(attrs_to_merge)
+ ].flatten.compact
+ )
+ )
+ end
+
+ def tag_name_or_class(name)
+ case name
+ in /\A[A-Z]/
+ Ident(name)
+ else
+ SymbolLiteral(Ident(name))
+ end
+ end
+
+ def splat_hash(node)
+ BareAssocHash([AssocSplat(node)])
+ end
+
+ def merge_props(attrs_to_merge)
+ return if attrs_to_merge.empty?
+
+ splat_hash(call_helpers(:merge_props, attrs_to_merge))
+ end
+
+ def first_or_array(nodes)
+ case nodes
+ in [node]
+ node
+ else
+ ArrayLiteral(LBracket("["), Args(nodes))
+ end
+ end
+
+ def sym(str)
+ if str.match(/\A[\w_]+\z/)
+ SymbolLiteral(Ident(str))
+ else
+ DynaSymbol([TStringContent(str)], '"')
+ end
+ end
+
+ def props_hash(attrs)
+ HashLiteral(
+ LBrace("{"),
+ attrs.map do |key, value|
+ if key.to_s == "class"
+ Assoc(
+ SymbolLiteral(Ident(key.to_s)),
+ first_or_array(value.to_s.split.map { sym(_1) })
+ # ARef(
+ # VarRef(Const("Styles")),
+ # Args(value.to_s.split.map { sym(_1) })
+ # )
+ )
+ else
+ Assoc(
+ sym(key.to_s),
+ case value
+ in Symbol
+ SymbolLiteral(Ident(value.to_s))
+ in String
+ StringLiteral([TStringContent(value.to_s)], :'"')
+ in SyntaxTree::ArrayLiteral
+ value
+ in TrueClass | FalseClass | NilClass
+ VarRef(Kw(value.inspect))
+ end
+ )
+ end
+ end
+ )
+ end
+
+ def try_split_string_literal(node)
+ case node
+ in SyntaxTree::StringLiteral
+ split_string_literal(node)
+ in [SyntaxTree::StringLiteral => node]
+ split_string_literal(node)
+ else
+ node
+ end
+ end
+
+ def split_string_literal(string_literal)
+ string_literal
+ # string_literal
+ # .parts
+ # .map do |part|
+ # case part
+ # in SyntaxTree::TStringContent
+ # string_literal(part.value)
+ # in SyntaxTree::StringEmbExpr
+ # part.statements
+ # end
+ # end
+ # .flatten
+ end
+
+ def ruby_script(statements)
+ case statements
+ in []
+ nil
+ in [SyntaxTree::StringLiteral => string_literal]
+ split_string_literal(string_literal)
+ in [statement]
+ statement
+ else
+ Begin(BodyStmt(Statements(statements), nil, nil, nil, nil))
+ end
+ end
+
+ def silent(node)
+ case node
+ in SyntaxTree::ReturnNode
+ node
+ else
+ Begin(
+ BodyStmt(
+ Statements([node, VarRef(Kw("nil"))]),
+ nil,
+ nil,
+ nil,
+ nil
+ )
+ )
+ end
+ end
+
+ def mayu_const_path
+ # ConstPathRef(VarRef(Const("Mayu")), Const("Mayu"))
+ Const("Mayu")
+ end
+
+ def create_callback(name)
+ CallNode(
+ factory,
+ Period("."),
+ Ident("callback"),
+ ArgParen(Args([VarRef(Kw("self")), SymbolLiteral(name)]))
+ )
+ end
+
+ def call_helpers(method, *args)
+ CallNode(
+ CallNode(VarRef(Kw("self")), Period("."), Ident("class"), nil), # mayu_const_path,
+ Period("."),
+ Ident(method.to_s),
+ wrap_args([*args.flatten.compact])
+ )
+ end
+
+ def helper_ident
+ if @options.enable_new_helper_ident
+ CallNode(VarRef(Kw("self")), Period("."), Ident("Mayu"), nil)
+ else
+ Ident("mayu")
+ end
+ end
+
+ def wrap_args(args)
+ args.empty? ? nil : ArgParen(Args(args))
+ end
+
+ def string_literal(value) =
+ StringLiteral([TStringContent(value.to_s)], '"')
+ def call_freeze(node) =
+ CallNode(node, Period("."), Ident("freeze"), nil)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/source_map_mark_ruby_visitor.rb b/lib/mayu/modules/loaders/transformers/haml/source_map_mark_ruby_visitor.rb
new file mode 100644
index 00000000..906f952a
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/source_map_mark_ruby_visitor.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ class SourceMapMarkRubyVisitor < SyntaxTree::Visitor
+ include SyntaxTree::DSL
+
+ def initialize(source, offset)
+ @source_lines = source.lines
+ @offset = offset
+ end
+
+ def visit_program(node)
+ return node
+ node.copy(statements: visit(node.statements))
+ end
+
+ def visit_def(node)
+ add_source_map_mark(node)
+ end
+
+ def visit_statements(node)
+ node.body.each { add_source_map_mark(_1) }
+
+ add_source_map_mark(node)
+ end
+
+ def visit_assign(node)
+ add_source_map_mark(node)
+ end
+
+ private
+
+ def add_source_map_mark(node)
+ visit_child_nodes(node)
+
+ code = @source_lines[node.location.start_line - 1].strip
+
+ node.comments.replace(
+ [
+ Modules::SourceMap::Mark[
+ @offset + node.location.start_line,
+ code
+ ].to_comment
+ ]
+ )
+
+ node
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/state_and_props_transformer.rb b/lib/mayu/modules/loaders/transformers/haml/state_and_props_transformer.rb
new file mode 100644
index 00000000..b08cfad8
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/state_and_props_transformer.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "../mutation_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ class StateAndPropsTransformer
+ include SyntaxTree::DSL
+
+ def initialize(provides_context = Set.new)
+ @provides_context = provides_context
+ end
+
+ def visitor
+ MutationVisitor.build do |visitor|
+ visitor.mutate(
+ "VarRef[value: GVar[value: /\\A\\$\\*/]]"
+ ) { |var_ref| props_ivar }
+
+ visitor.mutate(
+ "VarRef[value: GVar[value: /\\A\\$[\\w_]+/]]"
+ ) { |var_ref| props_aref(var_ref.value) }
+
+ visitor.mutate(
+ "Assign[target: VarField[value: GVar]]"
+ ) do |assign|
+ assign => { target: { target: { value: var_name } } }
+ loc = assign.target.location
+ raise "Can not write to prop #{var_name} on line #{loc.start_line} col #{loc.start_column}"
+ end
+
+ visitor.mutate("VarRef[value: CVar]") do |node|
+ name = node.value.value.delete_prefix("@@")
+
+ ARef(VarRef(IVar("@__context")), SymbolLiteral(Ident(name)))
+ end
+
+ visitor.mutate(
+ "Assign[target: VarField[value: CVar]]"
+ ) do |node|
+ name = node.target.value.value.delete_prefix("@@")
+ puts "ADDING #{name}"
+ @provides_context.add(name)
+ pp @provides_context
+
+ node.copy(
+ target:
+ ARef(
+ VarRef(IVar("@__context")),
+ SymbolLiteral(Ident(name))
+ )
+ )
+ end
+
+ visitor.mutate(
+ "OpAssign[target: VarField[value: CVar]]"
+ ) do |node|
+ name = node.target.value.value.delete_prefix("@@")
+ @provides_context.add(name)
+
+ node.copy(
+ target:
+ ARef(
+ VarRef(IVar("@__context")),
+ SymbolLiteral(Ident(name))
+ )
+ )
+ end
+
+ visitor.mutate(
+ "OpAssign[target: VarField[value: IVar]]"
+ ) do |assign|
+ CallNode(nil, nil, Ident("update!"), ArgParen(Args([assign])))
+ end
+
+ visitor.mutate(
+ "Assign[target: VarField[value: IVar]]"
+ ) do |assign|
+ CallNode(nil, nil, Ident("update!"), ArgParen(Args([assign])))
+ end
+ end
+ end
+
+ private
+
+ def props_ivar
+ VarRef(IVar("@__props"))
+ end
+
+ def props_aref(node)
+ ARef(props_ivar, Args([var_to_symbol(node)]))
+ end
+
+ def call_self(method)
+ CallNode(VarRef(Kw("self")), Period("."), Ident(method), nil)
+ end
+
+ def var_to_symbol(node)
+ SymbolLiteral(Ident(strip_var_prefix(node.value)))
+ end
+
+ def strip_var_prefix(str)
+ str[/\A[@$]*(.*)/, 1]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/transform_single_expression_methods_visitor.rb b/lib/mayu/modules/loaders/transformers/haml/transform_single_expression_methods_visitor.rb
new file mode 100644
index 00000000..2d4bb1c9
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/transform_single_expression_methods_visitor.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "../mutation_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ class TransformSingleExpressionMethodsVisitor
+ include SyntaxTree::DSL
+
+ def visitor
+ # Input:
+ # def foo = 123
+ # Output:
+ # def foo
+ # 123
+ # end
+ # This makes source map comments show up on correct lines
+ MutationVisitor.build do |visitor|
+ visitor.mutate("DefNode[bodystmt: Statements]") do |node|
+ node.copy(
+ bodystmt: BodyStmt(node.bodystmt, nil, nil, nil, nil)
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/haml/transformer_helpers.rb b/lib/mayu/modules/loaders/transformers/haml/transformer_helpers.rb
new file mode 100644
index 00000000..de27ecc3
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/haml/transformer_helpers.rb
@@ -0,0 +1,414 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ module Haml
+ module TransformerHelpers
+ IN_RE = /\A\s*in\s+/
+
+ def visit_tag_children(children)
+ children
+ .reject { it in { type: :plain, value: { text: "" } } }
+ .then { join_plain_nodes(it) }
+ .then { prepend_whitespace(it) }
+ .then { append_whitespace(it) }
+ .then { group_control_statements(it) }
+ .flatten
+ end
+
+ def join_plain_nodes(children)
+ children
+ .chunk_while do |prev, curr|
+ (
+ (prev in { type: :plain, value: { text: prev_text } }) &&
+ (curr in { type: :plain, value: { text: new_text } })
+ )
+ end
+ .map do |chunk|
+ case chunk
+ in [{ type: :plain } => first, *]
+ text = chunk.map { it.value[:text].to_s.strip }.join(" ")
+ first.value[:text] = text
+ first
+ else
+ chunk
+ end
+ end
+ .flatten
+ .compact
+ end
+
+ def group_control_statements(children)
+ children
+ .chunk_while do |a, b|
+ case [a, b]
+ in [
+ { type: :script, value: { keyword: "if" | "elsif" } },
+ { type: :script, value: { keyword: "elsif" | "else" } }
+ ]
+ true
+ in [
+ { type: :script, value: { keyword: "case" | "when" } },
+ { type: :script, value: { keyword: "when" | "else" } }
+ ]
+ true
+ in [
+ {
+ type: :script,
+ value: { keyword: "case" } | { text: IN_RE }
+ },
+ {
+ type: :script,
+ value: { keyword: "else" } | { text: IN_RE }
+ }
+ ]
+ true
+ in [
+ { type: :script, value: { keyword: "begin" } },
+ {
+ type: :script,
+ value: { keyword: "rescue" | "else" | "ensure" }
+ }
+ ]
+ true
+ in [
+ { type: :script, value: { keyword: "rescue" } },
+ { type: :script, value: { keyword: "else" | "ensure" } }
+ ]
+ true
+ in [
+ { type: :script, value: { keyword: "else" } },
+ { type: :script, value: { keyword: "ensure" } }
+ ]
+ true
+ else
+ false
+ end
+ end
+ .map do |chunk|
+ case chunk
+ in [{ type: :script, value: { keyword: "if" } }, *]
+ group_condition(:if, chunk)
+ in [{ type: :script, value: { keyword: "case" } }, *]
+ group_condition(:case, chunk)
+ in [{ type: :script, value: { keyword: "begin" } }, *]
+ group_condition(:begin, chunk)
+ else
+ chunk.map { |node| node.accept(self) }
+ end
+ end
+ .flatten
+ .compact
+ end
+
+ def wrap_multiple_expressions_in_array(nodes)
+ if nodes.length > 1
+ [@builder.flattened_array(nodes)]
+ else
+ nodes
+ end
+ end
+
+ def group_condition(type, chunk)
+ chunk
+ .then { join_ruby_script_nodes(it) }
+ .then { parse_ruby(it, fix: true) } => [statement]
+
+ visitor = MutationVisitor.new
+
+ chunk.shift if type == :case
+
+ visitor.mutate("Statements") do |node|
+ top = chunk.shift
+
+ if node.child_nodes in [SyntaxTree::VoidStmt]
+ @builder.Statements(
+ top
+ .children
+ .then { visit_tag_children(it) }
+ .then { wrap_multiple_expressions_in_array(it) }
+ )
+ else
+ unless top.children.empty?
+ raise "Line #{top.line} should not have children."
+ end
+
+ node
+ end
+ end
+
+ @builder.ruby_script([statement.accept(visitor)])
+ end
+
+ def join_ruby_script_nodes(nodes)
+ nodes.map { |node| node.value[:text] }.join("\n")
+ end
+
+ def prepend_whitespace(children)
+ [nil, *children].each_cons(2)
+ .map do |prev, curr|
+ if prev in {
+ type: :tag, value: { nuke_outer_whitespace: true }
+ }
+ if curr in { type: :plain, value: { text: } }
+ curr.value = { text: " #{text}" }
+ else
+ next make_space(curr), curr
+ end
+ end
+
+ curr
+ end
+ end
+
+ def append_whitespace(children)
+ [*children, nil].each_cons(2)
+ .flat_map do |curr, succ|
+ if succ in {
+ type: :tag, value: { nuke_inner_whitespace: true }
+ }
+ if curr in { type: :plain, value: { text: } }
+ curr.value = { text: "#{text} " }
+ else
+ next curr, make_space(curr)
+ end
+ end
+
+ curr
+ end
+ end
+
+ def make_space(ref_node)
+ ::Haml::Parser::ParseNode.new(
+ :plain,
+ ref_node.line,
+ { text: " " },
+ ref_node.parent,
+ []
+ )
+ end
+
+ def visit_filter(node)
+ case node.value
+ in { name: "ruby", text: }
+ if text
+ @builder.wrap_in_begin_end(
+ @builder.ruby_script(
+ parse_ruby(text, mark_sourcemap: node.line)
+ )
+ )
+ end
+ in { name: "css", text: }
+ text
+ in { name: "plain", text: }
+ case text.rstrip.lines.to_a
+ in []
+ # noop
+ in [line]
+ @builder.string_literal(line)
+ in [*lines]
+ id =
+ RbNaCl::Hash
+ .sha256(Base64.encode64(@options.content_hash) + text)
+ .unpack("h*")
+ .join
+
+ @builder.Heredoc(
+ @builder.HeredocBeg("<<~PLAIN_#{id}"),
+ @builder.HeredocEnd("PLAIN_#{id}"),
+ true,
+ lines.map { @builder.TStringContent(it.sub(/\n*$/, "\n")) }
+ )
+ end
+ end
+ end
+
+ def visit_plain(node)
+ node.value => { text: }
+ @builder.string_literal(text)
+ end
+
+ def source_map_mark(line, content, &)
+ [
+ Modules::SourceMap::Mark[line, content].to_comment,
+ yield
+ ].flatten
+ end
+
+ def visit_script(node)
+ visit_script2(node).tap do
+ it.comments.replace(
+ [
+ Modules::SourceMap::Mark[
+ node.line,
+ node.value[:text].strip
+ ].to_comment
+ ]
+ )
+ end
+ end
+
+ def visit_script2(node)
+ case node.value[:text].strip
+ when /\Areturn\s+(?if|unless)\s+(?.+)/
+ $~ => { type:, condition_source: }
+
+ parse_ruby(
+ condition_source,
+ fix: true,
+ mark_sourcemap: node.line
+ ) => [condition]
+
+ statements =
+ @builder.Statements(
+ [
+ @builder.ReturnNode(
+ @builder.Args(visit_tag_children(node.children))
+ )
+ ]
+ )
+
+ case type
+ in "if"
+ @builder.IfNode(condition, statements, nil)
+ in "unless"
+ @builder.UnlessNode(condition, statements, nil)
+ end
+ when /\Areturn/
+ @builder.ReturnNode(
+ @builder.Args(visit_tag_children(node.children))
+ )
+ else
+ transform_script_node(node)
+ end
+ end
+
+ def with_state(name, value, &block)
+ @state[name], prev = value, @state[name]
+ yield prev
+ ensure
+ @state[name] = prev
+ end
+
+ def visit_silent_script(node)
+ with_state(:is_silent, true) do |was_silent|
+ if was_silent
+ visit_script(node)
+ else
+ @builder.silent(visit_script(node))
+ end
+ end
+ end
+
+ def transform_script_node(node)
+ source = node.value.fetch(:text).strip
+
+ if node.children.empty?
+ parse_ruby(source, fix: false) => statements
+ return @builder.ruby_script(statements)
+ end
+
+ parse_ruby(source, fix: true) => [statement]
+
+ visitor = MutationVisitor.new
+
+ visitor.mutate("Statements[body: [VoidStmt]]") do
+ @builder.Statements(visit_tag_children(node.children))
+ end
+
+ @builder.ruby_script([statement.accept(visitor)])
+ end
+
+ def parse_ruby(source, fix: false, mark_sourcemap: false)
+ source = fix_syntax_by_adding_missing_pairs(source) if fix
+
+ statements =
+ SyntaxTree
+ .parse(source)
+ .statements
+ .accept(
+ StateAndPropsTransformer.new(@provides_context).visitor
+ )
+
+ if mark_sourcemap
+ statements
+ .accept(TransformSingleExpressionMethodsVisitor.new.visitor)
+ .accept(SourceMapMarkRubyVisitor.new(source, mark_sourcemap))
+ .body
+ else
+ statements.body
+ end
+ rescue SyntaxTree::Parser::ParseError
+ explain =
+ SyntaxSuggest::ExplainSyntax.new(
+ code_lines: SyntaxSuggest::CodeLine.from_source(source)
+ ).call
+
+ msg = ["Failed parsing Ruby: #{source}"]
+
+ msg.push <<~MSG unless explain.errors.empty?
+ Errors:
+ #{explain.errors.join(" \n")}
+ MSG
+
+ msg.push <<~MSG unless explain.missing.empty?
+ Missing:
+ #{explain.missing.map { explain.why(it) }.join(" \n")}
+ MSG
+
+ raise ParseError, "\n#{msg.join("\n")}"
+ end
+
+ def fix_syntax_by_adding_missing_pairs(source)
+ left_right = SyntaxSuggest::LeftRightLexCount.new
+ SyntaxSuggest::LexAll
+ .new(source:)
+ .each { left_right.count_lex(it) }
+ left_right.missing
+ [source, *left_right.missing].join("\n")
+ end
+
+ def wrap_handler_mutation_visitor
+ visitor = MutationVisitor.new
+
+ visitor.mutate(
+ "Assoc[key: Label, value: VCall[value: Ident]]"
+ ) do |assoc|
+ if assoc.key.value.start_with?("on")
+ @builder.Assoc(
+ assoc.key,
+ @builder.create_callback(assoc.value.value)
+ )
+ else
+ assoc
+ end
+ end
+
+ visitor
+ end
+
+ def string_keys_to_labels_mutation_visitor
+ visitor = MutationVisitor.new
+
+ visitor.mutate("Assoc[key: StringLiteral]") do |assoc|
+ @builder.Assoc(
+ @builder.Label(
+ assoc.key.parts.map(&:value).join.gsub("-", "_") + ":"
+ ),
+ assoc.value
+ )
+ end
+
+ visitor
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/mutation_visitor.rb b/lib/mayu/modules/loaders/transformers/mutation_visitor.rb
new file mode 100644
index 00000000..3dcd2031
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/mutation_visitor.rb
@@ -0,0 +1,76 @@
+require "syntax_tree"
+require "syntax_tree/mutation_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ class MutationVisitor < SyntaxTree::MutationVisitor
+ def self.build(&) = new.tap(&)
+
+ def visit_assign(node)
+ node.copy(target: visit(node.target), value: visit(node.value))
+ end
+
+ def visit_unary(node)
+ node.copy(statement: visit(node.statement))
+ end
+
+ def visit_opassign(node)
+ node.copy(target: visit(node.target), value: visit(node.value))
+ end
+
+ def visit_rassign(node)
+ node.copy(
+ operator: visit(node.operator),
+ pattern: visit(node.pattern),
+ value: visit(node.value)
+ )
+ end
+
+ def visit_assoc_splat(node)
+ node.copy(value: visit(node.value))
+ end
+
+ def visit_field(node)
+ node.copy(
+ parent: visit(node.parent),
+ operator: node.operator == :"::" ? :"::" : visit(node.operator),
+ name: visit(node.name)
+ )
+ end
+
+ def visit_binary(node)
+ node.copy(left: visit(node.left), right: visit(node.right))
+ end
+
+ def visit_lambda(node)
+ node.copy(
+ params: visit(node.params),
+ statements: visit(node.statements)
+ )
+ end
+
+ def visit_assoc(node)
+ node.copy(key: visit(node.key), value: visit(node.value))
+ end
+
+ def visit_aref(node)
+ node.copy(
+ collection: visit(node.collection),
+ index: visit(node.index)
+ )
+ end
+
+ def visit_if_op(node)
+ node.copy(
+ predicate: visit(node.predicate),
+ truthy: visit(node.truthy),
+ falsy: visit(node.falsy)
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/ruby.rb b/lib/mayu/modules/loaders/transformers/ruby.rb
new file mode 100644
index 00000000..3f8d2cf6
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/ruby.rb
@@ -0,0 +1,365 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "syntax_tree"
+require_relative "mutation_visitor"
+require_relative "xml_utils"
+require_relative "frozen_string_literal_visitor"
+
+module Mayu
+ module Modules
+ module Loaders
+ module Transformers
+ class Ruby
+ class Formatter < SyntaxTree::Formatter
+ def format(node, stackable: true)
+ stack << node if stackable
+ doc = nil
+
+ # If there are comments, then we're going to format them around the node
+ # so that they get printed properly.
+ if node.comments.any?
+ trailing = []
+ last_leading = nil
+
+ # First, we're going to print all of the comments that were found before
+ # the node. We'll also gather up any trailing comments that we find.
+ node.comments.each do |comment|
+ if comment.trailing?
+ trailing << comment
+ else
+ comment.format(self)
+ breakable(force: true)
+ last_leading = comment
+ end
+ end
+
+ # If the node has a stree-ignore comment right before it, then we're
+ # going to just print out the node as it was seen in the source.
+ doc =
+ if last_leading&.ignore?
+ range = source[node.start_char...node.end_char]
+ first = true
+
+ range.each_line(chomp: true) do |line|
+ if first
+ first = false
+ else
+ breakable_return
+ end
+
+ text(line)
+ end
+
+ breakable_return if range.end_with?("\n")
+ else
+ node.format(self)
+ end
+
+ # Print all comments that were found after the node.
+ trailing.each do |comment|
+ line_suffix(priority: COMMENT_PRIORITY) do
+ comment.inline? ? text(" ") : breakable
+ comment.format(self)
+ break_parent
+ end
+ end
+ else
+ doc = node.format(self)
+ end
+
+ stack.pop if stackable
+ doc
+ end
+ end
+
+ include SyntaxTree::DSL
+
+ COLLECTIONS = {
+ SyntaxTree::IVar => "state",
+ SyntaxTree::GVar => "props"
+ }
+
+ def self.transform(
+ source,
+ path,
+ using: [],
+ base_class: nil,
+ enable_assets: false
+ )
+ transformer = new
+ # puts "\e[33m#{source}\e[0m"
+ if base_class
+ SyntaxTree.parse(base_class).statements.body => [base_class_ast]
+ else
+ base_class_ast = nil
+ end
+
+ using =
+ using.map do
+ SyntaxTree.parse(_1).statements.body => [mod]
+ mod
+ end
+
+ SyntaxTree
+ .parse(source)
+ .accept(transformer.heredoc_html)
+ .then do
+ transformer.wrap_in_class(
+ _1,
+ path,
+ base_class_ast:,
+ using:,
+ enable_assets:
+ )
+ end
+ .accept(transformer.frozen_strings)
+ .then { Formatter.format(source, _1) }
+ rescue SyntaxTree::Parser::ParseError => e
+ puts "\e[1;31mError parsing: #{path}\e[0m"
+ line = source.lines.to_a[e.lineno.pred].dup
+ line[e.column, 0] = "\e[3m"
+ puts "#{e.lineno}: #{line}\e[0m"
+ raise
+ end
+
+ def frozen_strings = FrozenStringLiteralVisitor.new
+
+ def wrap_in_class(
+ program,
+ path,
+ base_class_ast:,
+ using:,
+ enable_assets:
+ )
+ class_name =
+ File.basename(path, ".*").sub(/\A[[:lower:]]/) { _1.upcase }
+
+ statements =
+ Statements(
+ [
+ ClassDeclaration(
+ VarRef(Const(class_name)),
+ base_class_ast,
+ BodyStmt(
+ Statements(
+ [
+ DefNode(
+ VarRef(Kw("self")),
+ Period("."),
+ Ident("module_path"),
+ nil,
+ BodyStmt(
+ Statements([VarRef(Kw("__FILE__"))]),
+ nil,
+ nil,
+ nil,
+ nil
+ )
+ ),
+ import_statement(base_class_ast),
+ using_statements(using),
+ program.statements.body
+ ].compact.flatten
+ ),
+ nil,
+ nil,
+ nil,
+ nil
+ )
+ ),
+ unless class_name == "Default"
+ Assign(VarRef(Const("Default")), VarRef(Const(class_name)))
+ end,
+ (assets_code if enable_assets)
+ ].compact
+ )
+ program.copy(statements:)
+ end
+
+ def using_statements(using)
+ using.map { Command(Ident("using"), Args([_1]), nil) }
+ end
+
+ def import_statement(base_class_ast)
+ return unless base_class_ast.nil?
+
+ SyntaxTree
+ .parse(
+ "def self.import(path) = ::Mayu::Modules::System.import(path, module_path)"
+ )
+ .statements
+ .body
+ .first
+ end
+
+ def heredoc_html
+ MutationVisitor.new.tap do |visitor|
+ visitor.mutate(
+ "XStringLiteral | Heredoc[beginning: HeredocBeg[value: '<<~HTML']]"
+ ) do |node|
+ tokenizer = XMLUtils::Tokenizer.new
+
+ node.parts.flat_map do |child|
+ case child
+ in SyntaxTree::TStringContent
+ tokenizer.tokenize(child.value)
+ in SyntaxTree::StringEmbExpr
+ tokenizer.T(:statements, child.statements.accept(visitor))
+ end
+ end
+
+ parser = XMLUtils::Parser.new
+ parser.parse(tokenizer.tokens.dup)
+
+ statements =
+ parser.tokens.map { xml_token_to_ast_node(_1) }.compact
+
+ Formatter.format("", Statements(statements))
+
+ Statements(statements)
+ end
+ end
+ end
+
+ def xml_token_to_ast_node(token)
+ case token
+ in { type: :tag, value: { name:, attrs:, children: } }
+ args = [
+ SymbolLiteral(Ident(name.to_sym)),
+ *children.map { xml_token_to_ast_node(_1) },
+ unless attrs.empty?
+ BareAssocHash(attrs.map { xml_token_to_ast_node(_1) })
+ end
+ ].compact
+
+ ARef(VarRef(Const("H")), Args(args))
+ in { type: :attr, value: { name:, value: } }
+ Assoc(
+ StringLiteral([TStringContent(name)], '"'),
+ xml_token_to_ast_node(value)
+ )
+ in { type: :attr_value, value: }
+ StringLiteral([TStringContent(value)], '"')
+ in { type: :var_ref, value: /\A@(.*)/ }
+ ARef(call_self("state"), Args([SymbolLiteral(Ident($~[1]))]))
+ in { type: :var_ref, value: /\A\$(.*)/ }
+ ARef(
+ VarRef(IVar("@__props")),
+ Args([SymbolLiteral(Ident($~[1]))])
+ )
+ in type: :newline
+ nil
+ in { type: :string, value: }
+ StringLiteral([TStringContent(value)], '"')
+ in { type: :statements, value: }
+ case value.body
+ in []
+ nil
+ in [first]
+ first
+ in [*many]
+ Begin(BodyStmt(value))
+ end
+ end
+ end
+
+ def assets_code
+ MethodAddBlock(
+ CallNode(
+ ConstPathRef(VarRef(Const("Default")), Const("INLINE_STYLES")),
+ Period("."),
+ Ident("each"),
+ nil
+ ),
+ BlockNode(
+ BlockVar(Params([], [], [], [], [], [], nil), nil),
+ nil,
+ Statements(
+ [
+ CallNode(
+ nil,
+ nil,
+ Ident("add_asset"),
+ ArgParen(
+ Args(
+ [
+ ARef(
+ ConstPathRef(
+ ConstPathRef(
+ ConstPathRef(
+ VarRef(Const("Mayu")),
+ Const("Assets")
+ ),
+ Const("Generators")
+ ),
+ Const("Text")
+ ),
+ Args(
+ [
+ CallNode(
+ VarRef(Ident("_1")),
+ Period("."),
+ Ident("filename"),
+ nil
+ ),
+ CallNode(
+ VarRef(Ident("_1")),
+ Period("."),
+ Ident("content"),
+ nil
+ )
+ ]
+ )
+ )
+ ]
+ )
+ )
+ )
+ ]
+ )
+ )
+ )
+ end
+
+ private
+
+ def call_html(parts)
+ call_self(:html, ArgParen(Args([StringLiteral(parts, '"')])))
+ end
+
+ def call_self(method, args = nil)
+ CallNode(VarRef(Kw("self")), Period("."), Ident(method), args)
+ end
+
+ def update(nodes)
+ MethodAddBlock(
+ call_self("update"),
+ BlockNode(Kw("{"), nil, Statements(Array(nodes)))
+ )
+ end
+
+ def aref(node)
+ ARef(
+ call_self(COLLECTIONS.fetch(node.class)),
+ Args([SymbolLiteral(Ident(strip_var_prefix(node.value)))])
+ )
+ end
+
+ def aref_field(node)
+ ARefField(
+ call_self(COLLECTIONS.fetch(node.class)),
+ Args([SymbolLiteral(Ident(strip_var_prefix(node.value)))])
+ )
+ end
+
+ def strip_var_prefix(str)
+ str.delete_prefix("@").delete_prefix("$")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/loaders/transformers/xml_utils.rb b/lib/mayu/modules/loaders/transformers/xml_utils.rb
new file mode 100644
index 00000000..a0a961cc
--- /dev/null
+++ b/lib/mayu/modules/loaders/transformers/xml_utils.rb
@@ -0,0 +1,279 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# Released under AGPL-3.0
+
+require "strscan"
+
+module VDOM
+ class XMLUtils
+ Token =
+ Data.define(:type, :value) do
+ def to_s = inspect
+
+ def inspect
+ case self
+ in { type: :tag, value: { name:, attrs:, children: } }
+ str = [name.to_sym, *children, *attrs].map(&:inspect)
+ .reject(&:empty?)
+ .join(", ")
+ "h(#{str})"
+ in { type: :attr, value: { name:, value: } }
+ " #{name}: #{value.inspect}"
+ in { type: :var_ref, value: /\A@(.*)/ }
+ "self.state[:#{$~[1]}]"
+ in { type: :var_ref, value: /\A\$(.*)/ }
+ "self.props[:#{$~[1]}]"
+ in type: :newline
+ ""
+ in { type: :string, value: }
+ value.inspect
+ in { type: :statements, value: }
+ SyntaxTree::Formatter.format("", value)
+ in { type:, value: }
+ "[#{type} #{value.inspect}]"
+ end
+ end
+ end
+
+ class Tokenizer
+ def T(type, value = nil)
+ @tokens.push(Token[type, value])
+ end
+
+ def initialize
+ @tokens = []
+ @state = :any
+ end
+
+ attr_reader :tokens
+
+ def tokenize(source)
+ ss = StringScanner.new(source.lstrip)
+
+ until ss.eos?
+ new_state =
+ case p(@state)
+ in :any
+ tokenize_any(ss)
+ in :string
+ tokenize_string(ss)
+ in :tag
+ tokenize_tag(ss)
+ in :attrs
+ tokenize_attrs(ss)
+ in :attr_value
+ tokenize_attr_value(ss)
+ end
+ @state = new_state
+ end
+ end
+
+ private
+
+ def tokenize_any(ss)
+ case
+ when ss.scan(/)
+ :tag
+ when ss.scan(/\n/)
+ T(:newline)
+ :any
+ else
+ :string
+ end
+ end
+
+ def tokenize_string(ss)
+ parts = []
+
+ while str = ss.scan_until(//) or raise "Expected tag to end!"
+ T(:close_tag, tag_name)
+ return :any
+ end
+
+ T(:open_tag_begin, tag_name)
+
+ :attrs
+ end
+
+ def tokenize_attrs(ss)
+ ss.skip(/\s+/)
+
+ if ss.scan(/>/)
+ T(:open_tag_end)
+ return :any
+ end
+
+ if ss.scan(%r{/})
+ raise "Expected > after /" unless ss.scan(/>/)
+
+ T(:open_tag_end, self_closing: true)
+
+ return :any
+ end
+
+ attr = ss.scan(/\w+/)
+
+ return :attrs unless attr
+
+ T(:attr_name, attr)
+
+ if ss.scan(/=/)
+ T(:attr_assign)
+ :attr_value
+ else
+ :attrs
+ end
+ end
+
+ def tokenize_attr_value(ss)
+ if var_ref = ss.scan(/[@$][\w_]+/)
+ T(:var_ref, var_ref)
+ return :attrs
+ end
+
+ if value_begin = ss.scan(/"/)
+ if value = ss.scan_until(/"/)[0...-1]
+ T(:attr_value, value)
+ return :attrs
+ end
+ end
+
+ raise "Expected value at #{ss.pos}"
+ end
+ end
+
+ class Parser
+ def initialize
+ @tokens = []
+ end
+
+ attr_reader :tokens
+
+ def parse(tokens)
+ @tokens.push(parse_any(tokens)) until tokens.empty?
+
+ self
+ end
+
+ private
+
+ def parse_any(tokens, close_tag = nil)
+ case p(token = tokens.shift)
+ in { type: :open_tag_begin, value: }
+ parse_tag(tokens, value)
+ in type: :string
+ token
+ in type: :newline
+ token
+ in type: :close_tag
+ token
+ in type: :statements
+ token
+ in nil
+ raise "Unexpected end of tokens"
+ end
+ end
+
+ def parse_tag(tokens, name)
+ attrs = []
+
+ while token = tokens.shift
+ case token
+ in { type: :open_tag_end, value: { self_closing: true } }
+ return Token[:tag, { name:, attrs:, children: [] }]
+ in type: :open_tag_end
+ children = parse_children(tokens, name)
+ return Token[:tag, { name:, attrs:, children: }]
+ in { type: :attr_name, value: }
+ attrs.push(parse_attr(tokens, value))
+ end
+ end
+ end
+
+ def parse_children(tokens, close_tag)
+ children = []
+
+ while token = parse_any(tokens, close_tag)
+ case token
+ in { type: :close_tag, value: close_tag }
+ return children
+ in { type: :close_tag, value: }
+ raise "Expected close tag for #{close_tag} but got #{value}"
+ else
+ children.push(token)
+ end
+ end
+
+ children
+ end
+
+ def parse_attr(tokens, name)
+ while token = tokens.shift
+ case token
+ in type: :attr_assign
+ next
+ in type: :var_ref
+ return Token[:attr, { name:, value: token }]
+ in type: :attr_value
+ return Token[:attr, { name:, value: token }]
+ end
+ end
+ end
+ end
+ end
+end
+
+if __FILE__ == $0
+ tokenizer = VDOM::XMLUtils::Tokenizer.new
+
+ tokenizer.tokenize(<<~HTML)
+
+
asd: asdasd
+ HTML
+
+ puts "Before:"
+ puts tokenizer.tokens
+
+ index = tokenizer.tokens.length
+
+ tokenizer.tokenize(<<~HTML)
+
+ HTML
+
+ puts "Added:"
+ puts tokenizer.tokens.slice(index..-1)
+
+ parser = VDOM::XMLUtils::Parser.new
+
+ puts "Parsed"
+ puts parser.parse(tokenizer.tokens.dup).tokens
+end
diff --git a/lib/mayu/modules/mod.rb b/lib/mayu/modules/mod.rb
new file mode 100644
index 00000000..3deffb3c
--- /dev/null
+++ b/lib/mayu/modules/mod.rb
@@ -0,0 +1,183 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ class Exports < Module
+ def initialize(mod, source, path)
+ @mod = mod
+ @path = path
+ module_eval(source, path, 1)
+ end
+
+ def import(path) = @mod.import(path)
+
+ def add_asset(asset) = @mod.add_asset(asset)
+ end
+
+ class Mod < Module
+ attr_accessor :order
+ attr_reader :path
+ attr_reader :dependants
+ attr_reader :dependencies
+ attr_reader :system
+ attr_reader :source_map
+ attr_reader :assets
+ attr_reader :imports
+ attr_reader :dependency_nodes
+
+ def initialize(system, path)
+ @order = Float::INFINITY
+ @system = system
+ @path = path
+ @dependants = Set.new
+ @dependencies = Set.new
+ @system.register(@path, self)
+ @source = nil
+ @source_map = nil
+ @assets = Set.new
+ @imports = {}
+ @dependency_nodes = Set.new
+ end
+
+ def to_s
+ File.join("MAYU_ROOT", @path)
+ end
+
+ def const_missing(const)
+ if const == :Exports
+ reload(reload_source: false)
+
+ if exports = const_get(:Exports)
+ return exports
+ end
+ end
+
+ super
+ end
+
+ def marshal_dump
+ [
+ @order,
+ @path,
+ @dependants,
+ @dependencies,
+ @source,
+ @assets,
+ @source_map,
+ @imports,
+ @dependency_nodes
+ ]
+ end
+
+ def marshal_load(a)
+ @order,
+ @path,
+ @dependants,
+ @dependencies,
+ @source,
+ @assets,
+ @source_map,
+ @imports,
+ @dependency_nodes =
+ a
+ @imports ||= {}
+ @dependency_nodes ||= Set.new
+ Registry[@path] = self
+ end
+
+ def reload(reload_source: true)
+ old_exports =
+ if const_defined?(:Exports)
+ # Console.logger.info(self, "Reloading #{@path}")
+ const_get(:Exports)
+ else
+ # Console.logger.info(self, "Loading #{@path}")
+ nil
+ end
+
+ if reload_source
+ begin
+ reload_source!
+ rescue => e
+ pp e
+ puts e.backtrace
+ return
+ end
+ end
+
+ @assets.clear
+
+ path = @path
+
+ exports =
+ begin
+ Exports.new(self, @source, path)
+ rescue => e
+ puts e
+ puts e.backtrace.first(5)
+ return
+ end
+
+ remove_const(:Exports) if const_defined?(:Exports)
+ const_set(:Exports, exports)
+ end
+
+ def reload_source!
+ if staged_source_update?
+ @source = @staged_source
+ @source_map = @staged_source_map
+ @imports = @staged_imports
+ clear_staged_source_update!
+ else
+ @source, @source_map, @imports = @system.read_source(@path)
+ end
+
+ @dependency_nodes = @system.resolve_dependencies_for(self, @imports)
+ end
+
+ def stage_source_update!
+ source, source_map, imports = @system.read_source(@path)
+ return false if source == @source
+
+ @staged_source = source
+ @staged_source_map = source_map
+ @staged_imports = imports
+
+ true
+ end
+
+ def import(path)
+ @system.import(path, @path)
+ end
+
+ def add_asset(asset)
+ @assets.add(asset.filename)
+ @system.add_asset(asset)
+ end
+
+ def exports
+ self::Exports.constants
+ end
+
+ def absolute_path
+ File.join(@system.root, @path)
+ end
+
+ private
+
+ def staged_source_update?
+ defined?(@staged_source) && defined?(@staged_source_map) &&
+ defined?(@staged_imports)
+ end
+
+ def clear_staged_source_update!
+ remove_instance_variable(:@staged_source)
+ remove_instance_variable(:@staged_source_map)
+ remove_instance_variable(:@staged_imports)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/registry.rb b/lib/mayu/modules/registry.rb
new file mode 100644
index 00000000..28092507
--- /dev/null
+++ b/lib/mayu/modules/registry.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "securerandom"
+
+module Mayu
+ module Modules
+ module Registry
+ PREFIX = "Mod_"
+ # DIVISION_SLASH = "\u2215"
+ # ONE_DOT_LEADER = "\u2024"
+ REPLACEMENTS = {
+ # "/" => DIVISION_SLASH,
+ # "." => ONE_DOT_LEADER,
+ }
+
+ def self.[](path)
+ const_name = path_to_const_name(path)
+ const_defined?(const_name) && const_get(const_name)
+ end
+
+ def self.[]=(path, obj)
+ const_name = path_to_const_name(path)
+ const_set(const_name, obj)
+ # puts "\e[33mSetting #{name}::#{const_name} = #{obj.inspect}\e[0m"
+ obj
+ end
+
+ def self.delete(path)
+ const_name = path_to_const_name(path)
+ const_defined?(const_name) && remove_const(path_to_const_name(path))
+ end
+
+ def self.path_to_const_name(path)
+ PREFIX +
+ path
+ .to_s
+ .gsub(/[^[a-zA-Z0-9]]/) do |char|
+ REPLACEMENTS.fetch(char) { "_#{_1.ord}_" }
+ end
+ end
+
+ def self.modules
+ constants.filter { _1.start_with?(PREFIX) }.map { const_get(_1) }
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/resolver.rb b/lib/mayu/modules/resolver.rb
new file mode 100644
index 00000000..059defe5
--- /dev/null
+++ b/lib/mayu/modules/resolver.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ class Resolver
+ class ResolveError < StandardError
+ end
+
+ attr_reader :root
+
+ def initialize(root, extensions: [], verbose: false)
+ @root = root
+ @extensions = extensions
+ @verbose = verbose
+ @resolved_paths = {}
+ end
+
+ def resolve(path, source_dir = "/")
+ relative_to_root = File.absolute_path(path, source_dir)
+
+ @resolved_paths.fetch(relative_to_root) do
+ absolute_path = File.join(@root, relative_to_root)
+
+ resolve_with_extensions(absolute_path) do |extension|
+ return(
+ @resolved_paths.store(
+ relative_to_root,
+ relative_to_root + extension
+ )
+ )
+ end
+
+ if File.directory?(absolute_path)
+ basename = File.basename(absolute_path)
+
+ resolve_with_extensions(
+ File.join(absolute_path, basename)
+ ) do |extension|
+ return(
+ @resolved_paths.store(
+ relative_to_root,
+ File.join(relative_to_root, basename) + extension
+ )
+ )
+ end
+ end
+
+ raise ResolveError,
+ "Could not resolve #{path} from #{source_dir} (app root: #{@root})"
+ end
+ end
+
+ private
+
+ def resolve_with_extensions(absolute_path, &block)
+ @extensions.find do |extension|
+ absolute_path_with_extension = absolute_path + extension
+
+ if File.file?(absolute_path_with_extension)
+ if @verbose
+ $stderr.puts "\e[1mFound #{absolute_path_with_extension}\e[0m"
+ end
+ yield extension
+ else
+ if @verbose
+ $stderr.puts "\e[2mTried #{absolute_path_with_extension}\e[0m"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/rules.rb b/lib/mayu/modules/rules.rb
new file mode 100644
index 00000000..f284e1f6
--- /dev/null
+++ b/lib/mayu/modules/rules.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Modules
+ module Rules
+ Rule =
+ Data.define(:test, :use, :options) do
+ def self.[](test, use, **options) = new(test, use, options)
+ def match?(path) = test.match?(path)
+ def call(loading_file) = use.call(loading_file)
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/source_map.rb b/lib/mayu/modules/source_map.rb
new file mode 100644
index 00000000..e89ea48a
--- /dev/null
+++ b/lib/mayu/modules/source_map.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "base64"
+
+module Mayu
+ module Modules
+ module SourceMap
+ Mark =
+ Data.define(:line, :text) do
+ def to_s
+ "SourceMapMark:#{line}:#{Base64.urlsafe_encode64(text)}"
+ end
+
+ def to_comment(location: SyntaxTree::Location.default)
+ SyntaxTree::Comment.new(value: "# #{to_s}", inline: true, location:)
+ end
+ end
+
+ Position = Data.define(:line, :column)
+
+ MatchingLine =
+ Data.define(:line, :old_line, :new_line, :text) do
+ def self.match(new_line, line)
+ if line.match(/\A\s+# SourceMapMark:(\d+):([[:alnum:]_]+)/) in [
+ line_no,
+ text
+ ]
+ new(line, line_no.to_i, new_line, Base64.urlsafe_decode64(text))
+ end
+ end
+ end
+
+ SourceMap =
+ Data.define(:input, :output, :positions) do
+ def self.parse(input, output)
+ input_lines = input.each_line.to_a
+
+ positions =
+ output
+ .each_line
+ .with_index(1)
+ .each_with_object({}) do |(line, i), acc|
+ if curr = MatchingLine.match(i, line)
+ line_no = curr.old_line
+ column =
+ input_lines[line_no.pred].to_s.index(curr.text) || 0
+ acc[curr.new_line + 1] = Position[line_no, column]
+ end
+ end
+
+ new(input, output, positions)
+ end
+
+ def find_original_line_no(line_no)
+ positions.select { |k, _| k <= line_no }.max_by(&:first)&.last&.line
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/source_map.test.rb b/lib/mayu/modules/source_map.test.rb
new file mode 100755
index 00000000..eb554711
--- /dev/null
+++ b/lib/mayu/modules/source_map.test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+
+require_relative "source_map"
+
+class Mayu::Modules::SourceMap::Test < Minitest::Test
+ SourceMap = Mayu::Modules::SourceMap
+
+ def test_parse
+ source_map = SourceMap::SourceMap.parse(<<~INPUT, <<~OUTPUT)
+ :ruby
+ def hello
+ raise "asd"
+ end
+ %div
+ %p= hello
+ INPUT
+ class MyComponent
+ # #{SourceMap::Mark[2, "def hello"]}
+ def hello
+ # #{SourceMap::Mark[3, 'raise "asd"']}
+ raise "asd"
+ end
+ def render
+ H[:div,
+ H[:p
+ # #{SourceMap::Mark[6, "hello"]}
+ hello
+ ]
+ ]
+ end
+ end
+ OUTPUT
+
+ assert_equal(3, source_map.find_original_line_no(5))
+ assert_equal(6, source_map.find_original_line_no(11))
+ end
+end
diff --git a/lib/mayu/modules/system.rb b/lib/mayu/modules/system.rb
new file mode 100644
index 00000000..a5606523
--- /dev/null
+++ b/lib/mayu/modules/system.rb
@@ -0,0 +1,409 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "pathname"
+require "tsort"
+
+require_relative "mod"
+require_relative "rules"
+require_relative "resolver"
+require_relative "registry"
+require_relative "loaders"
+require_relative "import"
+require_relative "dependency"
+require_relative "import_rewriter"
+require_relative "backtrace_rewriter"
+require_relative "../assets"
+
+module Mayu
+ module Modules
+ class System
+ CURRENT_KEY = :modules_system
+ ReloadFailure =
+ Data.define(
+ :file,
+ :type,
+ :message,
+ :backtrace,
+ :source,
+ :line,
+ :column
+ ) { def success? = false }
+ ReloadResult =
+ Data.define(:changed_paths, :removed_paths, :errors) do
+ def success? = errors.empty?
+ end
+
+ def self.current
+ Thread.current.thread_variable_get(CURRENT_KEY)
+ end
+
+ def self.use(root, **, &)
+ new(root, **).use(&)
+ end
+
+ def self.import(path, source) = current.import(path, source)
+
+ def self.add_asset(generator) = current.add_asset(generator)
+
+ def self.import?(path, source)
+ import(path, source)
+ rescue StandardError
+ nil
+ end
+
+ attr_reader :root
+
+ def initialize(root, rules: [], extensions: ["", ".rb"])
+ @root = File.expand_path(root)
+ @resolver = Resolver.new(@root, extensions:)
+ @rules = rules
+ @assets = Mayu::Assets::Storage.new
+ @on_reload = Async::Notification.new
+ @mods = {}
+ end
+
+ def use(&)
+ if self.class.current
+ raise "There is already an active #{self.class.name}"
+ end
+
+ begin
+ Thread.current.thread_variable_set(CURRENT_KEY, self)
+ yield self
+ ensure
+ Thread.current.thread_variable_set(CURRENT_KEY, nil)
+ end
+ end
+
+ def wait_for_reload
+ @on_reload.wait
+ end
+
+ def marshal_dump
+ [@root, @resolver, @assets, @mods]
+ end
+
+ def marshal_load(a)
+ use do
+ @root, @resolver, @assets, @mods = a
+ @rules = []
+ @on_reload = Async::Notification.new
+
+ @mods
+ .each_value { _1.instance_variable_set(:@system, self) }
+ .each_value { _1::Exports }
+ end
+ end
+
+ def read_source(path)
+ file = Loaders::LoadingFile[@root, path, nil].load_source
+ input = file.source
+
+ matching_rules = @rules.select { _1.match?(path) }
+
+ raise "No rules for file: #{path}" if matching_rules.empty?
+
+ transformed =
+ matching_rules.reduce(file) { |file, rule| rule.call(file) }.source
+ rewritten =
+ ImportRewriter.new(resolver: @resolver, source_path: path).call(
+ transformed
+ )
+ # .tap do
+ # puts "\e[3m#{path}\e[0m\e[35m\n#{_1}\e[0m"
+ # end
+
+ source_map = SourceMap::SourceMap.parse(input, rewritten.source)
+ [rewritten.source, source_map, rewritten.imports]
+ end
+
+ def relative_from_root(path)
+ Pathname.new(path).relative_path_from(@root).to_s.sub(%r{\A/*}, "/")
+ end
+
+ def register(path, mod)
+ Registry[path] = mod
+ @mods[path] = mod
+
+ @mods.each_value do |other_mod|
+ next if other_mod.equal?(mod)
+ next unless other_mod.dependencies.include?(path)
+
+ mod.dependants.add(other_mod.path)
+ end
+ end
+
+ def unregister(path)
+ @mods.each do |path, mod|
+ mod.dependants.delete(path)
+ mod.dependencies.delete(path)
+ end
+
+ Registry.delete(path)
+ @mods.delete(path)
+ end
+
+ def handle_watch_events(events)
+ dirty_paths = Set.new
+ removed_paths = Set.new
+ reload_failures = []
+
+ events.each do |event|
+ # puts event
+
+ case event
+ in Watcher::Events::Created[path:]
+ in Watcher::Events::Updated[path:]
+ next unless mod = @mods[path]
+
+ changed =
+ begin
+ mod.stage_source_update!
+ rescue => e
+ reload_failures << build_reload_failure(path, e)
+ false
+ end
+
+ next unless changed
+
+ dirty_paths.add(mod.path)
+ visit_dependants(mod) { dirty_paths.add(_1.path) }
+ in Watcher::Events::Deleted[path:]
+ if mod = @mods.delete(path)
+ removed_paths.add(path)
+ visit_dependants(mod) { dirty_paths.add(_1.path) }
+ unregister(path)
+ end
+ end
+ end
+
+ unless reload_failures.empty?
+ reload_failures.each { log_reload_error(_1) }
+ @on_reload.signal(
+ ReloadResult[[], removed_paths.to_a, reload_failures]
+ )
+ return
+ end
+
+ return if dirty_paths.empty?
+
+ modules_to_reload =
+ overall_order
+ .select { dirty_paths.include?(_1) }
+ .reverse
+ .map { @mods[_1] }
+ .compact
+
+ messages = []
+
+ unless modules_to_reload.empty?
+ messages.push("\e[1;33mReloading modules:\e[0m", *modules_to_reload)
+ end
+
+ unless removed_paths.empty?
+ messages.push("\e[1;31mRemoved modules:\e[0m", *removed_paths)
+ end
+
+ Console.logger.info(self, *messages) unless messages.empty?
+
+ modules_to_reload.each(&:reload)
+
+ update_overall_order
+
+ @on_reload.signal(
+ ReloadResult[modules_to_reload.map(&:path), removed_paths.to_a, []]
+ )
+ end
+
+ def import(path, source = "/")
+ # puts "\e[35mimport(#{path.inspect}, #{source.inspect})\e[0m"
+
+ source_mod = @mods[source]
+
+ if source_mod && import_hash?(path)
+ path =
+ source_mod
+ .dependency_nodes
+ .find { _1.import_hash == path }
+ &.resolved_path ||
+ source_mod
+ .imports
+ .fetch(path) do
+ raise Resolver::ResolveError,
+ "Could not resolve import hash #{path.inspect} from #{source.inspect}"
+ end
+ end
+
+ mod = get_or_load_mod(path, File.dirname(source))
+
+ if source_mod
+ mod.dependants.add(source_mod.path)
+ source_mod.dependencies.add(mod.path)
+ end
+
+ mod::Exports::Default
+ end
+
+ def delete(path)
+ # TODO: Implement me
+ @mods[source]
+ end
+
+ def add_asset(asset)
+ @assets.enqueue(asset)
+ end
+
+ def overall_order
+ TSort.tsort(
+ ->(&b) { @mods.keys.each(&b) },
+ ->(key, &b) { @mods[key]&.dependants&.each(&b) }
+ )
+ end
+
+ def update_overall_order
+ overall_order
+ .map { @mods[_1] }
+ .compact
+ .each_with_index { |mod, index| mod.order = index }
+ end
+
+ def delete_mod(id)
+ return unless @mods.include?(id)
+
+ @mods.each do |mod|
+ mod.dependencies.delete(id)
+ mod.dependants.delete(id)
+ end
+ end
+
+ def get_mod(path, source = "/")
+ @mods[@resolver.resolve(path, source)]
+ end
+
+ def get_asset(filename)
+ @assets.get(filename)
+ end
+
+ def wait_for_asset(filename)
+ @assets.wait_for(filename)
+ end
+
+ def generate_assets(asset_dir, **opts)
+ @assets.run(asset_dir, **opts)
+ end
+
+ def format_exception(e)
+ BacktraceRewriter.new(@mods).format_exception(e)
+ end
+
+ def rewrite_backtrace(backtrace)
+ BacktraceRewriter.new(@mods).rewrite(backtrace)
+ end
+
+ def format_reload_error(reload_failure)
+ if reload_failure.line && reload_failure.column
+ "Failed to reload #{reload_failure.file} (#{reload_failure.type}): #{reload_failure.message} at #{reload_failure.file}:#{reload_failure.line}:#{reload_failure.column}"
+ elsif reload_failure.line
+ "Failed to reload #{reload_failure.file} (#{reload_failure.type}): #{reload_failure.message} at #{reload_failure.file}:#{reload_failure.line}"
+ else
+ "Failed to reload #{reload_failure.file} (#{reload_failure.type}): #{reload_failure.message}"
+ end
+ end
+
+ def log_reload_error(reload_failure)
+ message = format_reload_error(reload_failure)
+
+ defined?(Console) ? Console.logger.error(self, message) : warn(message)
+ end
+
+ def resolve_dependencies_for(mod, imports)
+ mod.dependency_nodes.each do |dependency|
+ mod.dependencies.delete(dependency.resolved_path)
+ @mods[dependency.resolved_path]&.dependants&.delete(mod.path)
+ end
+
+ dependency_nodes =
+ imports
+ .map do |import_hash, resolved_path|
+ Dependency.new(mod.path, import_hash, resolved_path)
+ end
+ .to_set
+
+ dependency_nodes.each do |dependency|
+ mod.dependencies.add(dependency.resolved_path)
+ @mods[dependency.resolved_path]&.dependants&.add(mod.path)
+ end
+
+ dependency_nodes
+ end
+
+ private
+
+ def build_reload_failure(path, error)
+ line = error.respond_to?(:lineno) ? error.lineno : nil
+ column = error.respond_to?(:column) ? error.column : nil
+ source = read_raw_source(path)
+ backtrace = normalize_reload_backtrace(error.backtrace)
+
+ if line
+ location = [path, line, column].compact.join(":")
+ unless backtrace.any? { _1.start_with?("#{path}:#{line}") }
+ backtrace.unshift(location)
+ end
+ end
+
+ ReloadFailure[
+ path,
+ error.class.name,
+ error.message,
+ backtrace,
+ source,
+ line,
+ column
+ ]
+ end
+
+ def read_raw_source(path)
+ relative_path = path.sub(%r{\A/+}, "")
+ File.read(File.join(@root, relative_path))
+ rescue StandardError
+ ""
+ end
+
+ def normalize_reload_backtrace(backtrace)
+ rewrite_backtrace(Array(backtrace)).map(&:to_s)
+ rescue => e
+ Array(backtrace).map(&:to_s) + [e.message]
+ end
+
+ def import_hash?(path)
+ path.is_a?(String) && path.start_with?(ImportRewriter::PREFIX)
+ end
+
+ def get_or_load_mod(path, source = "/")
+ resolved_path = @resolver.resolve(path, source)
+
+ if mod = @mods[resolved_path]
+ mod
+ else
+ mod = Mod.new(self, resolved_path)
+ @mods[resolved_path] = mod
+ mod.reload
+ mod
+ end
+ end
+
+ def visit_dependants(mod, &block)
+ mod.dependants.each do |dependency|
+ if mod = @mods[dependency]
+ yield mod
+ visit_dependants(mod, &block)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/modules/system.test.rb b/lib/mayu/modules/system.test.rb
new file mode 100644
index 00000000..6dc144f8
--- /dev/null
+++ b/lib/mayu/modules/system.test.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+require "tmpdir"
+require "async"
+
+require_relative "source_map"
+require_relative "system"
+require_relative "../watcher"
+
+class Mayu::Modules::System::Test < Minitest::Test
+ ImportRewriter = Mayu::Modules::ImportRewriter
+ Rules = Mayu::Modules::Rules
+ Loaders = Mayu::Modules::Loaders
+ System = Mayu::Modules::System
+
+ def test_rewrites_imports_to_hashes_and_keeps_dependency_graph
+ Dir.mktmpdir("mayu-system-test") do |root|
+ File.write(File.join(root, "a.rb"), <<~RUBY)
+ Dep = import "./b"
+ RUBY
+ File.write(File.join(root, "b.rb"), "")
+
+ system =
+ System.new(
+ root,
+ rules: [Rules::Rule[/\.rb$/, Loaders::Ruby[]]],
+ extensions: ["", ".rb"]
+ )
+
+ system.use do
+ default_export = system.import("/a.rb")
+ mod_a = system.get_mod("/a.rb")
+ mod_b = system.get_mod("/b.rb")
+ import_hash = ImportRewriter.hash_for("/b.rb")
+ source = mod_a.instance_variable_get(:@source)
+
+ assert_equal({ import_hash => "/b.rb" }, mod_a.imports)
+ assert_equal(
+ Set[Mayu::Modules::Dependency["/a.rb", import_hash, "/b.rb"]],
+ mod_a.dependency_nodes
+ )
+ assert_equal(mod_b::Exports::Default, default_export::Dep)
+ assert_includes(source, %(Dep = import("#{import_hash}")))
+ refute_includes(source, %(Dep = import "./b"))
+ assert_includes(mod_a.dependencies, "/b.rb")
+ assert_includes(mod_b.dependants, "/a.rb")
+ end
+ end
+ end
+
+ def test_watch_update_does_not_reload_when_transformed_source_is_unchanged
+ Dir.mktmpdir("mayu-system-test") do |root|
+ source = <<~RUBY
+ Dep = import "./d"
+ RUBY
+
+ File.write(File.join(root, "c.rb"), source)
+ File.write(File.join(root, "d.rb"), "")
+
+ system =
+ System.new(
+ root,
+ rules: [Rules::Rule[/\.rb$/, Loaders::Ruby[]]],
+ extensions: ["", ".rb"]
+ )
+
+ system.use do
+ system.import("/c.rb")
+ mod_c = system.get_mod("/c.rb")
+ exports_before = mod_c::Exports.object_id
+
+ File.write(File.join(root, "c.rb"), source)
+ system.handle_watch_events([Mayu::Watcher::Events::Updated["/c.rb"]])
+
+ assert_equal(exports_before, mod_c::Exports.object_id)
+ end
+ end
+ end
+
+ def test_watch_update_with_syntax_error_keeps_previous_exports
+ Dir.mktmpdir("mayu-system-test") do |root|
+ File.write(File.join(root, "c.rb"), %(Dep = import "./d"\n))
+ File.write(File.join(root, "d.rb"), "")
+
+ system =
+ System.new(
+ root,
+ rules: [Rules::Rule[/\.rb$/, Loaders::Ruby[]]],
+ extensions: ["", ".rb"]
+ )
+
+ system.use do
+ system.import("/c.rb")
+ mod_c = system.get_mod("/c.rb")
+ exports_before = mod_c::Exports.object_id
+
+ File.write(File.join(root, "c.rb"), "Dep = import(\n")
+ system.handle_watch_events([Mayu::Watcher::Events::Updated["/c.rb"]])
+
+ assert_equal(exports_before, mod_c::Exports.object_id)
+ end
+ end
+ end
+
+ def test_watch_update_with_syntax_error_signals_reload_failure
+ Dir.mktmpdir("mayu-system-test") do |root|
+ File.write(File.join(root, "c.rb"), %(Dep = import "./d"\n))
+ File.write(File.join(root, "d.rb"), "")
+
+ system =
+ System.new(
+ root,
+ rules: [Rules::Rule[/\.rb$/, Loaders::Ruby[]]],
+ extensions: ["", ".rb"]
+ )
+
+ system.use do
+ system.import("/c.rb")
+ File.write(File.join(root, "c.rb"), "Dep = import(\n")
+
+ reload_result =
+ Async do |task|
+ waiter = task.async { system.wait_for_reload }
+ system.handle_watch_events(
+ [Mayu::Watcher::Events::Updated["/c.rb"]]
+ )
+ Async::Task.current.with_timeout(0.5) { waiter.wait }
+ end.wait
+
+ refute_nil(reload_result)
+ refute(reload_result.success?)
+ assert_equal([], reload_result.changed_paths)
+ assert_equal(1, reload_result.errors.length)
+
+ failure = reload_result.errors.first
+
+ assert_equal("/c.rb", failure.file)
+ assert_match(/ParseError/, failure.type)
+ assert_equal(1, failure.line)
+ assert_includes(failure.source, "Dep = import(")
+ assert(failure.backtrace.any? { _1.start_with?("/c.rb:1") })
+ end
+ end
+ end
+end
diff --git a/lib/mayu/ref_counter.rb b/lib/mayu/ref_counter.rb
deleted file mode 100644
index d1b60dbc..00000000
--- a/lib/mayu/ref_counter.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# typed: strict
-
-module Mayu
- class RefCounter
- extend T::Sig
- extend T::Generic
-
- Elem = type_member
-
- sig { void }
- def initialize
- @refs = T.let(Hash.new { |h, k| h[k] = 0 }, T::Hash[Elem, Integer])
- end
-
- sig { params(key: Elem).returns(Integer) }
- def count(key)
- @refs.fetch(key, 0)
- end
-
- sig { returns(T::Array[Elem]) }
- def keys
- @refs.sort_by { _2 }.map(&:first)
- end
-
- sig { params(key: Elem).void }
- def acquire!(key)
- @refs[key] = @refs[key].to_i + 1
- end
-
- sig do
- type_parameters(:R)
- .params(key: Elem, block: T.proc.returns(T.type_parameter(:R)))
- .returns(T.type_parameter(:R))
- end
- def acquire(key, &block)
- acquire!(key)
-
- begin
- yield
- ensure
- release(key)
- end
- end
-
- sig { params(key: Elem).void }
- def release(key)
- count = @refs.fetch(key, nil)
- return unless count
-
- if count > 1
- @refs[key] = count - 1
- else
- @refs.delete(key)
- end
- end
- end
-end
diff --git a/lib/mayu/resources/README.md b/lib/mayu/resources/README.md
deleted file mode 100644
index 5db1b5fa..00000000
--- a/lib/mayu/resources/README.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Mayu::Resources
-
-This module contains classes for loading resources.
-
-A resource is any type of file that can be used in an app,
-usually a component, stylesheet or image.
-
-In development mode, it is possible to watch some directories
-for changes and reload resources dynamically as they are updated.
-
-For production, all the resources will be serialized using
-[Marshal](https://docs.ruby-lang.org/en/master/Marshal.html),
-and static fileswill be generated during build time, and then
-loaded in runtime.
diff --git a/lib/mayu/resources/asset.rb b/lib/mayu/resources/asset.rb
deleted file mode 100644
index 765828b8..00000000
--- a/lib/mayu/resources/asset.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "generators/image"
-require_relative "generators/copy_file"
-require_relative "generators/write_file"
-
-module Mayu
- module Resources
- class Asset
- extend T::Sig
-
- class Status < T::Enum
- enums do
- Pending = new
- Processing = new
- Done = new
- Failed = new
- end
- end
-
- sig { returns(String) }
- attr_reader :filename
- sig { returns(Status) }
- attr_reader :status
- sig { returns(Generators::Base) }
- attr_reader :generator
-
- sig { params(filename: String, generator: Generators::Base).void }
- def initialize(filename, generator)
- @filename = filename
- @generator = generator
- @status = T.let(Status::Pending, Status)
- end
-
- sig { returns(T::Boolean) }
- def pending? = @status == Status::Pending
- sig { returns(T::Boolean) }
- def processing? = @status == Status::Processing
- sig { returns(T::Boolean) }
- def done? = @status == Status::Done
- sig { returns(T::Boolean) }
- def failed? = @status == Status::Failed
-
- sig { params(asset_dir: String).returns(T::Boolean) }
- def process(asset_dir)
- return false unless pending?
- @status = Status::Processing
- @generator.process(File.join(asset_dir, filename))
- rescue StandardError
- @status = Status::Failed
- raise
- else
- @status = Status::Done
- true
- end
-
- MarshalFormat = T.type_alias { [String] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@filename]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @filename = args.first
- end
- end
- end
-end
diff --git a/lib/mayu/resources/assets.rb b/lib/mayu/resources/assets.rb
deleted file mode 100644
index 05dba73a..00000000
--- a/lib/mayu/resources/assets.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "async/variable"
-require "async/queue"
-require "async/semaphore"
-
-module Mayu
- module Resources
- class Assets
- extend T::Sig
-
- sig { void }
- def initialize
- @queue = T.let(Async::Queue.new, Async::Queue)
- @results = T.let({}, T::Hash[String, Async::Variable])
- @assets = T.let({}, T::Hash[String, Asset])
- end
-
- sig { params(filename: String).void }
- def wait_for(filename)
- (@results[filename] ||= Async::Variable.new).wait
- end
-
- sig { params(asset: Asset).void }
- def add(asset)
- @assets[asset.filename] ||= asset
- @queue.enqueue(asset)
- end
-
- sig do
- params(
- asset_dir: String,
- concurrency: Integer,
- forever: T::Boolean,
- task: Async::Task
- ).returns(Async::Task)
- end
- def run(
- asset_dir,
- concurrency:,
- forever: false,
- task: Async::Task.current
- )
- task.async do
- semaphore = Async::Semaphore.new(concurrency)
-
- while forever || !@queue.empty?
- process(@queue.dequeue, asset_dir, semaphore)
- end
- end
- end
-
- private
-
- sig do
- params(
- asset: Asset,
- asset_dir: String,
- semaphore: Async::Semaphore
- ).void
- end
- def process(asset, asset_dir, semaphore)
- semaphore.async do
- if asset.process(asset_dir)
- var = (@results[asset.filename] ||= Async::Variable.new)
- var.resolve unless var.resolved?
- end
- rescue => e
- Console.logger.error(self, e)
- raise
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/dependency_graph.rb b/lib/mayu/resources/dependency_graph.rb
deleted file mode 100644
index 24e8b557..00000000
--- a/lib/mayu/resources/dependency_graph.rb
+++ /dev/null
@@ -1,306 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "tsort"
-require "set"
-require "cgi"
-require_relative "mermaid_exporter"
-require_relative "dot_exporter"
-
-module Mayu
- module Resources
- class DependencyGraph
- extend T::Sig
- # This is basically a reimplementation of this library:
- # https://github.com/jriecken/dependency-graph
-
- class Direction < T::Enum
- enums do
- Incoming = new(:incoming)
- Outgoing = new(:outgoing)
- end
- end
-
- class Node
- extend T::Sig
-
- sig { returns(Resource) }
- attr_reader :resource
- sig { returns(T::Set[String]) }
- attr_reader :incoming
- sig { returns(T::Set[String]) }
- attr_reader :outgoing
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- @resource = resource
- @incoming = T.let(Set.new, T::Set[String])
- @outgoing = T.let(Set.new, T::Set[String])
- end
-
- sig { params(id: String).void }
- def delete(id)
- @incoming.delete(id)
- @outgoing.delete(id)
- end
-
- MarshalFormat =
- T.type_alias { [Resource, T::Set[String], T::Set[String]] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@resource, @incoming, @outgoing]
- end
-
- sig { params(dumped: MarshalFormat).void }
- def marshal_load(dumped)
- @resource, @incoming, @outgoing = dumped
- end
- end
-
- sig { void }
- def initialize
- @nodes = T.let({}, T::Hash[String, Node])
- end
-
- sig { returns(Integer) }
- def size = @nodes.size
-
- sig { params(id: String).returns(T::Boolean) }
- def include?(id) = @nodes.include?(id)
-
- sig { params(id: String, resource: Resource).returns(Resource) }
- def add_node(id, resource)
- (@nodes[id] ||= Node.new(resource)).resource
- end
-
- sig { params(id: String).void }
- def delete_node(id)
- return unless @nodes.include?(id)
- @nodes.delete(id)
- delete_connections(id)
- end
-
- sig { params(id: String).void }
- def delete_connections(id)
- @nodes.each { |node| node.delete(id) }
- end
-
- sig { params(id: String).returns(T.nilable(Resource)) }
- def get_resource(id)
- @nodes[id]&.resource
- end
-
- sig { params(id: String).returns(T::Boolean) }
- def has_node?(id)
- @nodes.include?(id)
- end
-
- sig { params(source_id: String, target_id: String).void }
- def add_dependency(source_id, target_id)
- with_source_and_target(source_id, target_id) do |source, target|
- source.outgoing.add(target_id)
- target.incoming.add(source_id)
- end
- end
-
- sig { params(source_id: String, target_id: String).void }
- def remove_dependency(source_id, target_id)
- with_source_and_target(source_id, target_id) do |source, target|
- source.outgoing.delete(target_id)
- source.incoming.delete(source_id)
- end
- end
-
- sig { params(id: String).returns(T::Array[String]) }
- def direct_dependencies_of(id)
- @nodes.fetch(id).outgoing.to_a
- end
-
- sig { params(id: String).returns(T::Array[String]) }
- def direct_dependants_of(id)
- @nodes.fetch(id).incoming.to_a
- end
-
- sig do
- params(
- id: String,
- started_at: T.nilable(String),
- only_leaves: T::Boolean,
- block: T.nilable(T.proc.params(arg0: String).returns(T::Boolean))
- ).returns(T::Set[String])
- end
- def dependencies_of(id, started_at = nil, only_leaves: false, &block)
- raise "Circular" if id == started_at
-
- @nodes
- .fetch(id)
- .outgoing
- .map do |dependency|
- next nil unless yield dependency if block_given?
-
- dependencies = dependencies_of(dependency, started_at || id)
-
- if !only_leaves || dependencies.empty?
- dependencies.add(dependency)
- else
- dependencies
- end
- end
- .compact
- .reduce(Set.new, &:merge)
- end
-
- sig do
- params(
- id: String,
- started_at: T.nilable(String),
- only_leaves: T::Boolean
- ).returns(T::Set[String])
- end
- def dependants_of(id, started_at = nil, only_leaves: false)
- raise "Circular" if id == started_at
-
- @nodes
- .fetch(id)
- .incoming
- .map do |dependant|
- dependants = dependants_of(dependant, started_at || id)
- if !only_leaves || dependants.empty?
- dependants.add(dependant)
- else
- dependants
- end
- end
- .reduce(Set.new, &:merge)
- end
-
- sig { returns(T::Array[String]) }
- def entry_nodes
- @nodes.filter { _2.incoming.empty? }.keys
- end
-
- sig { params(only_leaves: T::Boolean).returns(T::Array[String]) }
- def overall_order(only_leaves: true)
- TSort.tsort(
- ->(&b) { @nodes.keys.each(&b) },
- ->(key, &b) { @nodes[key]&.outgoing&.each(&b) }
- )
- end
-
- sig { returns(String) }
- def to_dot
- DotExporter.new(self).to_source
- end
-
- sig { returns(String) }
- def to_mermaid_source
- MermaidExporter.new(self).to_source
- end
-
- sig { returns(String) }
- def to_mermaid_url
- MermaidExporter.new(self).to_url
- end
-
- sig { returns(T::Array[String]) }
- def paths
- @nodes.keys
- end
-
- sig { params(block: T.proc.params(arg0: Resource).void).void }
- def each_resource(&block)
- @nodes.each_value { |node| yield node.resource }
- end
-
- MarshalFormat = T.type_alias { T::Hash[String, Node] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- @nodes
- end
-
- sig { params(nodes: MarshalFormat).void }
- def marshal_load(nodes)
- @nodes = nodes
- end
-
- sig do
- params(
- id: String,
- direction: Direction,
- visited: T::Set[String],
- block: T.proc.params(arg0: String).void
- ).void
- end
- def dfs2(id, direction, visited: T::Set[String].new, &block)
- if visited.include?(id)
- return
- else
- visited.add(id)
- end
-
- @nodes
- .fetch(id)
- .send(direction.serialize)
- .each { dfs2(_1, direction, visited:, &block) }
-
- yield id
- end
-
- private
-
- sig do
- params(
- source_id: String,
- target_id: String,
- block: T.proc.params(arg0: Node, arg1: Node).void
- ).void
- end
- def with_source_and_target(source_id, target_id, &block)
- yield(fetch_node(:source, source_id), fetch_node(:target, target_id))
- end
-
- sig { params(type: Symbol, id: String).returns(Node) }
- def fetch_node(type, id)
- @nodes.fetch(id) do
- raise ArgumentError,
- "Could not find #{type} #{id.inspect} in #{@nodes.keys.inspect}"
- end
- end
-
- sig do
- params(
- node: Node,
- direction: Direction,
- block: T.proc.params(arg0: Node).void
- ).void
- end
- def dfs(node, direction, &block)
- node
- .send(direction.serialize)
- .each { |id| dfs(@nodes.fetch(id), direction, &block) }
-
- yield node
- end
- end
- end
-end
-
-# if __FILE__ == $0
-# graph = Resources::DependencyGraph.new
-#
-# graph.add_node("a")
-# graph.add_node("b")
-# graph.add_node("c")
-#
-# p graph.size
-#
-# graph.add_dependency("a", "b")
-# graph.add_dependency("b", "c")
-# p graph.dependencies_of("a")
-# p graph.dependencies_of("b")
-# p graph.dependants_of("c")
-# p graph.overall_order
-# p graph.overall_order(only_leaves: true)
-# end
diff --git a/lib/mayu/resources/dot_exporter.rb b/lib/mayu/resources/dot_exporter.rb
deleted file mode 100644
index cf450d7a..00000000
--- a/lib/mayu/resources/dot_exporter.rb
+++ /dev/null
@@ -1,167 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "base64"
-require "zlib"
-require_relative "dependency_graph"
-
-module Mayu
- module Resources
- class DotExporter
- extend T::Sig
-
- sig { params(graph: DependencyGraph).void }
- def initialize(graph)
- @graph = graph
- end
-
- sig { returns(String) }
- def to_source
- StringIO.new.tap { write_source(_1) }.tap(&:rewind).read.to_s
- end
-
- sig { params(out: StringIO).returns(StringIO) }
- def write_source(out)
- entries = @graph.overall_order(only_leaves: false)
- tree = make_tree(entries.map { _1.split("/") })
-
- out.puts <<~EOF
- strict digraph "dependency-cruiser output" {
- ordering="out" rankdir="LR" splines="ortho" overlap="false" nodesep="0.16" ranksep="0.5" fontname="Helvetica-bold" fontsize="9" style="rounded,bold,filled" fillcolor="#ffffff" compound="true"
- node [shape="box" style="rounded, filled" height="0.2" color="black" fillcolor="#ffffcc" fontcolor="black" fontname="Helvetica" fontsize="9"]
- edge [arrowhead="normal" arrowsize="0.6" penwidth="2.0" color="#00000033" fontname="Helvetica" fontsize="9"]
-
- EOF
-
- entries.each do |entry|
- fillcolor = "#bbfeff"
- out.puts " #{entry.inspect} [label=<#{File.dirname(entry)}
#{File.basename(entry)}> tooltip=#{File.basename(entry).inspect} fillcolor=#{fillcolor.inspect}]"
-
- @graph
- .direct_dependencies_of(entry)
- .each do |dep|
- color = "#e5009b99"
- out.puts " #{entry.inspect} -> #{dep.inspect} [color=#{color.inspect}]"
- end
- end
-
- out.puts "}"
- out
- end
-
- private
-
- sig { params(path: String).returns(T.nilable(String)) }
- def filetype_class(path)
- case File.extname(path)
- when ".rb"
- "Ruby"
- when ".css"
- "CSS"
- when ".png"
- "Image"
- end
- end
-
- sig { params(str: String).returns(String) }
- def encode(str)
- str.gsub("/", "__").gsub("[", "__").gsub("]", "__")
- end
-
- sig { params(str: String).returns(String) }
- def escape(str)
- str.gsub(/\W/) { |ch| ch.codepoints.map { |cp| "##{cp};" }.join }
- end
-
- sig { params(str: String).returns(String) }
- def display_name(str)
- case File.extname(str)
- when ".rb"
- "fa:fa-gem #{str} "
- when ".css"
- "fab:fa-css3 #{str} "
- when ".png"
- "fa:fa-image #{str} "
- else
- str
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_routes(out, node, path = [])
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- if key == "page.rb"
- pathstr = path.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts " ROUTE__#{encode(value)}[#{pathstr.inspect}]"
- end
- else
- print_routes(out, value, path2)
- end
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_route_edges(out, node, path = [])
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- if key == "page.rb"
- pathstr = path.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts " ROUTE__#{encode(value)}-->#{encode(value)}"
- end
- else
- print_route_edges(out, value, path2)
- end
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_subgraphs(out, node, path = [])
- level = path.length
- indent = " " * level.succ
-
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- out.puts "#{indent}#{encode(value)}[#{display_name(key).inspect}]"
- else
- pathstr = path2.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts "#{indent}subgraph PATH#{encode(pathstr)}[#{pathstr.inspect}]"
- print_subgraphs(out, value, path2)
- out.puts "#{indent}end"
- end
- end
- end
-
- sig do
- params(entries: T::Array[T::Array[String]], level: Integer).returns(
- T.untyped
- )
- end
- def make_tree(entries, level = 0)
- entries
- .group_by { _1[level] }
- .transform_values do |paths|
- paths
- .partition { _1.length.pred <= level.succ }
- .then do |leaves, branches|
- leaves.each_with_object(
- make_tree(branches, level + 1)
- ) { |leaf, obj| obj[leaf.last] = leaf.join("/") }
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/generators/base.rb b/lib/mayu/resources/generators/base.rb
deleted file mode 100644
index 2645b378..00000000
--- a/lib/mayu/resources/generators/base.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-module Mayu
- module Resources
- module Generators
- class Base
- extend T::Sig
- extend T::Helpers
- abstract!
-
- sig { abstract.params(target_path: String).void }
- def process(target_path)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/generators/copy_file.rb b/lib/mayu/resources/generators/copy_file.rb
deleted file mode 100644
index 31be193d..00000000
--- a/lib/mayu/resources/generators/copy_file.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "fileutils"
-require_relative "base"
-
-module Mayu
- module Resources
- module Generators
- class CopyFile < Base
- extend T::Sig
-
- sig { params(source_path: String).void }
- def initialize(source_path)
- @source_path = source_path
- end
-
- sig { override.params(target_path: String).void }
- def process(target_path)
- return if File.exist?(target_path)
- FileUtils.copy_file(@source_path, target_path)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/generators/image.rb b/lib/mayu/resources/generators/image.rb
deleted file mode 100644
index 0f626111..00000000
--- a/lib/mayu/resources/generators/image.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "image_size"
-require "base64"
-require "shellwords"
-require_relative "base"
-
-module Mayu
- module Resources
- module Generators
- class Image < Base
- extend T::Sig
-
- sig do
- params(
- source_path: String,
- version: Types::Image::ImageDescriptor
- ).void
- end
- def initialize(source_path, version)
- @source_path = source_path
- @version = version
- end
-
- sig { override.params(target_path: String).void }
- def process(target_path)
- return if File.exist?(target_path)
-
- require "rmagick"
-
- Console.logger.info(
- self,
- "Generating #{target_path} from #{@source_path}"
- )
-
- Magick::Image
- .read(@source_path)
- .first
- .resize_to_fit(@version.width)
- .write(target_path) { |options| options.quality = 80 }
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/generators/write_file.rb b/lib/mayu/resources/generators/write_file.rb
deleted file mode 100644
index f07b421b..00000000
--- a/lib/mayu/resources/generators/write_file.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "fileutils"
-require_relative "base"
-
-module Mayu
- module Resources
- module Generators
- class WriteFile < Base
- extend T::Sig
-
- sig { params(contents: String, compress: T::Boolean).void }
- def initialize(contents:, compress:)
- @contents = contents
- @compress = compress
- end
-
- sig { override.params(target_path: String).void }
- def process(target_path)
- write_file(target_path, @contents)
-
- if @compress
- write_file(target_path + ".br", Brotli.deflate(@contents))
- end
- end
-
- private
-
- sig { params(path: String, content: String).void }
- def write_file(path, content)
- return if File.exist?(path)
- Console.logger.info(self, "Writing #{path}")
- File.write(path, content)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/hot_swap.rb b/lib/mayu/resources/hot_swap.rb
deleted file mode 100644
index 4ee7604b..00000000
--- a/lib/mayu/resources/hot_swap.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "hot_swap/file_watcher"
-require_relative "registry"
-
-module Mayu
- module Resources
- module HotSwap
- extend T::Sig
-
- sig do
- params(registry: Registry, block: T.proc.void).returns(Async::Task)
- end
- def self.start(registry, &block)
- FileWatcher.watch(registry.root, ["app"]) do |event|
- Console.logger.info(self, "\e[33mSwapping code\e[0m")
- start_at = Time.now.to_f
-
- visited = T::Set[String].new
-
- event.modified.each do |path|
- registry.reload_resource(path, visited:)
- end
-
- event.added.each do |path|
- registry.reload_resource(
- path,
- visited:,
- add: path.start_with?("/app/pages/")
- )
- end
-
- event.removed.each { |path| registry.unload_resource(path, visited:) }
-
- Console.logger.info(
- self,
- format("\e[33mSwapped code in %.3fs\e[0m", Time.now.to_f - start_at)
- )
-
- yield
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/hot_swap/file_watcher.rb b/lib/mayu/resources/hot_swap/file_watcher.rb
deleted file mode 100644
index 180775db..00000000
--- a/lib/mayu/resources/hot_swap/file_watcher.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "listen"
-require "async"
-require "async/queue"
-require "async/task"
-require "thread"
-
-module Mayu
- module Resources
- module HotSwap
- module FileWatcher
- extend T::Sig
-
- class Event < T::Struct
- const :modified, T::Array[String]
- const :added, T::Array[String]
- const :removed, T::Array[String]
- end
-
- sig do
- params(
- root: String,
- dirs: T::Array[String],
- task: Async::Task,
- block: T.proc.params(arg0: Event).void
- ).returns(Async::Task)
- end
- def self.watch(
- root = Dir.pwd,
- dirs = [""],
- task: Async::Task.current,
- &block
- )
- root = File.expand_path(root)
- queue = Thread::Queue.new
- paths = dirs.map { File.join(root, _1) }
-
- listener =
- T.let(
- T
- .unsafe(Listen)
- .to(*paths) do |modified, added, removed|
- Event
- .new(
- modified: modified.map { _1.delete_prefix(root) },
- added: added.map { _1.delete_prefix(root) },
- removed: removed.map { _1.delete_prefix(root) }
- )
- .then { queue.enq(_1) }
- end,
- Listen::Listener
- )
-
- listener.start
-
- Console.logger.info("Watching directories for changes:", *paths)
-
- task.async do
- loop { block.call(queue.deq) }
- ensure
- listener.stop
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/mermaid_exporter.rb b/lib/mayu/resources/mermaid_exporter.rb
deleted file mode 100644
index 7120d54e..00000000
--- a/lib/mayu/resources/mermaid_exporter.rb
+++ /dev/null
@@ -1,210 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "base64"
-require "zlib"
-require_relative "dependency_graph"
-
-module Mayu
- module Resources
- class MermaidExporter
- extend T::Sig
-
- GRAPH_DIRECTION = "LR" # Left to right
-
- sig { params(graph: DependencyGraph).void }
- def initialize(graph)
- @graph = graph
- end
-
- sig { returns(String) }
- def to_url
- data = {
- code: to_source,
- mermaid: JSON.generate({ theme: "dark" }),
- updateEditor: false,
- autoSync: false,
- updateDiagram: false,
- editorMode: "code"
- }
-
- pako =
- data
- .then { JSON.generate(_1) }
- .then { Zlib.deflate(_1) }
- .then { Base64.urlsafe_encode64(_1) }
-
- "https://mermaid.live/view#pako:#{pako}"
- end
-
- sig { returns(String) }
- def to_source
- StringIO.new.tap { write_source(_1) }.tap(&:rewind).read.to_s
- end
-
- sig { params(out: StringIO).returns(StringIO) }
- def write_source(out)
- entries =
- @graph
- .overall_order(only_leaves: false)
- .filter { @graph.get_resource(_1)&.exists? }
-
- tree = make_tree(entries.map { _1.split("/") })
- out.puts "graph #{GRAPH_DIRECTION}"
-
- out.puts " subgraph routes"
- print_routes(out, tree.dig("", "app", "pages") || {})
- out.puts " end"
-
- print_route_edges(out, tree.dig("", "app", "pages") || {})
-
- print_subgraphs(out, tree)
-
- entries.each do |entry|
- @graph
- .direct_dependencies_of(entry)
- .filter { @graph.get_resource(_1)&.exists? }
- .each { |dep| out.puts " #{encode(entry)}-->#{encode(dep)}" }
- rescue StandardError
- next
- end
-
- entries.each do |entry|
- unless @graph.get_resource(entry)&.exists?
- out.puts " class #{encode(entry)} NonExistant"
- end
-
- filetype_class(entry)&.tap do
- out.puts " class #{encode(entry)} #{_1}"
- end
- end
-
- out.puts <<~EOF.gsub(/^/, " ")
- style routes stroke:#09c,stroke-width:5,fill:#f0f;
- classDef cluster fill:#0003;
- classDef Ruby fill:#600,stroke:#900,stroke-width:3px;
- classDef Image fill:#069,stroke:#09c,stroke-width:3px;
- classDef CSS fill:#063,stroke:#096,stroke-width:3px;
- classDef NonExistant opacity:50%,stroke-dasharray:5px;
- linkStyle default fill:transparent,opacity:50%;
- EOF
-
- out
- end
-
- private
-
- sig { params(path: String).returns(T.nilable(String)) }
- def filetype_class(path)
- case File.extname(path)
- when ".rb"
- "Ruby"
- when ".css"
- "CSS"
- when ".png"
- "Image"
- end
- end
-
- sig { params(str: String).returns(String) }
- def encode(str)
- str.gsub("/", "__").gsub("[", "__").gsub("]", "__")
- end
-
- sig { params(str: String).returns(String) }
- def escape(str)
- str.gsub(/\W/) { |ch| ch.codepoints.map { |cp| "##{cp};" }.join }
- end
-
- sig { params(str: String).returns(String) }
- def display_name(str)
- case File.extname(str)
- when ".rb"
- "fa:fa-gem #{str} "
- when ".css"
- "fab:fa-css3 #{str} "
- when ".png"
- "fa:fa-image #{str} "
- else
- str
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_routes(out, node, path = [])
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- if key == "page.rb" || key == "page.haml"
- pathstr = path.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts " ROUTE__#{encode(value)}[#{pathstr.inspect}]"
- end
- else
- print_routes(out, value, path2)
- end
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_route_edges(out, node, path = [])
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- if key == "page.rb" || key == "page.haml"
- pathstr = path.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts " ROUTE__#{encode(value)}-->#{encode(value)}"
- end
- else
- print_route_edges(out, value, path2)
- end
- end
- end
-
- sig do
- params(out: StringIO, node: T.untyped, path: T::Array[String]).void
- end
- def print_subgraphs(out, node, path = [])
- level = path.length
- indent = " " * level.succ
-
- node.each do |key, value|
- path2 = path + [key]
-
- if value.is_a?(String)
- out.puts "#{indent}#{encode(value)}[#{display_name(key).inspect}]"
- else
- pathstr = path2.flatten.join("/").sub(%r{\A/?}, "/")
- out.puts "#{indent}subgraph PATH#{encode(pathstr)}[#{pathstr.inspect}]"
- print_subgraphs(out, value, path2)
- out.puts "#{indent}end"
- end
- end
- end
-
- sig do
- params(entries: T::Array[T::Array[String]], level: Integer).returns(
- T.untyped
- )
- end
- def make_tree(entries, level = 0)
- entries
- .group_by { _1[level] }
- .transform_values do |paths|
- paths
- .partition { _1.length.pred <= level.succ }
- .then do |leaves, branches|
- leaves.each_with_object(
- make_tree(branches, level + 1)
- ) { |leaf, obj| obj[leaf.last] = leaf.join("/") }
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/registry.rb b/lib/mayu/resources/registry.rb
deleted file mode 100644
index 499dd35d..00000000
--- a/lib/mayu/resources/registry.rb
+++ /dev/null
@@ -1,190 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "sorbet-runtime"
-require "msgpack"
-require_relative "resolver"
-require_relative "dependency_graph"
-require_relative "resource"
-require_relative "types"
-require_relative "assets"
-
-MessagePack::DefaultFactory.register_type(0x00, Symbol)
-
-module Mayu
- module Resources
- class Registry
- EXTENSIONS = T.let(["", ".rb", ".haml"].freeze, T::Array[String])
-
- extend T::Sig
-
- sig { returns(String) }
- attr_reader :root
- sig { returns(DependencyGraph) }
- attr_reader :dependency_graph
-
- sig { params(root: String).void }
- def initialize(root:)
- @root = T.let(File.expand_path(root), String)
- @dependency_graph = T.let(DependencyGraph.new, DependencyGraph)
- @resolver =
- T.let(
- Resolver::Filesystem.new(@root, extensions: EXTENSIONS),
- Resolver::Base
- )
- @assets = T.let(Assets.new, T.nilable(Assets))
- end
-
- sig { params(dumped: String, root: String).returns(Registry) }
- def self.load(dumped, root:)
- MessagePack.unpack(dumped) => { type: "registry", data: String => data }
- registry =
- T.cast(
- Marshal.load(
- data,
- ->(obj) do
- obj.instance_variable_set(:@root, root) if obj.is_a?(Registry)
- obj
- end
- ),
- Registry
- )
-
- registry
- end
-
- sig { returns(String) }
- def dump
- MessagePack.pack(type: "registry", data: Marshal.dump(self))
- end
-
- MarshalFormat = T.type_alias { [String, T::Hash[String, String]] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [Marshal.dump(@dependency_graph), @resolver.resolved_paths]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- dependency_graph =
- T.cast(
- Marshal.load(
- args[0],
- ->(obj) do
- if obj.is_a?(Resource)
- obj.instance_variable_set(:@registry, self)
- end
- obj
- end
- ),
- DependencyGraph
- )
-
- @dependency_graph = dependency_graph
- @resolver = Resolver::Static.new(args[1])
- end
-
- sig { params(path: String).returns(String) }
- def absolute_path(path)
- File.join(@root, File.expand_path(path, "/"))
- end
-
- sig { returns(String) }
- def mermaid_url
- @dependency_graph.to_mermaid_url
- end
-
- sig { params(filename: String, timeout: Integer, task: Async::Task).void }
- def wait_for_asset(filename, timeout: 2, task: Async::Task.current)
- return unless @assets
-
- task.with_timeout(timeout) { @assets.wait_for(filename) }
- end
-
- sig do
- params(
- asset_dir: String,
- concurrency: Integer,
- forever: T::Boolean
- ).returns(Async::Task)
- end
- def generate_assets(asset_dir, concurrency:, forever:)
- if @assets
- @assets.run(asset_dir, concurrency:, forever:)
- else
- raise "Assets can't be generated in production"
- end
- end
-
- sig do
- params(path: String, visited: T::Set[String], add: T::Boolean).void
- end
- def reload_resource(path, visited: T::Set[String].new, add: false)
- unless @dependency_graph.has_node?(path)
- add_resource(path) if add
- return
- end
-
- reload_resources(
- [path, *@dependency_graph.dependants_of(path)],
- visited:
- )
- end
-
- sig { params(path: String, visited: T::Set[String]).void }
- def unload_resource(path, visited: T::Set[String].new)
- return unless @dependency_graph.has_node?(path)
-
- dependants = @dependency_graph.dependants_of(path)
-
- Console.logger.info(self, "Unloading resource, #{path}")
-
- @dependency_graph.delete_node(path)
-
- reload_resources(dependants, visited:)
- end
-
- sig { params(path: String, source: String).returns(Resource) }
- def load_resource(path, source = "/")
- resolved_path = @resolver.resolve(path, source)
- add_resource(resolved_path)
- end
-
- sig { params(path: String).returns(Resource) }
- def add_resource(path)
- if resource = @dependency_graph.get_resource(path)
- return resource
- end
-
- resource = Resource.new(registry: self, path:)
-
- @dependency_graph.add_node(resource.path, resource)
-
- resource.assets.each { |asset| @assets.add(asset) } if @assets
-
- Console.logger.info(
- self,
- "Loaded #{resource.type.name} from #{resource.path}"
- )
- resource
- end
-
- private
-
- sig { params(paths: T::Enumerable[String], visited: T::Set[String]).void }
- def reload_resources(paths, visited:)
- paths
- .select { visited.add?(_1) }
- .map { @dependency_graph.get_resource(_1) }
- .compact
- .each do |resource|
- Console.logger.info(self, "Reloading resource: #{resource.path}")
- @dependency_graph.delete_connections(resource.path)
- resource.load_type
- resource.assets.each { |asset| @assets.add(asset) } if @assets
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/resolver.rb b/lib/mayu/resources/resolver.rb
deleted file mode 100644
index e1a540ce..00000000
--- a/lib/mayu/resources/resolver.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-module Mayu
- module Resources
- module Resolver
- # https://bugs.ruby-lang.org/issues/15330
- # https://bugs.ruby-lang.org/issues/18841
- autoload :Static, File.join(__dir__, "resolver", "static")
- autoload :Filesystem, File.join(__dir__, "resolver", "filesystem")
- end
- end
-end
diff --git a/lib/mayu/resources/resolver/base.rb b/lib/mayu/resources/resolver/base.rb
deleted file mode 100644
index d5d1a813..00000000
--- a/lib/mayu/resources/resolver/base.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-module Mayu
- module Resources
- module Resolver
- class Base
- class ResolveError < StandardError
- end
-
- extend T::Sig
- extend T::Helpers
- abstract!
-
- sig { returns(T::Hash[String, String]) }
- attr_reader :resolved_paths
-
- sig { void }
- def initialize
- @resolved_paths = T.let({}, T::Hash[String, String])
- end
-
- sig do
- overridable.params(path: String, source_dir: String).returns(String)
- end
- def resolve(path, source_dir = "/")
- raise ResolveError, "Could not resolve #{path} from #{source_dir}"
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/resolver/filesystem.rb b/lib/mayu/resources/resolver/filesystem.rb
deleted file mode 100644
index ddd8df92..00000000
--- a/lib/mayu/resources/resolver/filesystem.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "base"
-
-module Mayu
- module Resources
- module Resolver
- class Filesystem < Base
- sig { params(root: String, extensions: T::Array[String]).void }
- def initialize(root, extensions: [""])
- super()
- @root = root
- @extensions = extensions
- end
-
- sig do
- override.params(path: String, source_dir: String).returns(String)
- end
- def resolve(path, source_dir = "/")
- # TODO: Fix this!!
- # if path.start_with?("/")
- # return resolve(".#{path}", "/")
- # end
- #
- # if !path.match(/\A\.\.?\//)
- # return resolve("./#{path}", "/components")
- # end
-
- relative_to_root = File.absolute_path(path, source_dir)
-
- if found = @resolved_paths[relative_to_root]
- return found
- end
-
- absolute_path = File.join(@root, relative_to_root)
-
- resolve_with_extensions(absolute_path) do |extension|
- return(
- @resolved_paths.store(
- relative_to_root,
- relative_to_root + extension
- )
- )
- end
-
- if File.directory?(absolute_path)
- basename = File.basename(absolute_path)
-
- resolve_with_extensions(
- File.join(absolute_path, basename)
- ) do |extension|
- return(
- @resolved_paths.store(
- relative_to_root,
- File.join(relative_to_root, basename) + extension
- )
- )
- end
- end
-
- raise ResolveError,
- "Could not resolve #{path} from #{source_dir} (app root: #{@root})"
- end
-
- private
-
- sig do
- params(
- absolute_path: String,
- block: T.proc.params(arg0: String).void
- ).void
- end
- def resolve_with_extensions(absolute_path, &block)
- @extensions.find do |extension|
- absolute_path_with_extension = absolute_path + extension
-
- if File.file?(absolute_path_with_extension)
- puts "\e[1mFound #{absolute_path_with_extension}\e[0m"
- yield extension
- else
- puts "\e[2mTried #{absolute_path_with_extension}\e[0m"
- end
- end
- end
-
- sig { params(path: String).returns(T::Boolean) }
- def exist?(path)
- File.exist?(path)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/resolver/static.rb b/lib/mayu/resources/resolver/static.rb
deleted file mode 100644
index 5b03a0f2..00000000
--- a/lib/mayu/resources/resolver/static.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "base"
-
-module Mayu
- module Resources
- module Resolver
- class Static < Base
- sig { params(paths: T::Hash[String, String]).void }
- def initialize(paths)
- super()
- @resolved_paths = paths
- # Console.logger.info(self, *@resolved_paths.map { "#{_1} => #{_2}" })
- end
-
- sig do
- override.params(path: String, source_dir: String).returns(String)
- end
- def resolve(path, source_dir = "/")
- relative_to_root = File.absolute_path(path, source_dir)
- @resolved_paths[relative_to_root] || relative_to_root
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/resource.rb b/lib/mayu/resources/resource.rb
deleted file mode 100644
index a110d3fc..00000000
--- a/lib/mayu/resources/resource.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "digest"
-
-module Mayu
- module Resources
- class Resource < Module
- class Wrapper < BasicObject
- extend ::T::Sig
-
- sig { params(impl: ::T.untyped).void }
- def initialize(impl)
- @impl = impl
- end
-
- sig do
- params(
- meth: ::Symbol,
- args: ::T.untyped,
- kwargs: ::T.untyped,
- block: ::T.untyped
- ).returns(::T.untyped)
- end
- def method_missing(meth, *args, **kwargs, &block)
- @impl.send(meth, *args, **kwargs, &block)
- end
-
- sig { params(impl: ::T.untyped).returns(::T.untyped) }
- def __replace_impl!(impl)
- @impl = impl
- end
-
- sig { returns(::String) }
- def inspect
- "#"
- end
- end
-
- extend T::Sig
-
- Impl = T.type_alias { T.untyped }
-
- sig { returns(Registry) }
- attr_reader :registry
- sig { returns(String) }
- attr_reader :path
- sig { returns(String) }
- attr_reader :path_hash
- sig { returns(T.untyped) }
- attr_reader :wrapper
-
- sig { params(registry: Registry, path: String).void }
- def initialize(registry:, path:)
- @registry = registry
- @path = path
- @path_hash = T.let(Digest::SHA256.hexdigest(path), String)
- @type = T.let(nil, T.untyped)
- @wrapper = T.let(Wrapper.new(nil), T.untyped)
- @content_hash = T.let(nil, T.nilable(String))
- end
-
- sig { params(encoding: String).returns(String) }
- def read(encoding: "binary")
- File.read(absolute_path)
- end
-
- sig { returns(T::Boolean) }
- def exists?
- File.exist?(absolute_path)
- end
-
- sig { returns(String) }
- def content_hash
- @content_hash ||= calculate_content_hash
- end
-
- sig { returns(String) }
- def calculate_content_hash
- Digest::SHA256.file(absolute_path).digest
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- self.type&.assets || []
- end
-
- sig { params(assets_dir: String).returns(T::Array[Asset]) }
- def generate_assets(assets_dir)
- if type = self.type
- type.generate_assets(assets_dir)
- else
- []
- end
- end
-
- sig { returns(String) }
- def app_root = @registry.root
-
- sig { returns(String) }
- def absolute_path = @registry.absolute_path(@path)
-
- sig { returns(T.untyped) }
- def type
- @type || load_type
- end
-
- sig { returns(T.untyped) }
- def load_type
- @content_hash = nil
- @wrapper.__replace_impl!(
- @type =
- if exists?
- Types.for_path(self.path).new(self)
- else
- Types::Nil.new(self)
- end
- )
- end
-
- sig { params(path: String).returns(T.untyped) }
- def import(path)
- resource = self.registry.load_resource(path, File.dirname(self.path))
- self.registry.dependency_graph.add_dependency(self.path, resource.path)
- resource.type
- end
-
- MarshalFormat =
- T.type_alias do
- [String, String, T.nilable(String), Resources::Types::Base]
- end
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- if exists?
- [path, path_hash, content_hash, type]
- else
- [path, path_hash, nil, type]
- end
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @path, @path_hash, @content_hash, @type = args
- @type.instance_variable_set(:@resource, self)
- @wrapper = Wrapper.new(@type)
- end
- end
- end
-end
diff --git a/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.in.css b/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.in.css
deleted file mode 100644
index 77cf7c28..00000000
--- a/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.in.css
+++ /dev/null
@@ -1,3 +0,0 @@
-a + .b {
- color: #fff;
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.out.css b/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.out.css
deleted file mode 100644
index 5c779158..00000000
--- a/lib/mayu/resources/transformers/__test__/css/adjacent_selectors.out.css
+++ /dev/null
@@ -1,6 +0,0 @@
-@layer app\/components\/MyComponent\?SCXjFuB8 {
-.app\/components\/MyComponent_a\?GgZETqNB + .app\/components\/MyComponent\.b\?GgZETqNB {
- color: #fff;
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/css/attributes.in.css b/lib/mayu/resources/transformers/__test__/css/attributes.in.css
deleted file mode 100644
index e7bdc0d3..00000000
--- a/lib/mayu/resources/transformers/__test__/css/attributes.in.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.page[aria-current="page"] {
- background: var(--blue);
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/attributes.out.css b/lib/mayu/resources/transformers/__test__/css/attributes.out.css
deleted file mode 100644
index 0b0035cc..00000000
--- a/lib/mayu/resources/transformers/__test__/css/attributes.out.css
+++ /dev/null
@@ -1,6 +0,0 @@
-@layer app\/components\/MyComponent\?MiWQFWcF {
-.app\/components\/MyComponent\.page\?-eWE5yiJ[aria-current="page"] {
- background: var(--blue);
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/css/composes.out.css b/lib/mayu/resources/transformers/__test__/css/composes.out.css
deleted file mode 100644
index 6bcabf32..00000000
--- a/lib/mayu/resources/transformers/__test__/css/composes.out.css
+++ /dev/null
@@ -1,9 +0,0 @@
-@layer app\/components\/MyComponent\?dn2abQvd {
-.app\/components\/MyComponent\.foo\?aiQioe1q {
- color: #f0f;
-}
-
-.app\/components\/MyComponent\.bar\?aiQioe1q {
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/css/element_selectors.in.css b/lib/mayu/resources/transformers/__test__/css/element_selectors.in.css
deleted file mode 100644
index a0fc1c0e..00000000
--- a/lib/mayu/resources/transformers/__test__/css/element_selectors.in.css
+++ /dev/null
@@ -1,3 +0,0 @@
-p {
- color: fuchsia;
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/element_selectors.out.css b/lib/mayu/resources/transformers/__test__/css/element_selectors.out.css
deleted file mode 100644
index f2a306fb..00000000
--- a/lib/mayu/resources/transformers/__test__/css/element_selectors.out.css
+++ /dev/null
@@ -1,6 +0,0 @@
-@layer app\/components\/MyComponent\?GK5pcDZq {
-.app\/components\/MyComponent_p\?GiigLbAS {
- color: #f0f;
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/css/has.in.css b/lib/mayu/resources/transformers/__test__/css/has.in.css
deleted file mode 100644
index 7da38e56..00000000
--- a/lib/mayu/resources/transformers/__test__/css/has.in.css
+++ /dev/null
@@ -1,7 +0,0 @@
-.formGroup:has(:invalid) {
- --color: var(--invalid);
-}
-
-.formGroup:has(:invalid:not(:focus)) {
- animation: shake 0.25s;
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/has.out.css b/lib/mayu/resources/transformers/__test__/css/has.out.css
deleted file mode 100644
index c79c638f..00000000
--- a/lib/mayu/resources/transformers/__test__/css/has.out.css
+++ /dev/null
@@ -1,10 +0,0 @@
-@layer app\/components\/MyComponent\?tjLaPD8V {
-.app\/components\/MyComponent\.formGroup\?vNHgJWqX:has(:invalid) {
- --color: var(--invalid);
-}
-
-.app\/components\/MyComponent\.formGroup\?vNHgJWqX:has(:invalid:not(:focus)) {
- animation: .25s shake;
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/css/media_queries.in.css b/lib/mayu/resources/transformers/__test__/css/media_queries.in.css
deleted file mode 100644
index 37fd63aa..00000000
--- a/lib/mayu/resources/transformers/__test__/css/media_queries.in.css
+++ /dev/null
@@ -1,8 +0,0 @@
-@media (min-width: 8em) and (max-width: 32em) {
- .foo {
- color: fuchsia;
- }
-}
-.bar {
- color: blue;
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/media_queries.out.css b/lib/mayu/resources/transformers/__test__/css/media_queries.out.css
deleted file mode 100644
index 8eb2a107..00000000
--- a/lib/mayu/resources/transformers/__test__/css/media_queries.out.css
+++ /dev/null
@@ -1,12 +0,0 @@
-@layer app\/components\/MyComponent\?E59aOM9B {
-@media (width >= 8em) and (width <= 32em) {
- .app\/components\/MyComponent\.foo\?Ef7fDeuq {
- color: #f0f;
- }
-}
-
-.app\/components\/MyComponent\.bar\?Ef7fDeuq {
- color: #00f;
-}
-
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/pseudo_classes.in.css b/lib/mayu/resources/transformers/__test__/css/pseudo_classes.in.css
deleted file mode 100644
index 0e2948b0..00000000
--- a/lib/mayu/resources/transformers/__test__/css/pseudo_classes.in.css
+++ /dev/null
@@ -1,5 +0,0 @@
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
diff --git a/lib/mayu/resources/transformers/__test__/css/pseudo_classes.out.css b/lib/mayu/resources/transformers/__test__/css/pseudo_classes.out.css
deleted file mode 100644
index 45b5ebf9..00000000
--- a/lib/mayu/resources/transformers/__test__/css/pseudo_classes.out.css
+++ /dev/null
@@ -1,6 +0,0 @@
-@layer app\/components\/MyComponent\?8Y3SjNF9 {
-*, :before, :after {
- box-sizing: border-box;
-}
-
-}
\ No newline at end of file
diff --git a/lib/mayu/resources/transformers/__test__/haml/README.md b/lib/mayu/resources/transformers/__test__/haml/README.md
deleted file mode 100644
index 188c8570..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Haml transformer tests
-
-Structure:
-
-- `test-name.haml`: Haml input.
-- `test-name.rb`: Expected Ruby output.
-
-Skipped tests:
-
-`test-name.skip` contains the reason.
diff --git a/lib/mayu/resources/transformers/__test__/haml/case.rb b/lib/mayu/resources/transformers/__test__/haml/case.rb
deleted file mode 100644
index 9c2919f1..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/case.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :div,
- case props[:value]
- when "foo"
- Mayu::VDOM::H[:p, "Foo"]
- when "bar"
- Mayu::VDOM::H[:p, "Bar"]
- else
- Mayu::VDOM::H[:p, "Other"]
- end
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/class_names.rb b/lib/mayu/resources/transformers/__test__/haml/class_names.rb
deleted file mode 100644
index 3b23fc95..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/class_names.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-begin
- lol = "lol"
- id = "check123"
- props = { label: "label", asd: "asd" }
-end
-public def render
- Mayu::VDOM::H[
- :div,
- "hello",
- Mayu::VDOM::H[
- :input,
- **mayu.merge_props(
- {
- class: classname,
- type: "checkbox",
- placeholder: props[:label],
- **props.except(:label)
- },
- { id: id }
- )
- ],
- **mayu.merge_props({ class: %i[foo bar] }, { class: "baz" }, { asdd: lol })
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/comments.rb b/lib/mayu/resources/transformers/__test__/haml/comments.rb
deleted file mode 100644
index e12af991..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/comments.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[:div, Mayu::VDOM::H[:foo], Mayu::VDOM::H[:bar]]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/css.haml b/lib/mayu/resources/transformers/__test__/haml/css.haml
deleted file mode 100644
index ef8e3d7f..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/css.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-:css
- .button { color: #f0f; }
-%button.button Click me
diff --git a/lib/mayu/resources/transformers/__test__/haml/css.rb b/lib/mayu/resources/transformers/__test__/haml/css.rb
deleted file mode 100644
index d5f0c838..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/css.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-Self =
- setup_component(
- assets: ["Cd9Qx4lhkbeZyruovAokW1FL3tHSmMOxOc-2y1h7Zvc=.css"],
- styles: {
- button: "app/components/MyComponent.button?dhhHwAZl"
- }
- )
-public def render
- Mayu::VDOM::H[:button, "Click me", **mayu.merge_props({ class: :button })]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/dashes.rb b/lib/mayu/resources/transformers/__test__/haml/dashes.rb
deleted file mode 100644
index e0326662..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/dashes.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :div,
- Mayu::VDOM::H[
- :svg,
- Mayu::VDOM::H[:line, **mayu.merge_props({ stroke_width: 2 })]
- ]
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/early_return.rb b/lib/mayu/resources/transformers/__test__/haml/early_return.rb
deleted file mode 100644
index e05cf227..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/early_return.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- begin
- return Mayu::VDOM::H[:div, **mayu.merge_props({ class: :foo })] if true
- nil
- end
- Mayu::VDOM::H[:div, **mayu.merge_props({ class: :bar })]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/early_return2.rb b/lib/mayu/resources/transformers/__test__/haml/early_return2.rb
deleted file mode 100644
index 955cfeef..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/early_return2.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- return Mayu::VDOM::H[:div, **mayu.merge_props({ class: :foo })] if props[:foo]
- Mayu::VDOM::H[:div, **mayu.merge_props({ class: :bar })]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/handlers.rb b/lib/mayu/resources/transformers/__test__/haml/handlers.rb
deleted file mode 100644
index 8a258641..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/handlers.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-def handle_click(e)
- Console.logger.info(self, e)
-end
-public def render
- Mayu::VDOM::H[
- :button,
- "Click me",
- **mayu.merge_props({ onclick: mayu.handler(:handle_click) })
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/if_else.rb b/lib/mayu/resources/transformers/__test__/haml/if_else.rb
deleted file mode 100644
index 2068022b..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/if_else.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-begin
- # setup
-end
-public def render
- if true
- Mayu::VDOM::H[:div, **mayu.merge_props({ class: :foo })]
- else
- Mayu::VDOM::H[:div, **mayu.merge_props({ class: :bar })]
- end
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/interpolation.rb b/lib/mayu/resources/transformers/__test__/haml/interpolation.rb
deleted file mode 100644
index 4c853fe9..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/interpolation.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :div,
- Mayu::VDOM::H[:div, "foo #{bar} baz"],
- Mayu::VDOM::H[:div, "foo #{bar} baz"],
- Mayu::VDOM::H[:div, "foo #{bar} baz"],
- Mayu::VDOM::H[:div, ("lol #{boll} polle" if bar)]
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/object_ref_as_key.rb b/lib/mayu/resources/transformers/__test__/haml/object_ref_as_key.rb
deleted file mode 100644
index d69e9810..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/object_ref_as_key.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[:div, **mayu.merge_props({ key: ["hello"] })]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/props.rb b/lib/mayu/resources/transformers/__test__/haml/props.rb
deleted file mode 100644
index 2fa13767..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/props.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :div,
- Mayu::VDOM::H[:h1, self.props[:title]],
- Mayu::VDOM::H[:h1, "hej #{self.props[:title][123]} asd"],
- Mayu::VDOM::H[:h2, $~],
- **mayu.merge_props({ class: self.props[:class] })
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/slots.rb b/lib/mayu/resources/transformers/__test__/haml/slots.rb
deleted file mode 100644
index 6692106f..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/slots.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :body,
- Mayu::VDOM::H[:main, mayu.slot],
- Mayu::VDOM::H[:footer, mayu.slot("footer")]
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/slots_dynamic.rb b/lib/mayu/resources/transformers/__test__/haml/slots_dynamic.rb
deleted file mode 100644
index d67de1aa..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/slots_dynamic.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- begin
- name = "foo"
- nil
- end
- mayu.slot(name) { Mayu::VDOM::H[:p, "Fallback content"] }
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/slots_fallback.rb b/lib/mayu/resources/transformers/__test__/haml/slots_fallback.rb
deleted file mode 100644
index 52ea61eb..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/slots_fallback.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[:div, mayu.slot { Mayu::VDOM::H[:p, "Fallback content"] }]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/spacing.rb b/lib/mayu/resources/transformers/__test__/haml/spacing.rb
deleted file mode 100644
index 7535c3df..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/spacing.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :p,
- "There should be no space on the left of this text. But there should be one between this line and the previous line. ",
- Mayu::VDOM::H[
- :a,
- "And there should be spaces before this link",
- **mayu.merge_props({ href: "/" })
- ],
- ". Was there?"
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/spacing2.rb b/lib/mayu/resources/transformers/__test__/haml/spacing2.rb
deleted file mode 100644
index b1c03609..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/spacing2.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :div,
- Mayu::VDOM::H[:p, "Hello World"],
- Mayu::VDOM::H[:p, "Hello World"],
- Mayu::VDOM::H[:p, "Hello World"],
- Mayu::VDOM::H[:p, "Hello World"]
- ]
-end
diff --git a/lib/mayu/resources/transformers/__test__/haml/spacing3.rb b/lib/mayu/resources/transformers/__test__/haml/spacing3.rb
deleted file mode 100644
index a91ff977..00000000
--- a/lib/mayu/resources/transformers/__test__/haml/spacing3.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-Self = setup_component(assets: [], styles: {})
-public def render
- Mayu::VDOM::H[
- :p,
- "Blabla #{asd}",
- " ",
- Mayu::VDOM::H[:a, "hopp", **mayu.merge_props({ href: "asd" })]
- ]
-end
diff --git a/lib/mayu/resources/transformers/css.rb b/lib/mayu/resources/transformers/css.rb
deleted file mode 100644
index 31cc5574..00000000
--- a/lib/mayu/resources/transformers/css.rb
+++ /dev/null
@@ -1,145 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-require "base64"
-require "digest/sha2"
-
-module Mayu
- module Resources
- module Transformers
- module CSS
- class TransformResult < T::Struct
- const :filename, String
- const :output, String
- const :content_hash, String
- const :layer_name, String
- const :classes, T::Hash[Symbol, String]
- const :elements, T::Hash[Symbol, String]
- const :source_map, T::Hash[String, T.untyped]
- end
-
- extend T::Sig
-
- sig do
- params(transform_results: T::Array[TransformResult]).returns(
- T::Hash[String, String]
- )
- end
- def self.merge_classnames(transform_results)
- classnames = Hash.new { |h, k| h[k] = Set.new }
-
- transform_results.each do |transform_result|
- transform_result.classes.each do |source, target|
- classnames[source].add(target)
- end
- end
-
- classnames.transform_values { _1.join(" ") }
- end
-
- sig do
- params(
- source: String,
- source_path: String,
- source_line: Integer,
- minify: T::Boolean
- ).returns(TransformResult)
- end
- def self.transform(source:, source_path:, source_line: 1, minify: true)
- # Required here because it's not necessary in production..
- # kinda messy. need to rewrite the entire "resources" thing...
- require "mayu/css"
-
- source_path_without_extension =
- File.join(
- File.dirname(source_path),
- File.basename(source_path, ".*")
- ).delete_prefix("./")
-
- result =
- Mayu::CSS.transform(source_path_without_extension, source, minify:)
-
- output = result.code.encode("utf-8")
-
- header = "/* #{source_path} */\n"
-
- content_hash = Digest::SHA256.digest(output)
- urlsafe_hash = Base64.urlsafe_encode64(content_hash)
- filename = "#{urlsafe_hash}.css"
-
- layer_name =
- "#{source_path_without_extension}?#{urlsafe_hash.slice(0, 8)}"
-
- output = "@layer #{escape_string(layer_name)} {\n#{output}\n}"
-
- TransformResult.new(
- filename:,
- output:,
- layer_name: layer_name,
- classes:
- join_classes(
- result.classes,
- result.elements,
- result.exports
- ).freeze,
- elements: result.elements.transform_keys(&:to_sym),
- content_hash:,
- source_map: {
- "version" => 3,
- "file" => filename,
- "sourceRoot" => "mayu://",
- "sources" => [source_path],
- "sourcesContent" => [source]
- }
- )
- end
-
- sig do
- params(
- classes: T::Hash[Symbol, String],
- elements: T::Hash[Symbol, String],
- exports: T::Hash[String, T.untyped]
- ).returns(T::Hash[Symbol, String])
- end
- def self.join_classes(classes, elements, exports)
- {
- **classes
- .transform_values { join_class(_1, exports, classes) }
- .transform_keys(&:to_sym),
- **elements
- .transform_values { join_class(_1, exports, classes) }
- .transform_keys { :"__#{_1}" }
- }
- end
-
- sig do
- params(
- klass: String,
- exports: T::Hash[String, T.untyped],
- classes: T::Hash[Symbol, String]
- ).returns(String)
- end
- def self.join_class(klass, exports, classes)
- if composes = exports[klass]&.composes
- [
- klass,
- *composes.map do |compose|
- case compose
- in Mayu::CSS::ComposeLocal
- classes[compose.name.to_sym]
- end
- end
- ].join(" ")
- else
- klass
- end
- end
-
- sig { params(str: String).returns(String) }
- def self.escape_string(str)
- str.gsub(/[^\w-]/, '\\\\\0')
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/transformers/css.test.rb b/lib/mayu/resources/transformers/css.test.rb
deleted file mode 100644
index d192563d..00000000
--- a/lib/mayu/resources/transformers/css.test.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-# typed: true
-
-require "minitest/autorun"
-require "test_helper"
-
-require_relative "css"
-require_relative "css/rouge_lexer"
-require "rouge"
-
-class Mayu::Resources::Transformers::CSS::Test < Minitest::Test
- EXAMPLES_ROOT = File.join(__dir__, "__test__", "css")
-
- def test_composes123
- result =
- Mayu::Resources::Transformers::CSS.transform(
- source: <<~CSS,
- .foo { color: #f0f; }
- .bar { composes: foo; }
- baz { composes: bar; }
- CSS
- source_path: "path/to/file"
- )
-
- assert_equal(
- {
- foo: "path/to/file.foo?IbyVK-OP",
- bar: "path/to/file.bar?IbyVK-OP path/to/file.foo?IbyVK-OP",
- __baz: "path/to/file_baz?IbyVK-OP path/to/file.bar?IbyVK-OP"
- },
- result.classes
- )
- end
-
- Dir[File.join(EXAMPLES_ROOT, "*.in.css")].each do |input_path|
- basename = File.basename(input_path, ".in.css")
-
- if ENV["MATCH"] in String => match
- next unless basename.include?(match)
- end
-
- skip_path = File.join(EXAMPLES_ROOT, "#{basename}.skip")
- output_path = File.join(EXAMPLES_ROOT, "#{basename}.out.css")
-
- input = File.read(input_path)
- expected = File.read(output_path)
-
- define_method(:"test_file_#{basename}") do
- T.bind(self, Mayu::Resources::Transformers::CSS::Test)
-
- skip File.read(skip_path) if File.exist?(skip_path)
- actual = transform(input)
- # File.write(output_path, actual)
- assert_equal(expected.chomp, actual.chomp)
- end
- end
-
- private
-
- def transform(source)
- source_path = "app/components/MyComponent"
-
- Mayu::Resources::Transformers::CSS
- .transform(source:, source_path:, minify: false)
- .output
- .each_line
- .map(&:rstrip)
- .join("\n")
- .tap do
- puts(
- "\e[1mTransformed:\e[0m",
- prepend_line_numbers(
- colorize_source(
- _1.strip,
- Mayu::Resources::Transformers::CSS::RougeLexer.new
- ).each_line
- )
- )
- end
- end
-
- def prepend_line_numbers(lines, start_line: 1, error_line: nil)
- number_format = "\e[38;5;250;48;5;236m%3d \e[0m"
- error_format = "\e[41m%s\e[0m"
-
- lines
- .map
- .with_index(start_line) do |line, i|
- if error_line == i
- format(error_format, line.chomp) + "\n"
- else
- line
- end.prepend(format(number_format, i))
- end
- end
-
- def transform_file(root:, path:)
- Mayu::Resources::Transformers::CSS.transform(
- source: File.read(File.join(root, path)),
- source_path: path
- )
- end
-
- def colorize_source(source, lexer)
- theme = Rouge::Themes::Monokai.new
- formatter = Rouge::Formatters::Terminal256.new(theme:)
- formatter.format(lexer.lex(source.chomp))
- end
-end
diff --git a/lib/mayu/resources/transformers/css/rouge_lexer.rb b/lib/mayu/resources/transformers/css/rouge_lexer.rb
deleted file mode 100644
index e6af9eb2..00000000
--- a/lib/mayu/resources/transformers/css/rouge_lexer.rb
+++ /dev/null
@@ -1,841 +0,0 @@
-# -*- coding: utf-8 -*- #
-# typed: false
-# frozen_string_literal: true
-#
-# MIT license. See http://www.opensource.org/licenses/mit-license.php
-
-# Copyright (c) 2012 Jeanine Adkisson.
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# This file is copied from and slightly modified.
-# https://github.com/rouge-ruby/rouge/blob/730208cee94cc6bc17a766d1786af414f751b1c7/lib/rouge/lexers/css.rb
-# Modified locations have been marked with "MAYU".
-
-require "rouge"
-
-module Mayu
- module Resources
- module Transformers
- module CSS
- class RougeLexer < Rouge::RegexLexer
- title "CSS"
- desc "Cascading Style Sheets, used to style web pages"
-
- tag "css"
- filenames "*.css"
- mimetypes "text/css"
-
- # Documentation: https://www.w3.org/TR/CSS21/syndata.html#characters
-
- # MAYU: This regex has been changed to allow escaping characters (i.e: .foo\.bar).
- identifier = /[\p{L}_-](?:[\p{Word}\p{Cf}-]|\\.)*/
- number = /-?(?:[0-9]+(\.[0-9]+)?|\.[0-9]+)/
-
- def self.attributes
- @attributes ||=
- Set.new %w[
- align-content
- align-items
- align-self
- alignment-adjust
- alignment-baseline
- all
- anchor-point
- animation
- animation-delay
- animation-direction
- animation-duration
- animation-fill-mode
- animation-iteration-count
- animation-name
- animation-play-state
- animation-timing-function
- appearance
- azimuth
- backface-visibility
- background
- background-attachment
- background-clip
- background-color
- background-image
- background-origin
- background-position
- background-repeat
- background-size
- baseline-shift
- binding
- bleed
- bookmark-label
- bookmark-level
- bookmark-state
- bookmark-target
- border
- border-bottom
- border-bottom-color
- border-bottom-left-radius
- border-bottom-right-radius
- border-bottom-style
- border-bottom-width
- border-collapse
- border-color
- border-image
- border-image-outset
- border-image-repeat
- border-image-slice
- border-image-source
- border-image-width
- border-left
- border-left-color
- border-left-style
- border-left-width
- border-radius
- border-right
- border-right-color
- border-right-style
- border-right-width
- border-spacing
- border-style
- border-top
- border-top-color
- border-top-left-radius
- border-top-right-radius
- border-top-style
- border-top-width
- border-width
- bottom
- box-align
- box-decoration-break
- box-direction
- box-flex
- box-flex-group
- box-lines
- box-ordinal-group
- box-orient
- box-pack
- box-shadow
- box-sizing
- break-after
- break-before
- break-inside
- caption-side
- clear
- clip
- clip-path
- clip-rule
- color
- color-profile
- columns
- column-count
- column-fill
- column-gap
- column-rule
- column-rule-color
- column-rule-style
- column-rule-width
- column-span
- column-width
- content
- counter-increment
- counter-reset
- crop
- cue
- cue-after
- cue-before
- cursor
- direction
- display
- dominant-baseline
- drop-initial-after-adjust
- drop-initial-after-align
- drop-initial-before-adjust
- drop-initial-before-align
- drop-initial-size
- drop-initial-value
- elevation
- empty-cells
- filter
- fit
- fit-position
- flex
- flex-basis
- flex-direction
- flex-flow
- flex-grow
- flex-shrink
- flex-wrap
- float
- float-offset
- font
- font-family
- font-feature-settings
- font-kerning
- font-language-override
- font-size
- font-size-adjust
- font-stretch
- font-style
- font-synthesis
- font-variant
- font-variant-alternates
- font-variant-caps
- font-variant-east-asian
- font-variant-ligatures
- font-variant-numeric
- font-variant-position
- font-weight
- grid-cell
- grid-column
- grid-column-align
- grid-column-sizing
- grid-column-span
- grid-columns
- grid-flow
- grid-row
- grid-row-align
- grid-row-sizing
- grid-row-span
- grid-rows
- grid-template
- hanging-punctuation
- height
- hyphenate-after
- hyphenate-before
- hyphenate-character
- hyphenate-lines
- hyphenate-resource
- hyphens
- icon
- image-orientation
- image-rendering
- image-resolution
- ime-mode
- inline-box-align
- justify-content
- left
- letter-spacing
- line-break
- line-height
- line-stacking
- line-stacking-ruby
- line-stacking-shift
- line-stacking-strategy
- list-style
- list-style-image
- list-style-position
- list-style-type
- margin
- margin-bottom
- margin-left
- margin-right
- margin-top
- mark
- marker-offset
- marks
- mark-after
- mark-before
- marquee-direction
- marquee-loop
- marquee-play-count
- marquee-speed
- marquee-style
- mask
- max-height
- max-width
- min-height
- min-width
- move-to
- nav-down
- nav-index
- nav-left
- nav-right
- nav-up
- object-fit
- object-position
- opacity
- order
- orphans
- outline
- outline-color
- outline-offset
- outline-style
- outline-width
- overflow
- overflow-style
- overflow-wrap
- overflow-x
- overflow-y
- padding
- padding-bottom
- padding-left
- padding-right
- padding-top
- page
- page-break-after
- page-break-before
- page-break-inside
- page-policy
- pause
- pause-after
- pause-before
- perspective
- perspective-origin
- phonemes
- pitch
- pitch-range
- play-during
- pointer-events
- position
- presentation-level
- punctuation-trim
- quotes
- rendering-intent
- resize
- rest
- rest-after
- rest-before
- richness
- right
- rotation
- rotation-point
- ruby-align
- ruby-overhang
- ruby-position
- ruby-span
- size
- speak
- speak-as
- speak-header
- speak-numeral
- speak-punctuation
- speech-rate
- src
- stress
- string-set
- tab-size
- table-layout
- target
- target-name
- target-new
- target-position
- text-align
- text-align-last
- text-combine-horizontal
- text-decoration
- text-decoration-color
- text-decoration-line
- text-decoration-skip
- text-decoration-style
- text-emphasis
- text-emphasis-color
- text-emphasis-position
- text-emphasis-style
- text-height
- text-indent
- text-justify
- text-orientation
- text-outline
- text-overflow
- text-rendering
- text-shadow
- text-space-collapse
- text-transform
- text-underline-position
- text-wrap
- top
- transform
- transform-origin
- transform-style
- transition
- transition-delay
- transition-duration
- transition-property
- transition-timing-function
- unicode-bidi
- vertical-align
- visibility
- voice-balance
- voice-duration
- voice-family
- voice-pitch
- voice-pitch-range
- voice-range
- voice-rate
- voice-stress
- voice-volume
- volume
- white-space
- widows
- width
- word-break
- word-spacing
- word-wrap
- writing-mode
- z-index
- ]
- end
-
- def self.builtins
- @builtins ||=
- Set.new %w[
- above
- absolute
- always
- armenian
- aural
- auto
- avoid
- left
- bottom
- baseline
- behind
- below
- bidi-override
- blink
- block
- bold
- bolder
- both
- bottom
- capitalize
- center
- center-left
- center-right
- circle
- cjk-ideographic
- close-quote
- collapse
- condensed
- continuous
- crop
- cross
- crosshair
- cursive
- dashed
- decimal
- decimal-leading-zero
- default
- digits
- disc
- dotted
- double
- e-resize
- embed
- expanded
- extra-condensed
- extra-expanded
- fantasy
- far-left
- far-right
- fast
- faster
- fixed
- georgian
- groove
- hebrew
- help
- hidden
- hide
- high
- higher
- hiragana
- hiragana-iroha
- icon
- inherit
- inline
- inline-table
- inset
- inside
- invert
- italic
- justify
- katakana
- katakana-iroha
- landscape
- large
- larger
- left
- left-side
- leftwards
- level
- lighter
- line-through
- list-item
- loud
- low
- lower
- lower-alpha
- lower-greek
- lower-roman
- lowercase
- ltr
- medium
- message-box
- middle
- mix
- monospace
- n-resize
- narrower
- ne-resize
- no-close-quote
- no-open-quote
- no-repeat
- none
- normal
- nowrap
- nw-resize
- oblique
- once
- open-quote
- outset
- outside
- overline
- pointer
- portrait
- px
- relative
- repeat
- repeat-x
- repeat-y
- rgb
- ridge
- right
- right-side
- rightwards
- s-resize
- sans-serif
- scroll
- se-resize
- semi-condensed
- semi-expanded
- separate
- serif
- show
- silent
- slow
- slower
- small-caps
- small-caption
- smaller
- soft
- solid
- spell-out
- square
- static
- status-bar
- super
- sw-resize
- table-caption
- table-cell
- table-column
- table-column-group
- table-footer-group
- table-header-group
- table-row
- table-row-group
- text
- text-bottom
- text-top
- thick
- thin
- top
- transparent
- ultra-condensed
- ultra-expanded
- underline
- upper-alpha
- upper-latin
- upper-roman
- uppercase
- url
- visible
- w-resize
- wait
- wider
- x-fast
- x-high
- x-large
- x-loud
- x-low
- x-small
- x-soft
- xx-large
- xx-small
- yes
- ]
- end
-
- def self.constants
- @constants ||=
- Set.new %w[
- indigo
- gold
- firebrick
- indianred
- yellow
- darkolivegreen
- darkseagreen
- mediumvioletred
- mediumorchid
- chartreuse
- mediumslateblue
- black
- springgreen
- crimson
- lightsalmon
- brown
- turquoise
- olivedrab
- cyan
- silver
- skyblue
- gray
- darkturquoise
- goldenrod
- darkgreen
- darkviolet
- darkgray
- lightpink
- teal
- darkmagenta
- lightgoldenrodyellow
- lavender
- yellowgreen
- thistle
- violet
- navy
- orchid
- blue
- ghostwhite
- honeydew
- cornflowerblue
- darkblue
- darkkhaki
- mediumpurple
- cornsilk
- red
- bisque
- slategray
- darkcyan
- khaki
- wheat
- deepskyblue
- darkred
- steelblue
- aliceblue
- gainsboro
- mediumturquoise
- floralwhite
- coral
- purple
- lightgrey
- lightcyan
- darksalmon
- beige
- azure
- lightsteelblue
- oldlace
- greenyellow
- royalblue
- lightseagreen
- mistyrose
- sienna
- lightcoral
- orangered
- navajowhite
- lime
- palegreen
- burlywood
- seashell
- mediumspringgreen
- fuchsia
- papayawhip
- blanchedalmond
- peru
- aquamarine
- white
- darkslategray
- ivory
- dodgerblue
- lemonchiffon
- chocolate
- orange
- forestgreen
- slateblue
- olive
- mintcream
- antiquewhite
- darkorange
- cadetblue
- moccasin
- limegreen
- saddlebrown
- darkslateblue
- lightskyblue
- deeppink
- plum
- aqua
- darkgoldenrod
- maroon
- sandybrown
- magenta
- tan
- rosybrown
- pink
- lightblue
- palevioletred
- mediumseagreen
- dimgray
- powderblue
- seagreen
- snow
- mediumblue
- midnightblue
- paleturquoise
- palegoldenrod
- whitesmoke
- darkorchid
- salmon
- lightslategray
- lawngreen
- lightgreen
- tomato
- hotpink
- lightyellow
- lavenderblush
- linen
- mediumaquamarine
- green
- blueviolet
- peachpuff
- ]
- end
-
- # source: http://www.w3.org/TR/CSS21/syndata.html#vendor-keyword-history
- def self.vendor_prefixes
- @vendor_prefixes ||=
- Set.new %w[
- -ah-
- -atsc-
- -hp-
- -khtml-
- -moz-
- -ms-
- -o-
- -rim-
- -ro-
- -tc-
- -wap-
- -webkit-
- -xv-
- mso-
- prince-
- ]
- end
-
- state :root do
- mixin :basics
- rule /{/, Punctuation, :stanza
- rule /:[:]?#{identifier}/, Name::Decorator
- rule /\.#{identifier}/, Name::Class
- rule /##{identifier}/, Name::Function
- rule /@#{identifier}/, Keyword, :at_rule
- rule identifier, Name::Tag
- rule %r{[~^*!%&\[\]()<>|+=@:;,./?-]}, Operator
- rule /"(\\\\|\\"|[^"])*"/, Str::Single
- rule /'(\\\\|\\'|[^'])*'/, Str::Double
- end
-
- state :value do
- mixin :basics
- rule /url\(.*?\)/, Str::Other
- rule /#[0-9a-f]{1,6}/i, Num # colors
- rule /#{number}(?:%|(?:em|px|pt|pc|in|mm|cm|ex|rem|ch|vw|vh|vmin|vmax|dpi|dpcm|dppx|deg|grad|rad|turn|s|ms|Hz|kHz)\b)?/,
- Num
- rule %r{[\[\]():\/.,]}, Punctuation
- rule /"(\\\\|\\"|[^"])*"/, Str::Single
- rule /'(\\\\|\\'|[^'])*'/, Str::Double
- rule(identifier) do |m|
- if self.class.constants.include? m[0]
- token Name::Constant
- elsif self.class.builtins.include? m[0]
- token Name::Builtin
- else
- token Name
- end
- end
- end
-
- state :at_rule do
- rule /{(?=\s*#{identifier}\s*:)/m, Punctuation, :at_stanza
- rule /{/, Punctuation, :at_body
- rule /;/, Punctuation, :pop!
- mixin :value
- end
-
- state :at_body do
- mixin :at_content
- mixin :root
- end
-
- state :at_stanza do
- mixin :at_content
- mixin :stanza
- end
-
- state :at_content do
- rule /}/ do
- token Punctuation
- pop! 2
- end
- end
-
- state :basics do
- rule /\s+/m, Text
- rule %r{/\*(?:.*?)\*/}m, Comment
- end
-
- state :stanza do
- mixin :basics
- rule /}/, Punctuation, :pop!
- rule /(#{identifier})(\s*)(:)/m do |m|
- name_tok =
- if self.class.attributes.include? m[1]
- Name::Label
- elsif self.class.vendor_prefixes.any? do |p|
- m[1].start_with?(p)
- end
- Name::Label
- elsif m[1].start_with?("--")
- # MAYU: This was added to highlight CSS variables.
- Name::Constant
- else
- Name::Property
- end
-
- groups name_tok, Text, Punctuation
-
- push :stanza_value
- end
- end
-
- state :stanza_value do
- rule /;/, Punctuation, :pop!
- rule(/(?=})/) { pop! }
- rule /!\s*important\b/, Comment::Preproc
- rule /^@.*?$/, Comment::Preproc
- mixin :value
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/transformers/haml.rb b/lib/mayu/resources/transformers/haml.rb
deleted file mode 100644
index 3141b045..00000000
--- a/lib/mayu/resources/transformers/haml.rb
+++ /dev/null
@@ -1,985 +0,0 @@
-# typed: true
-# frozen_string_literal: true
-
-require "ripper"
-require "syntax_suggest"
-require "syntax_suggest/api"
-require "syntax_suggest/code_line"
-require "syntax_suggest/explain_syntax"
-require "syntax_suggest/lex_all"
-require "syntax_suggest/ripper_errors"
-require "syntax_tree"
-require "syntax_tree/haml"
-require_relative "css"
-
-module Mayu
- module Resources
- module Transformers
- module Haml
- class MutationVisitor < SyntaxTree::Visitor::MutationVisitor
- # This class visits more nodes than the parent class.
- # This should probably be fixed in the syntax_tree gem,
- # but I don't know what other nodes need to be fixed.
-
- def self.build(&block)
- new.tap { yield _1 }
- end
-
- def visit_assign(node)
- node.copy(target: visit(node.target), value: visit(node.value))
- end
-
- def visit_assoc_splat(node)
- node.copy(value: visit(node.value))
- end
-
- def visit_assoc(node)
- node.copy(key: visit(node.key), value: visit(node.value))
- end
-
- def visit_aref(node)
- node.copy(
- collection: visit(node.collection),
- index: visit(node.index)
- )
- end
-
- def visit_opassign(node)
- node.copy(target: visit(node.target), value: visit(node.value))
- end
-
- def visit_binary(node)
- node.copy(left: visit(node.left), right: visit(node.right))
- end
-
- def visit_if_op(node)
- node.copy(
- predicate: visit(node.predicate),
- truthy: visit(node.truthy),
- falsy: visit(node.falsy)
- )
- end
- end
-
- class TransformResult < T::Struct
- const :filename, String
- const :output, String
- const :content_hash, String
- const :css, T.nilable(CSS::TransformResult)
- const :source_map, T::Hash[String, T.untyped]
- end
-
- class TransformOptions < T::Struct
- const :source, String
- const :source_path, String
- const :source_line, Integer
-
- # TODO: Remove content_hash, it does not seem to be used?
- const :content_hash, T.nilable(String)
-
- const :transform_elements_to_classes, T::Boolean, default: true
- const :enable_new_helper_ident, T::Boolean, default: false
-
- def source_path_without_extension
- File.join(
- File.dirname(source_path),
- File.basename(source_path, ".*")
- ).delete_prefix("./")
- end
- end
-
- extend T::Sig
-
- sig { params(options: TransformOptions).returns(TransformResult) }
- def self.transform(options)
- result =
- SyntaxTree::Haml.parse(options.source).accept(
- Transformer.new(options)
- )
-
- TransformResult.new(
- filename: options.source_path,
- output: result.source,
- content_hash: Digest::SHA256.digest(result.source),
- css: result.styles.first,
- source_map: {
- }
- )
- end
-
- class RubyBuilder
- include SyntaxTree::DSL
-
- def initialize(options)
- @options = options
- end
-
- def assign_const(name, value) = Assign(VarField(Const(name)), value)
- def self_var_ref = VarRef(Kw("self"))
-
- def create_program(setup, styles, render)
- Program(
- Statements(
- [
- Comment("# frozen_string_literal: true", false),
- assign_const("Self", setup_component(styles)),
- *setup,
- create_render(render)
- ]
- )
- ).accept(StateAndPropsTransformer.new.visitor)
- end
-
- def const_path(*names)
- names.reduce(nil) do |parent, name|
- const = Const(name)
-
- if T.cast(parent, T.untyped)
- ConstPathRef(parent, const)
- else
- TopConstRef(const)
- end
- end
- end
-
- def setup_component(styles)
- CallNode(
- nil,
- nil,
- Ident("setup_component"),
- ArgParen(
- Args(
- [
- BareAssocHash(
- assocs(
- assets:
- array(styles.map { string_literal(_1.filename) }),
- styles: props_hash(CSS.merge_classnames(styles))
- )
- )
- ]
- )
- )
- )
- end
-
- def assocs(**kwargs)
- kwargs.map { |key, value| Assoc(Label("#{key}:"), value) }
- end
-
- def array(elems)
- ArrayLiteral(LBracket("["), Args(elems))
- end
-
- def create_render(statements)
- Command(
- Ident("public"),
- Args(
- [
- DefNode(
- nil,
- nil,
- Ident("render"),
- nil,
- BodyStmt(
- Statements(
- [
- # set_fiber_local("current_component", VarRef(Kw("self"))),
- *statements
- ]
- ),
- nil,
- nil,
- nil,
- nil
- )
- )
- ]
- ),
- nil
- )
- end
-
- def set_fiber_local(ident, value)
- Assign(
- ARefField(
- VarRef(Const("Fiber")),
- Args([SymbolLiteral(Ident(ident))])
- ),
- value
- )
- end
-
- def slot(name = nil, fallback: nil)
- if fallback in [_, *]
- return(
- MethodAddBlock(
- slot(name, fallback: nil),
- BlockNode(
- Kw("do"),
- nil,
- BodyStmt(Statements(Array(fallback)), nil, nil, nil, nil)
- )
- )
- )
- end
-
- # call_helpers(:slot, [Ident("children"), name].compact)
- call_helpers(:slot, [name].compact)
- end
-
- def tag(name, children, attrs_to_merge)
- ARef(
- ConstPathRef(
- ConstPathRef(VarRef(Const("Mayu")), Const("VDOM")),
- Const("H")
- ),
- Args(
- [
- tag_name_or_class(name),
- *children,
- merge_props(attrs_to_merge)
- ].flatten.compact
- )
- )
- end
-
- def tag_name_or_class(name)
- case name
- in /\A[A-Z]/
- Ident(name)
- else
- SymbolLiteral(Ident(name))
- end
- end
-
- def splat_hash(node)
- BareAssocHash([AssocSplat(node)])
- end
-
- def merge_props(attrs_to_merge)
- return if attrs_to_merge.empty?
-
- splat_hash(call_helpers(:merge_props, attrs_to_merge))
- end
-
- def first_or_array(nodes)
- case nodes
- in [node]
- node
- else
- ArrayLiteral(LBracket("["), Args(nodes))
- end
- end
-
- def sym(str)
- if str.match(/\A[\w_]+\z/)
- SymbolLiteral(Ident(str))
- else
- DynaSymbol([TStringContent(str)], '"')
- end
- end
-
- def props_hash(attrs)
- HashLiteral(
- LBrace("{"),
- attrs.map do |key, value|
- if key.to_s == "class" && value in String
- Assoc(
- SymbolLiteral(Ident(key.to_s)),
- first_or_array(value.split.map { sym(_1) })
- )
- else
- Assoc(
- sym(key.to_s),
- case value
- in Symbol
- SymbolLiteral(Ident(value.to_s))
- in String
- StringLiteral([TStringContent(value.to_s)], :'"')
- in SyntaxTree::ArrayLiteral
- value
- in TrueClass | FalseClass | NilClass
- VarRef(Kw(value.to_s))
- end
- )
- end
- end
- )
- end
-
- def try_split_string_literal(node)
- case node
- in SyntaxTree::StringLiteral
- split_string_literal(node)
- in [SyntaxTree::StringLiteral => node]
- split_string_literal(node)
- else
- node
- end
- end
-
- def split_string_literal(string_literal)
- string_literal
- # string_literal
- # .parts
- # .map do |part|
- # case part
- # in SyntaxTree::TStringContent
- # string_literal(part.value)
- # in SyntaxTree::StringEmbExpr
- # part.statements
- # end
- # end
- # .flatten
- end
-
- def ruby_script(statements)
- case statements
- in []
- nil
- in [SyntaxTree::StringLiteral => string_literal]
- split_string_literal(string_literal)
- in [statement]
- statement
- else
- Begin(BodyStmt(Statements(statements), nil, nil, nil, nil))
- end
- end
-
- def silent(node)
- case node
- in SyntaxTree::ReturnNode
- node
- else
- Begin(
- BodyStmt(
- Statements([node, VarRef(Kw("nil"))]),
- nil,
- nil,
- nil,
- nil
- )
- )
- end
- end
-
- def compute(node)
- # MethodAddBlock(
- # call_helpers(:compute),
- # BlockNode(
- # Kw("do"),
- # nil,
- # BodyStmt(Statements([node]), nil, nil, nil, nil)
- # )
- # )
- node
- end
-
- def mayu_const_path
- ConstPathRef(VarRef(Const("Mayu")), Const("VDOM"))
- # Const("Mayu")
- end
-
- def call_helpers(method, *args)
- CallNode(
- Ident("mayu"),
- Period("."),
- Ident(method.to_s),
- wrap_args(args.flatten.compact)
- )
- end
-
- def helper_ident
- if @options.enable_new_helper_ident
- CallNode(VarRef(Kw("self")), Period("."), Ident("Mayu"), nil)
- else
- Ident("mayu")
- end
- end
-
- def wrap_args(args)
- args.empty? ? nil : ArgParen(Args(args))
- end
-
- def string_literal(value) =
- StringLiteral([TStringContent(value.to_s)], '"')
- def call_freeze(node) =
- CallNode(node, Period("."), Ident("freeze"), nil)
- end
-
- class ParseError < StandardError
- end
-
- class Transformer < SyntaxTree::Haml::Visitor
- class Result < T::Struct
- const :program, SyntaxTree::Program
- const :styles, T::Array[CSS::TransformResult]
-
- def source
- SyntaxTree::Formatter.format("", program)
- end
- end
-
- def initialize(options)
- @options = options
- @builder = RubyBuilder.new(options)
- @state = {}
- end
-
- def visit_root(node)
- setup = []
- styles = []
- render = []
-
- node.children.each do |child|
- case child
- in { type: :filter, value: { name: "ruby" } }
- if setup.empty? && styles.empty?
- setup.push(child)
- else
- render.push(child)
- end
- in type: :script | :silent_script
- render.push(child)
- in { type: :filter, value: { name: "css" } }
- styles.push(child.accept(self))
- in type: :tag
- render.push(child)
- end
- end
-
- Result.new(
- program:
- @builder.create_program(
- group_control_statements(setup),
- styles,
- group_control_statements(render)
- ),
- styles:
- )
- end
-
- def visit_haml_comment(node)
- nil
- end
-
- def visit_slot_tag(node)
- node.value => { attributes:, dynamic_attributes: }
-
- name = nil
-
- if new = dynamic_attributes.new
- parse_ruby(dynamic_attributes.new) => [parsed_attributes]
- hash = parsed_attributes.accept(HashKeyExtractorVisitor.new)
-
- name = hash[:name] || hash["name"]
- end
-
- if attr = attributes["name"]
- name ||= @builder.string_literal(attr)
- end
-
- return(
- @builder.slot(
- name,
- fallback: node.children.map { _1.accept(self) }
- )
- )
- end
-
- def visit_tag(node)
- node.value => {
- name:, attributes:, dynamic_attributes:, self_closing:, value:
- }
-
- return visit_slot_tag(node) if name == "slot"
-
- attrs = []
-
- if @options.transform_elements_to_classes
- attrs.push(@builder.props_hash(class: :"__#{name}"))
- end
-
- attrs.push(@builder.props_hash(attributes)) unless attributes.empty?
-
- if old = dynamic_attributes.old
- attrs.push(*parse_ruby(old))
- end
-
- if new = dynamic_attributes.new
- attrs.push(
- *parse_ruby(new)
- .map { _1.accept(string_keys_to_labels_mutation_visitor) }
- .map { _1.accept(wrap_handler_mutation_visitor) }
- )
- end
-
- if object_ref = node.value[:object_ref]
- unless object_ref == :nil
- parse_ruby(object_ref) => [key]
- attrs.push(@builder.props_hash(key:))
- end
- end
-
- @builder.tag(
- name,
- if value
- if node.value[:parse]
- parse_ruby(value, fix: false) => statements
- @builder.ruby_script(statements)
- elsif !value.empty?
- @builder.string_literal(value.to_s)
- end
- else
- visit_tag_children(node.children)
- end,
- attrs
- )
- end
-
- def visit_tag_children(children)
- children
- .reject { _1 in { type: :plain, value: { text: "" } } }
- .then { join_plain_nodes(_1) }
- .then { prepend_whitespace(_1) }
- .then { append_whitespace(_1) }
- .then { group_control_statements(_1) }
- .flatten
- end
-
- def join_plain_nodes(children)
- children
- .chunk_while do |prev, curr|
- (
- (prev in { type: :plain, value: { text: prev_text } }) &&
- (curr in { type: :plain, value: { text: new_text } })
- )
- end
- .map do |chunk|
- case chunk
- in [{ type: :plain } => first, *]
- text = chunk.map { _1.value[:text].to_s.strip }.join(" ")
- first.value[:text] = text
- first
- else
- chunk
- end
- end
- .flatten
- .compact
- end
-
- IN_RE = /\A\s*in\s+/
-
- def group_control_statements(children)
- children
- .chunk_while do |a, b|
- case [a, b]
- in [
- { type: :script, value: { keyword: "if" | "elsif" } },
- { type: :script, value: { keyword: "elsif" | "else" } }
- ]
- true
- in [
- { type: :script, value: { keyword: "case" | "when" } },
- { type: :script, value: { keyword: "when" | "else" } }
- ]
- true
- in [
- {
- type: :script,
- value: { keyword: "case" } | { text: IN_RE }
- },
- {
- type: :script,
- value: { keyword: "else" } | { text: IN_RE }
- }
- ]
- true
- in [
- { type: :script, value: { keyword: "begin" } },
- {
- type: :script,
- value: { keyword: "rescue" | "else" | "ensure" }
- }
- ]
- true
- in [
- { type: :script, value: { keyword: "rescue" } },
- { type: :script, value: { keyword: "else" | "ensure" } }
- ]
- true
- in [
- { type: :script, value: { keyword: "else" } },
- { type: :script, value: { keyword: "ensure" } }
- ]
- true
- else
- false
- end
- end
- .map do |chunk|
- case chunk
- in [{ type: :script, value: { keyword: "if" } }, *]
- @builder.compute(group_condition(:if, chunk))
- in [{ type: :script, value: { keyword: "case" } }, *]
- @builder.compute(group_condition(:case, chunk))
- in [{ type: :script, value: { keyword: "begin" } }, *]
- @builder.compute(group_condition(:begin, chunk))
- else
- chunk.map { _1.accept(self) }
- end
- end
- .flatten
- .compact
- end
-
- def group_condition(type, chunk)
- parse_ruby(join_ruby_script_nodes(chunk), fix: true) => [statement]
-
- visitor = MutationVisitor.new
-
- chunk.shift if type == :case
-
- visitor.mutate("Statements") do |node|
- top = chunk.shift
-
- if node.child_nodes in [SyntaxTree::VoidStmt]
- @builder.Statements(visit_tag_children(top.children))
- else
- unless top.children.empty?
- raise "Line #{top.line} should not have children."
- end
-
- node
- end
- end
-
- @builder.ruby_script([statement.accept(visitor)])
- end
-
- def join_ruby_script_nodes(nodes)
- nodes.map { _1.value[:text] }.join("\n")
- end
-
- def prepend_whitespace(children)
- [nil, *children].each_cons(2)
- .map do |prev, curr|
- if prev in {
- type: :tag, value: { nuke_outer_whitespace: true }
- }
- if curr in { type: :plain, value: { text: } }
- curr.value = { text: " #{text}" }
- else
- next make_space(curr), curr
- end
- end
-
- curr
- end
- end
-
- def append_whitespace(children)
- [*children, nil].each_cons(2)
- .flat_map do |curr, succ|
- if succ in {
- type: :tag, value: { nuke_inner_whitespace: true }
- }
- if curr in { type: :plain, value: { text: } }
- curr.value = { text: "#{text} " }
- else
- next curr, make_space(curr)
- end
- end
-
- curr
- end
- end
-
- def make_space(ref_node)
- ::Haml::Parser::ParseNode.new(
- :plain,
- ref_node.line,
- { text: " " },
- ref_node.parent,
- []
- )
- end
-
- def visit_filter(node)
- case node.value
- in { name: "ruby", text: }
- @builder.ruby_script(parse_ruby(text)) if text
- in { name: "css", text: }
- CSS.transform(
- source: text,
- source_path:
- @options.source_path_without_extension + ".haml (inline css)",
- source_line: node.line
- )
- in { name: "plain", text: }
- case text.inspect.each_line.to_a
- in []
- # noop
- in [line]
- @builder.string_literal(text)
- in [*lines]
- @builder.Heredoc(lines.map { @builder.TStringContent(_1) })
- end
- end
- end
-
- def visit_plain(node)
- node.value => { text: }
- @builder.string_literal(text)
- end
-
- def visit_script(node)
- case node.value[:text].strip
- when /\Areturn\s+(?if|unless)\s+(?.+)/
- $~ => { type:, condition_source: }
-
- parse_ruby(condition_source, fix: true) => [condition]
-
- statements =
- @builder.Statements(
- [
- @builder.ReturnNode(
- @builder.Args(visit_tag_children(node.children))
- )
- ]
- )
-
- case type
- in "if"
- @builder.IfNode(condition, statements, nil)
- in "unless"
- @builder.UnlessNode(condition, statements, nil)
- end
- when /\Areturn/
- @builder.ReturnNode(
- @builder.Args(visit_tag_children(node.children))
- )
- else
- @builder.compute(transform_script_node(node))
- end
- end
-
- def with_state(name, value, &block)
- @state[name], prev = value, @state[name]
- yield prev
- ensure
- @state[name] = prev
- end
-
- def visit_silent_script(node)
- with_state(:is_silent, true) do |was_silent|
- if was_silent
- visit_script(node)
- else
- @builder.silent(visit_script(node))
- end
- end
- end
-
- def transform_script_node(node)
- source = node.value.fetch(:text).strip
-
- if node.children.empty?
- parse_ruby(source, fix: false) => statements
- return @builder.ruby_script(statements)
- end
-
- parse_ruby(source, fix: true) => [statement]
-
- visitor = MutationVisitor.new
-
- visitor.mutate("Statements[body: [VoidStmt]]") do
- @builder.Statements(visit_tag_children(node.children))
- end
-
- @builder.ruby_script([statement.accept(visitor)])
- end
-
- def parse_ruby(source, fix: false)
- source = fix_syntax_by_adding_missing_pairs(source) if fix
-
- SyntaxTree.parse(source).statements.body
- rescue SyntaxTree::Parser::ParseError => e
- explain =
- SyntaxSuggest::ExplainSyntax.new(
- code_lines: SyntaxSuggest::CodeLine.from_source(source)
- ).call
-
- msg = ["Failed parsing Ruby: #{source}"]
-
- msg.push <<~MSG unless explain.errors.empty?
- Errors:
- #{explain.errors.join(" \n")}
- MSG
-
- msg.push <<~MSG unless explain.missing.empty?
- Missing:
- #{explain.missing.map { explain.why(_1) }.join(" \n")}
- MSG
-
- raise ParseError, "\n#{msg.join("\n")}"
- end
-
- def fix_syntax_by_adding_missing_pairs(source)
- left_right = SyntaxSuggest::LeftRightLexCount.new
- SyntaxSuggest::LexAll.new(source:).each { left_right.count_lex(_1) }
- left_right.missing
- [source, *left_right.missing].join("\n")
- end
-
- def wrap_handler_mutation_visitor
- visitor = MutationVisitor.new
-
- visitor.mutate(
- "Assoc[key: Label, value: VCall[value: Ident]]"
- ) do |assoc|
- if assoc.key.value.start_with?("on")
- handler_name = @builder.SymbolLiteral(assoc.value.value)
-
- @builder.Assoc(
- assoc.key,
- @builder.call_helpers(:handler, [handler_name])
- )
- else
- assoc
- end
- end
-
- visitor
- end
-
- def string_keys_to_labels_mutation_visitor
- visitor = MutationVisitor.new
-
- visitor.mutate("Assoc[key: StringLiteral]") do |assoc|
- @builder.Assoc(
- @builder.Label(
- assoc.key.parts.map(&:value).join.gsub("-", "_") + ":"
- ),
- assoc.value
- )
- end
-
- visitor
- end
- end
-
- class StateAndPropsTransformer
- include SyntaxTree::DSL
-
- COLLECTIONS = {
- SyntaxTree::IVar => "state",
- SyntaxTree::GVar => "props"
- }
-
- def visitor
- MutationVisitor.build do |visitor|
- visitor.mutate(
- "VarRef[value: GVar[value: /\\A\\$\\w+/]]"
- ) { |var_ref| aref(var_ref.value) }
-
- visitor.mutate(
- "Assign[target: VarField[value: GVar]]"
- ) do |assign|
- assign => { target: { target: { value: var_name } } }
- loc = assign.target.location
- raise "Can not write to prop #{var_name} on line #{loc.start_line} col #{loc.start_column}"
- end
-
- visitor.mutate("VarRef[value: IVar]") do |var_ref|
- aref(var_ref.value)
- end
-
- # visitor.mutate(
- # "OpAssign[target: VarField[value: IVar]]"
- # ) do |assign|
- # assign.copy(target: aref_field(assign.target.value))
- # end
- #
- # visitor.mutate(
- # "Assign[target: VarField[value: IVar]]"
- # ) do |assign|
- # assign.copy(target: aref_field(assign.target.value))
- # end
- end
- end
-
- private
-
- def aref(node)
- ARef(
- call_self(COLLECTIONS.fetch(node.class)),
- Args([var_to_symbol(node)])
- )
- end
-
- def aref_field(node)
- ARefField(
- call_self(COLLECTIONS.fetch(node.class)),
- Args([var_to_symbol(node)])
- )
- end
-
- def call_self(method)
- CallNode(VarRef(Kw("self")), Period("."), Ident(method), nil)
- end
-
- def var_to_symbol(node)
- SymbolLiteral(Ident(strip_var_prefix(node.value)))
- end
-
- def strip_var_prefix(str)
- str[/\A[@$]?(.*)/, 1]
- end
- end
- class HashKeyExtractorVisitor
- extend T::Sig
-
- sig do
- params(node: SyntaxTree::HashLiteral).returns(
- T::Hash[T.untyped, T.untyped]
- )
- end
- def visit_hash(node)
- hash = {}
-
- node.assocs.each do |child|
- if extract_key(child.key) in key
- hash[key] = extract_value(child.value)
- end
- end
-
- hash
- end
-
- sig do
- params(node: SyntaxTree::Node).returns(
- T.nilable(T.any(String, Symbol))
- )
- end
- def extract_key(node)
- case node
- when SyntaxTree::StringLiteral
- node.parts => [{ value: }]
- value
- when SyntaxTree::Label
- node.value
- end
- end
-
- sig { params(node: SyntaxTree::Node).returns(T.untyped) }
- def extract_value(node)
- node
- end
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/transformers/haml.test.rb b/lib/mayu/resources/transformers/haml.test.rb
deleted file mode 100644
index c2e2c417..00000000
--- a/lib/mayu/resources/transformers/haml.test.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-# typed: true
-
-require "minitest/autorun"
-require "test_helper"
-
-require_relative "haml"
-require "rouge"
-
-class TestHaml < Minitest::Test
- EXAMPLES_ROOT = File.join(__dir__, "__test__", "haml")
-
- Dir[File.join(EXAMPLES_ROOT, "*.haml")].each do |input_path|
- basename = File.basename(input_path, ".*")
-
- if ENV["MATCH"] in String => match
- next unless basename.include?(match)
- end
-
- skip_path = File.join(EXAMPLES_ROOT, "#{basename}.skip")
- output_path = File.join(EXAMPLES_ROOT, "#{basename}.rb")
-
- input = File.read(input_path)
- expected = File.read(output_path)
-
- define_method(:"test_#{basename}") do
- T.bind(self, TestHaml)
- skip File.read(skip_path) if File.exist?(skip_path)
- actual = transform_and_format(input, path: input_path)
- # File.write(output_path, actual)
- assert_equal(expected, actual)
- end
- end
-
- private
-
- def transform_and_format_file(root:, path:)
- transform_and_format(File.read(File.join(root, path)), path:)
- end
-
- def transform_and_format(
- haml,
- transform_elements_to_classes: false,
- path: nil
- )
- transformed =
- Mayu::Resources::Transformers::Haml.transform(
- Mayu::Resources::Transformers::Haml::TransformOptions.new(
- source: haml,
- source_path: "app/components/MyComponent.haml",
- source_line: 1,
- content_hash: "abc123",
- transform_elements_to_classes:
- )
- ).output
-
- puts "\e[1mInput:\e[0;2m #{path}\e[0m"
- puts prepend_line_numbers(
- colorize_source(haml, Rouge::Lexers::Haml.new).each_line
- )
- handle_parse_error(transformed) do
- formatted = SyntaxTree.format(transformed)
- puts "\e[1mOutput:\e[0m"
- puts prepend_line_numbers(
- colorize_source(formatted, Rouge::Lexers::Ruby).each_line
- )
- puts
- formatted
- end
- end
-
- def handle_parse_error(source)
- yield
- rescue SyntaxTree::Parser::ParseError => e
- start_line = [0, 0].max
- formatted_source =
- prepend_line_numbers(
- extract_lines(source.to_s, start_line, -1),
- start_line: start_line + 1,
- error_line: e.lineno
- ).join
-
- Console.logger.error(self, <<~ERROR)
- #{e.message} on line #{e.lineno} col #{e.column}
- #{formatted_source}
- ERROR
-
- raise
- end
-
- def extract_lines(str, from, to)
- str.each_line.to_a[from..to] || []
- end
-
- def colorize_source(source, lexer)
- theme = Rouge::Themes::Monokai.new
- formatter = Rouge::Formatters::Terminal256.new(theme:)
- formatter.format(lexer.lex(source.chomp))
- end
-
- def prepend_line_numbers(lines, start_line: 1, error_line: nil)
- number_format = "\e[38;5;250;48;5;236m%3d \e[0m"
- error_format = "\e[41m%s\e[0m"
-
- lines
- .map
- .with_index(start_line) do |line, i|
- if error_line == i
- format(error_format, line.chomp) + "\n"
- else
- line
- end.prepend(format(number_format, i))
- end
- end
-end
diff --git a/lib/mayu/resources/types.rb b/lib/mayu/resources/types.rb
deleted file mode 100644
index 069ac4d9..00000000
--- a/lib/mayu/resources/types.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "types/nil"
-require_relative "types/component"
-require_relative "types/image"
-require_relative "types/stylesheet"
-require_relative "types/javascript"
-require_relative "types/svg"
-
-module Mayu
- module Resources
- module Types
- extend T::Sig
-
- sig { params(path: String).returns(T.class_of(Types::Base)) }
- def self.for_path(path)
- case path
- when /\.rb\z/
- return Component
- when /\.haml\z/
- return Component
- when /\.js\z/
- return JavaScript
- when /\.css\z/
- return Stylesheet
- when /\.(png|jpe?g|gif|webp)$\z/
- return Image
- when /\.svg\z/
- return SVG
- end
-
- raise "No type for #{path}"
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/README.md b/lib/mayu/resources/types/README.md
deleted file mode 100644
index 041cfc80..00000000
--- a/lib/mayu/resources/types/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Resources::Types
-
-These classes implement behaviors for different types of resources.
-
-Some resources can generate static files during build time.
-These static files should have a predictable filename based
-on the content hash + some options.
-Compressable types should be brotlied.
-The generated files could be named like this:
-
- 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU=.css
- 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU=.css.br
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=640w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=768w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=960w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=1024w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=1366w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=1600w.png
- M4CmsIPAxdPZRaERcnCA8mL7whpfFfArIziflCS0iUY=1920w.png
- eN3Sv_BzhBLUw72oUgA5sCa8YF74CJm0Z8Z97eQgofk=.css
- eN3Sv_BzhBLUw72oUgA5sCa8YF74CJm0Z8Z97eQgofk=.css.br
-
-## Resources::Types::Component
-
-Loads a `.rb`-file or `.haml`-file and transpiles the latter,
-then evaluates the code in the scope of a new class that inherits
-`Mayu::Component::Base`.
-
-## Resources::Types::Image
-
-Loads an image, stores its size and information about which
-versions to generate for `srcset`.
-
-During build time it will generate smaller versions of the image,
-and store them in the configured directory for static files.
diff --git a/lib/mayu/resources/types/base.rb b/lib/mayu/resources/types/base.rb
deleted file mode 100644
index 83c88f3f..00000000
--- a/lib/mayu/resources/types/base.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "../resource"
-require_relative "../asset"
-
-module Mayu
- module Resources
- module Types
- class Base
- extend T::Sig
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- @resource = resource
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- []
- end
-
- sig { returns(String) }
- def name
- self.class.name.to_s.sub(/.*::/, "")
- end
-
- sig { params(assets_dir: String).returns(T::Array[Asset]) }
- def generate_assets(assets_dir)
- []
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/component.rb b/lib/mayu/resources/types/component.rb
deleted file mode 100644
index 86349403..00000000
--- a/lib/mayu/resources/types/component.rb
+++ /dev/null
@@ -1,198 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "base"
-require_relative "../../component/base"
-require_relative "../transformers/haml"
-
-module Mayu
- module Resources
- module Types
- class Component < Base
- module LoaderUtils
- extend T::Sig
-
- sig { params(mod: Module, resource: Resources::Resource).void }
- def self.define_import(mod, resource)
- mod.instance_exec(resource) do |resource|
- define_singleton_method(:__resource) { resource }
-
- sig do
- params(path: String).returns(T.class_of(Mayu::Component::Base))
- end
- def self.import(path)
- __resource.import(path) => Component => impl
- impl.component
- end
-
- sig { params(path: String).returns(Image) }
- def self.image(path)
- __resource.import(path) => Image => impl
- impl
- end
-
- sig { params(path: String).returns(SVG) }
- def self.svg(path)
- __resource.import(path) => SVG => impl
- impl
- end
- end
- end
- end
-
- extend T::Sig
-
- ComponentBase = Mayu::Component::Base
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- @resource = resource
-
- original_source = T.let(resource.read(encoding: "utf-8"), String)
-
- source =
- case File.extname(resource.path)
- when ".haml"
- transform_result =
- Transformers::Haml.transform(
- Transformers::Haml::TransformOptions.new(
- source: original_source,
- source_path: resource.path,
- source_line: 1
- )
- )
- source = transform_result.output
-
- @inline_css =
- T.let(
- transform_result.css,
- T.nilable(Transformers::CSS::TransformResult)
- )
-
- source
- else
- original_source
- end
-
- @source = T.let(source, String)
- @component = T.let(nil, T.nilable(T.class_of(ComponentBase)))
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- return [] unless @inline_css
-
- source_map_link =
- "\n/*# sourceMappingURL=#{@inline_css.filename}.map */\n"
-
- [
- Asset.new(
- @inline_css.filename,
- Generators::WriteFile.new(
- contents: @inline_css.output + source_map_link,
- compress: true
- )
- ),
- Asset.new(
- @inline_css.filename + ".map",
- Generators::WriteFile.new(
- contents: JSON.generate(@inline_css.source_map),
- compress: true
- )
- )
- ]
- end
-
- sig { returns(T.class_of(ComponentBase)) }
- def component
- @component ||= setup_component
- end
-
- sig { returns(T.class_of(Mayu::Component::Base)) }
- def setup_component
- impl = Class.new(Mayu::Component::Base)
-
- LoaderUtils.define_import(impl, @resource)
-
- impl.__mayu_resource = @resource
-
- impl.const_set(:INLINE_CSS_ASSETS, assets)
- impl.const_set(:H, Mayu::VDOM::H)
-
- begin
- # $stderr.puts "\e[33m#{@source}\e[0m"
- impl.class_eval(@source, @resource.path, 1)
- rescue SyntaxTree::Parser::ParseError => e
- $stderr.puts "\e[31mError parsing #{@resource.path}:#{e.lineno} #{e.message}\e[0m"
-
- puts "Error on line #{e.lineno}"
- @source
- .each_line
- .with_index(1) do |line, lineno|
- if lineno == e.lineno
- puts "\e[31m#{line.chomp}\e[0m"
- else
- puts "\e[33m#{line.chomp}\e[0m"
- end
- end
- rescue => e
- backtrace =
- [*e.backtrace].reject { _1.include?("/gems/sorbet-runtime-") }
- .join("\n")
- $stderr.puts "\e[31mError loading #{@resource.path}: #{e.class.name}: #{e.message}\n\e[33m#{backtrace}\e[0m"
- $stderr.puts "\e[33m#{@source}\e[0m"
- raise "Error parsing #{@resource.absolute_path}"
- end
-
- styles =
- @resource.registry.add_resource(
- @resource.path.sub(/\.\w+\z/, ".css")
- )
-
- @resource.registry.dependency_graph.add_dependency(
- @resource.path,
- styles.path
- )
-
- classes = T.let(Hash.new, T::Hash[Symbol, String])
-
- if styles.type.is_a?(Types::Stylesheet)
- classes.merge!(styles.type.classes)
-
- impl.instance_exec(styles) do |styles|
- define_singleton_method(:stylesheet) { styles.type }
- end
- end
-
- classes.merge!(@inline_css.classes) if @inline_css
-
- unless classes.empty?
- impl.instance_exec(
- Resources::Types::Stylesheet::ClassNames.new(classes)
- ) do |classnames|
- define_singleton_method(:styles) { classnames }
- define_method(:styles) { classnames }
- end
- end
-
- impl
- end
-
- MarshalFormat =
- T.type_alias do
- [String, T.nilable(Transformers::CSS::TransformResult)]
- end
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@source, @inline_css]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @source, @inline_css = args
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/image.rb b/lib/mayu/resources/types/image.rb
deleted file mode 100644
index 18848b6a..00000000
--- a/lib/mayu/resources/types/image.rb
+++ /dev/null
@@ -1,169 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require "image_size"
-require "base64"
-require_relative "base"
-
-module Mayu
- module Resources
- module Types
- class Image < Base
- extend T::Sig
-
- FORMATS = T.let([:webp], T::Array[Symbol])
-
- BREAKPOINTS =
- T.let(
- [120, 240, 320, 640, 768, 960, 1024, 1366, 1600, 1920, 3840].freeze,
- T::Array[Integer]
- )
-
- class ImageDescriptor < T::Struct
- const :format, Symbol
- const :width, Integer
- const :height, Integer
- const :filename, String
- end
-
- sig { returns(ImageDescriptor) }
- attr_reader :original
- sig { returns(T::Array[ImageDescriptor]) }
- attr_reader :versions
- sig { returns(String) }
- attr_reader :blur
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- @resource = resource
-
- content_hash = Base64.urlsafe_encode64(resource.content_hash)
- image_size = ImageSize.path(resource.absolute_path)
-
- extname = File.extname(resource.path)
- filename = "#{content_hash}.#{image_size.format}"
-
- @original =
- T.let(
- ImageDescriptor.new(
- format: image_size.format,
- width: image_size.width,
- height: image_size.height,
- filename: filename
- ),
- ImageDescriptor
- )
-
- breakpoints =
- BREAKPOINTS.select { _1 < image_size.width }.sort.reverse
- aspect_ratio = image_size.height / image_size.width.to_f
-
- formats = [image_size.format, *FORMATS].uniq
-
- @blur =
- T.let(
- [
- "convert",
- resource.absolute_path,
- "-resize",
- "16x16>",
- "-strip",
- "png:-"
- ].then { Shellwords.shelljoin(_1) }
- .then { `#{_1}` }
- .then { Base64.strict_encode64(_1) }
- .prepend("data:image/png;base64,"),
- String
- )
-
- @versions =
- T.let(
- formats
- .map do |format|
- breakpoints.map do |width|
- ImageDescriptor.new(
- format: format,
- width:,
- height: (width * aspect_ratio).to_i,
- filename: "#{content_hash}#{width}w.#{format}"
- )
- end
- end
- .flatten,
- T::Array[ImageDescriptor]
- )
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- [
- Asset.new(
- @original.filename,
- Generators::CopyFile.new(@resource.absolute_path)
- ),
- *@versions.map do |version|
- Asset.new(
- version.filename,
- Generators::Image.new(@resource.absolute_path, version)
- )
- end
- ]
- end
-
- sig { params(asset_dir: String).returns(T::Array[Asset]) }
- def generate_assets(asset_dir)
- assets.each { |asset| asset.process(asset_dir) }
- end
-
- sig { returns(String) }
- def src
- "/__mayu/static/#{@original.filename}"
- end
-
- sig { returns(String) }
- def srcset
- [@original, *@versions.filter { _1.format == :webp }].map do |version|
- "/__mayu/static/#{version.filename} #{version.width}w"
- end
- .reverse
- .join(",")
- end
-
- sig { returns(String) }
- def sizes
- [
- "100vw",
- *@versions
- .filter { _1.format == :webp }
- .map do |version|
- "(max-width: #{version.width}px) #{version.width}px"
- end
- ].reverse.join(", ")
- end
-
- sig { returns(Float) }
- def aspect_ratio
- original.width / original.height.to_f
- end
-
- MarshalFormat =
- T.type_alias { [ImageDescriptor, T::Array[ImageDescriptor], String] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@original, @versions, @blur]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @original, @versions, @blur = args
- end
-
- sig { returns(String) }
- def inspect
- "#"
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/javascript.rb b/lib/mayu/resources/types/javascript.rb
deleted file mode 100644
index 0ccb5bad..00000000
--- a/lib/mayu/resources/types/javascript.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-require "brotli"
-
-module Mayu
- module Resources
- module Types
- class JavaScript < Base
- extend T::Sig
-
- sig { returns(String) }
- attr_reader :filename
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- super
- @filename =
- T.let(
- Base64.urlsafe_encode64(@resource.content_hash).+(".js").freeze,
- String
- )
- @source = T.let(resource.read(encoding: "utf-8").freeze, String)
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- [
- Asset.new(
- filename,
- Generators::WriteFile.new(contents: @source, compress: true)
- )
- ]
- end
-
- MarshalFormat = T.type_alias { [String, String] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@filename, @source]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @filename, @source = args
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/nil.rb b/lib/mayu/resources/types/nil.rb
deleted file mode 100644
index 533a6fa2..00000000
--- a/lib/mayu/resources/types/nil.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "base"
-
-module Mayu
- module Resources
- module Types
- class Nil < Base
- extend T::Sig
-
- sig { returns(NilClass) }
- def marshal_dump
- nil
- end
-
- sig { params(args: NilClass).void }
- def marshal_load(args)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/stylesheet.rb b/lib/mayu/resources/types/stylesheet.rb
deleted file mode 100644
index c6c4fc4f..00000000
--- a/lib/mayu/resources/types/stylesheet.rb
+++ /dev/null
@@ -1,119 +0,0 @@
-# typed: strict
-
-require "brotli"
-require_relative "../transformers/css"
-
-module Mayu
- module Resources
- module Types
- class Stylesheet < Base
- Classes = T.type_alias { T::Hash[Symbol, String] }
-
- class ClassNames
- extend T::Sig
-
- sig { params(classes: Classes).void }
- def initialize(classes)
- @classes = classes
- end
-
- sig { params(ident: Symbol).returns(String) }
- def method_missing(ident)
- @classes[ident].to_s
- end
-
- sig { params(args: T.untyped).returns(String) }
- def [](*args)
- args
- .each_with_object(Set.new) { |arg, set| add_to_result(set, arg) }
- .join(" ")
- end
-
- private
-
- sig { params(result: T::Set[String], arg: T.untyped).void }
- def add_to_result(result, arg)
- case arg
- when Symbol
- if klass = @classes[arg]
- result.add(klass)
- end
- when String
- result.add(arg)
- when Array
- arg.each { add_to_result(result, _1) }
- when Hash
- arg.each { add_to_result(result, _1) if _2 }
- end
- end
- end
-
- extend T::Sig
-
- sig { returns(Classes) }
- attr_reader :classes
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- super
- klasses = {}
-
- transform_result =
- Transformers::CSS.transform(
- source: resource.read(encoding: "utf-8"),
- source_path: resource.path
- )
-
- transform_result.classes
-
- @source = T.let(transform_result.output, String)
- @content_hash = T.let(transform_result.content_hash, String)
- @classes = T.let(transform_result.classes, Classes)
- @filename = T.let(transform_result.filename, String)
- @source_map =
- T.let(transform_result.source_map, T::Hash[String, T.untyped])
- @classnames = T.let(nil, T.nilable(ClassNames))
- end
-
- sig { returns(ClassNames) }
- def classnames
- @classnames ||= ClassNames.new(self.classes)
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- source_map_link = "\n/*# sourceMappingURL=#{@filename}.map */\n"
-
- [
- Asset.new(
- @filename,
- Generators::WriteFile.new(
- contents: @source + source_map_link,
- compress: true
- )
- ),
- Asset.new(
- @filename + ".map",
- Generators::WriteFile.new(
- contents: JSON.generate(@source_map),
- compress: true
- )
- )
- ]
- end
-
- MarshalFormat = T.type_alias { [Classes, String, String, String] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@classes, @source, @content_hash, @filename]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @classes, @source, @content_hash, @filename = args
- end
- end
- end
- end
-end
diff --git a/lib/mayu/resources/types/svg.rb b/lib/mayu/resources/types/svg.rb
deleted file mode 100644
index af42ce0c..00000000
--- a/lib/mayu/resources/types/svg.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-# typed: strict
-
-require_relative "base"
-
-module Mayu
- module Resources
- module Types
- class SVG < Base
- extend T::Sig
-
- sig { params(resource: Resource).void }
- def initialize(resource)
- @resource = resource
-
- source = resource.read(encoding: "utf-8")
-
- content_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(source))
-
- @filename = T.let("#{content_hash}.svg", String)
- @source = T.let(source, String)
- end
-
- sig { returns(T::Array[Asset]) }
- def assets
- [
- Asset.new(
- @filename,
- Generators::WriteFile.new(contents: @source, compress: true)
- )
- ]
- end
-
- sig { returns(String) }
- def to_s = src
-
- sig { returns(String) }
- def src = "/__mayu/static/#{@filename}"
-
- MarshalFormat = T.type_alias { [String, String] }
-
- sig { returns(MarshalFormat) }
- def marshal_dump
- [@source, @filename]
- end
-
- sig { params(args: MarshalFormat).void }
- def marshal_load(args)
- @source, @filename = args
- end
- end
- end
- end
-end
diff --git a/lib/mayu/routes.rb b/lib/mayu/routes.rb
index 2e752f29..8e24d8c8 100644
--- a/lib/mayu/routes.rb
+++ b/lib/mayu/routes.rb
@@ -1,169 +1,273 @@
-# typed: strict
+# frozen_string_literal: true
-require "terminal-table"
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "rack/utils"
+require "pathname"
module Mayu
module Routes
- extend T::Sig
+ Segment =
+ Data.define(:name, :views, :children) do
+ def regexp = Regexp.escape(name)
+ def pathname(params) = name
+ def to_s = name
+ end
- class NotFoundError < StandardError
- end
+ Group =
+ Data.define(:name, :views, :children) do
+ def regexp = nil
+ def pathname(params) = nil
+ def to_s = name
+ end
- class Route < T::Struct
- const :path, String
- const :regexp, Regexp
- const :layouts, T::Array[String]
- const :template, String
- end
+ Param =
+ Data.define(:name, :param, :views, :children) do
+ def regexp = "(?<#{Regexp.escape(param)}>[^\/]+)"
+ def pathname(params) = params.fetch(param)
+ def to_s = name
+ end
- class RouteMatch < T::Struct
- const :params, T::Hash[Symbol, String]
- const :layouts, T::Array[String]
- const :template, String
- end
+ SplatParam =
+ Data.define(:name, :param, :views) do
+ def self.[](name, param, views, children)
+ raise "Splat param must be last" unless children.empty?
- EXTENSIONS = T.let(%w[.rb .haml].freeze, T::Array[String])
+ new(name, param, views)
+ end
+ def regexp = "(?<#{Regexp.escape(param)}>.+)"
+ def pathname(params) = Array(params.fetch(param)).join("/")
+ def children = []
+ def to_s = name
+ end
- PAGE_FILENAME = "page"
- LAYOUT_FILENAME = "layout"
- NOT_FOUND_FILENAME = "404"
+ Root =
+ Data.define(:path, :views, :children) do
+ def regexp = nil
+ def pathname(params) = ""
+ def to_s = ""
+ end
- sig do
- params(
- root: String,
- routes: T::Array[Route],
- layouts: T::Array[String],
- path: T::Array[String],
- level: Integer
- ).returns(T::Array[Route])
- end
- def self.build_routes(root, routes: [], layouts: [], path: [], level: 0)
- dir = T.unsafe(File).join(root, *path)
- return routes unless File.directory?(dir)
+ Views =
+ Data.define(:page, :layout, :template, :not_found) do
+ def not_found! = with(page: not_found)
+ def not_found? = page == not_found
+ end
- entries = Dir.entries(dir) - %w[. ..]
+ Match = Data.define(:route, :params, :query)
- if layout = find_and_delete(entries, LAYOUT_FILENAME)
- layouts += [T.unsafe(File).join(*path, layout)]
+ module Utils
+ def self.parse_query(query)
+ query
+ .then { Rack::Utils.parse_nested_query(_1) }
+ .then { deep_symbolize_keys(_1) }
end
- if page = find_and_delete(entries, PAGE_FILENAME)
- routes.push(
- Route.new(
- path: path.join("/"),
- regexp: path_to_regexp(path.join("/")),
- layouts:,
- template: T.unsafe(File).join(*path, page)
- )
- )
- end
-
- entries.each do |entry|
- build_routes(
- File.join(root),
- routes:,
- layouts:,
- path: path + [entry],
- level: level.succ
- )
- end
-
- if not_found = find_and_delete(entries, NOT_FOUND_FILENAME)
- routes.push(
- Route.new(
- path: path.join("/"),
- regexp: path_to_regexp([*path, "*"].join("/")),
- layouts:,
- template: T.unsafe(File).join(*path, not_found)
- )
- )
- else
- Console.logger.warn(self) { <<~EOF } if level.zero?
- There is no #{NOT_FOUND_FILENAME} in the app root,
- you should probably create one.
- EOF
+ def self.deep_symbolize_keys(obj)
+ case obj
+ when Hash
+ obj
+ .transform_keys do |key|
+ case key
+ in /\A[[:digit:]+]\z/
+ key.to_i
+ in String
+ key.to_sym
+ else
+ key
+ end
+ end
+ .transform_values { |value| deep_symbolize_keys(value) }
+ else
+ obj
+ end
end
-
- routes
end
- sig { params(routes: T::Array[Route]).void }
- def self.log_routes(routes)
- Console
- .logger
- .info(self) do
- Terminal::Table.new do |t|
- t.headings =
- %w[Path Template Layouts Regexp].map { "\e[1m#{_1}\e[0m" }
- t.style = { all_separators: true, border: :unicode }
-
- routes.each do |route|
- t.add_row(
- [
- "/#{route.path}",
- route.template,
- route.layouts.join("\n"),
- "/#{route.regexp.to_s}/"
- ]
- )
+ Route =
+ Data.define(:regexp, :segments, :views, :layouts) do
+ def match(path, query)
+ if match = regexp.match(path)
+ Match[
+ self,
+ match
+ .named_captures
+ .transform_keys(&:to_sym)
+ .transform_values do
+ case _1.split("/")
+ in [one]
+ one
+ in many
+ many
+ end
+ end,
+ Utils.parse_query(query)
+ ]
+ end
+ end
+
+ def pathname(**params)
+ segments.map { _1.pathname(params) }.compact.join("/")
+ end
+ end
+
+ Router =
+ Data.define(:root_dir, :routes) do
+ def self.build(root_dir)
+ new(root_dir, Builder.build(root_dir))
+ end
+
+ def match(request_path)
+ routes.each do |route|
+ path, query = request_path.split("?", 2)
+
+ if match = route.match(path, query)
+ return match
end
end
+
+ nil
end
- end
- sig do
- params(routes: T::Array[Route], request_path: String).returns(RouteMatch)
- end
- def self.match_route(routes, request_path)
- routes.each do |route|
- match = route.regexp.match(request_path)
+ def all_templates
+ set = Set.new
- next unless match
+ routes.each do |route|
+ set.add(route.views.page)
+ route.layouts.each { |layout| set.add(layout) }
+ end
- return(
- RouteMatch.new(
- template: route.template,
- layouts: route.layouts,
- params:
- match
- .named_captures
- .transform_keys(&:to_sym)
- .transform_values(&:to_s)
+ set
+ end
+ end
+
+ class Builder
+ def self.build(root_dir)
+ new(root_dir).build
+ end
+
+ def initialize(root_dir)
+ @root_dir = root_dir
+ end
+
+ def build
+ root =
+ Root.new(
+ @root_dir,
+ build_page(@root_dir),
+ traverse_children(@root_dir)
)
- )
+
+ page_routes = []
+ not_found_routes = []
+
+ build_routes(root) do |route|
+ if route.views.not_found?
+ not_found_routes << route
+ else
+ page_routes << route
+ end
+ end
+
+ page_routes + not_found_routes.reverse
end
- raise NotFoundError,
- "Page not found, and no 404 page either. You should probably create one."
- end
+ def build_routes(node, parents = [], &block)
+ segments = [*parents, node].compact
- sig do
- params(path: String, formats: T::Hash[Symbol, Regexp]).returns(Regexp)
- end
- def self.path_to_regexp(path, formats: {})
- parts =
- path
- .delete_prefix("/")
- .split("/")
- .map do |part|
- case part
- when "*"
- ".+"
- when /\A:(?\w+)\Z/
- var = Regexp.escape($~[:var])
- "(?<#{var}>[^/]+)"
+ re_parts = segments.map(&:regexp).compact.join("/")
+
+ if node.views.page
+ yield(
+ Route[
+ Regexp.compile('\A/' + re_parts + '\z'),
+ segments,
+ node.views,
+ segments.map(&:views).map(&:layout).compact
+ ]
+ )
+ end
+
+ if node.views.not_found
+ yield(
+ Route[
+ Regexp.compile(
+ if segments.size == 1
+ '\A/' + re_parts + '.*\z'
+ else
+ '\A/' + re_parts + '/.*\z'
+ end
+ ),
+ segments,
+ node.views.not_found!,
+ segments.map(&:views).map(&:layout).compact
+ ]
+ )
+ end
+
+ node.children.each { |child| build_routes(child, segments, &block) }
+ end
+
+ def build_page(dir)
+ views = { page: nil, layout: nil, template: nil, not_found: nil }
+
+ Dir
+ .entries(dir)
+ .map do |entry|
+ full_path = File.join(dir, entry)
+
+ next unless File.file?(full_path)
+
+ path =
+ Pathname
+ .new(File.join(dir, entry))
+ .relative_path_from(@root_dir)
+ .to_s
+
+ case entry
+ in "page.haml"
+ views[:page] = path
+ in "layout.haml"
+ views[:layout] = path
+ in "template.haml"
+ views[:template] = path
+ in "not_found.haml"
+ views[:not_found] = path
else
- Regexp.escape(part).to_s
+ nil
end
end
- Regexp.new('\A\/' + parts.join('\/') + '\Z')
- end
+ Views.new(**views)
+ end
- sig { params(a: T::Array[String], name: String).returns(T.nilable(String)) }
- def self.find_and_delete(a, name)
- EXTENSIONS.find do |extension|
- a.delete("#{name}#{extension}")&.tap { return _1 }
+ def traverse_children(dir)
+ Dir
+ .each_child(dir)
+ .sort
+ .map do |entry|
+ path = File.join(dir, entry)
+
+ if File.directory?(path)
+ case entry
+ in /\A\::(.*)\Z/ # [param]
+ SplatParam[
+ entry,
+ $~[1],
+ build_page(path),
+ traverse_children(path)
+ ]
+ in /\A\:(.*)\Z/ # [param]
+ Param[entry, $~[1], build_page(path), traverse_children(path)]
+ in /\A\((.*)\)\Z/ # (group)
+ Group[entry, build_page(path), traverse_children(path)]
+ else
+ Segment[entry, build_page(path), traverse_children(path)]
+ end
+ end
+ end
+ .compact
end
end
end
diff --git a/lib/mayu/routes.test.rb b/lib/mayu/routes.test.rb
new file mode 100755
index 00000000..315fea49
--- /dev/null
+++ b/lib/mayu/routes.test.rb
@@ -0,0 +1,67 @@
+#!/usr/bin/env ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+
+require_relative "routes"
+
+class Mayu::Routes::Test < Minitest::Test
+ def test_router
+ router = setup_router
+
+ match = router.match("/")
+ assert_equal(%w[layout.haml], match.route.layouts)
+ assert_equal("page.haml", match.route.views.page)
+
+ match = router.match("/subpage")
+ assert_equal(%w[layout.haml], match.route.layouts)
+ assert_equal("subpage/page.haml", match.route.views.page)
+
+ match = router.match("/subpage2")
+ assert_equal(%w[layout.haml subpage2/layout.haml], match.route.layouts)
+ assert_equal("subpage2/page.haml", match.route.views.page)
+
+ match = router.match("/subpage2/hello")
+ assert_equal(%w[layout.haml subpage2/layout.haml], match.route.layouts)
+ assert_equal("subpage2/hello/page.haml", match.route.views.page)
+ end
+
+ def test_params
+ router = setup_router
+
+ match = router.match("/params/123")
+
+ assert_equal({ id: "123" }, match.params)
+ assert_equal(%w[layout.haml], match.route.layouts)
+ assert_equal("params/:id/page.haml", match.route.views.page)
+ end
+
+ def test_query
+ router = setup_router
+
+ match = router.match("/subpage2/hello?foo=bar")
+ assert_equal({ foo: "bar" }, match.query)
+
+ match = router.match("/subpage2/hello?values[]=foo&values[]=bar")
+ assert_equal({ values: %w[foo bar] }, match.query)
+
+ match = router.match("/subpage2/hello?things[0]=foo&things[1]=bar")
+ assert_equal({ things: { 0 => "foo", 1 => "bar" } }, match.query)
+ end
+
+ def test_not_found
+ router = setup_router
+
+ match = router.match("/non-existant-route")
+ assert_equal(match.route.views.page, match.route.views.not_found)
+ end
+
+ private
+
+ def setup_router
+ Mayu::Routes::Router.build(File.join(__dir__, "__test__", "routes"))
+ end
+end
diff --git a/lib/mayu/routing.rb b/lib/mayu/routing.rb
deleted file mode 100644
index b7ee5e9f..00000000
--- a/lib/mayu/routing.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-require_relative "routing/routes"
-require_relative "routing/builder"
-require_relative "routing/matcher"
-
-module Mayu
- module Routing
- end
-end
-
-# root = Routing::Builder.build(File.join(__dir__, "example", "app", "pages"))
-# matcher = Routing::Matcher.new(root)
-# p matcher.match("/pokemon")
-# p matcher.match("/pokemon/123")
-# p matcher.match("/pokemon/123/asd")
diff --git a/lib/mayu/routing/builder.rb b/lib/mayu/routing/builder.rb
deleted file mode 100644
index 4f8bf2be..00000000
--- a/lib/mayu/routing/builder.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- module Routing
- class Builder
- extend T::Sig
-
- IGNORE = T.let(%w[. ..].freeze, T::Array[String])
- EXTENSIONS = T.let(%w[.rb .haml].freeze, T::Array[String])
-
- sig { params(root: String).returns(Routes::Route) }
- def self.build(root)
- new(root).build
- end
-
- sig { params(root: String).void }
- def initialize(root)
- @root = T.let(File.expand_path(root), String)
- end
-
- sig { returns(Routes::Route) }
- def build
- traverse_directory(Routes::Route.new, path: [])
- end
-
- private
-
- sig do
- params(route: Routes::Route, path: T::Array[String]).returns(
- Routes::Route
- )
- end
- def visit(route, path: [])
- absolute_path = File.join(@root, path)
- basename = File.basename(absolute_path)
- stat = File.stat(absolute_path)
-
- case
- when stat.directory?
- visit_dir(route, path:)
- when stat.file?
- visit_file(route, path:)
- end
-
- route
- end
-
- sig do
- params(route: Routes::Route, path: T::Array[String]).returns(
- Routes::Route
- )
- end
- def visit_dir(route, path: [])
- if match = path.last.to_s.match(/\A:(\w+)\z/)
- route.add_route(
- traverse_directory(Routes::Param.new(match[1].to_s), path:)
- )
- else
- route.add_route(
- traverse_directory(Routes::Named.new(path.last.to_s), path:)
- )
- end
-
- route
- end
-
- sig do
- params(route: Routes::Route, path: T::Array[String]).returns(
- Routes::Route
- )
- end
- def traverse_directory(route, path: [])
- absolute_path = File.join(@root, path)
-
- Dir
- .entries(absolute_path)
- .difference(IGNORE)
- .each { |entry| visit(route, path: [*path, entry]) }
-
- route
- end
-
- sig do
- params(route: Routes::Route, path: T::Array[String]).returns(
- Routes::Route
- )
- end
- def visit_file(route, path: [])
- basename = path.last.to_s
- extname = File.extname(basename)
-
- if EXTENSIONS.include?(extname)
- case basename.delete_suffix(extname)
- when "page"
- route.page = basename
- when "layout"
- route.layout = basename
- when "404"
- route.not_found = basename
- end
- end
-
- route
- end
- end
- end
-end
diff --git a/lib/mayu/routing/matcher.rb b/lib/mayu/routing/matcher.rb
deleted file mode 100644
index 64296e28..00000000
--- a/lib/mayu/routing/matcher.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- module Routing
- class Matcher
- extend T::Sig
-
- class RouteError < StandardError
- end
-
- sig { params(root: Routes::Route).void }
- def initialize(root)
- @root = root
- end
-
- sig { params(path: String).void }
- def match(path)
- layouts = []
- parts = []
- params = {}
-
- not_found = T.let(nil, T.nilable(String))
-
- found =
- path
- .delete_prefix("/")
- .split("/")
- .reduce(@root) do |curr, part|
- layouts.push(File.join("", *parts, curr.layout)) if curr.layout
-
- if curr.not_found
- not_found = File.join("", *parts, curr.not_found)
- end
-
- match = curr.match(part)
-
- break unless match
-
- if match.is_a?(Routes::Param)
- params[match.name.to_sym] = part
- parts.push(":#{part}")
- else
- parts.push(part)
- end
-
- match
- end
-
- return { layouts:, component: File.join("", *parts), params: } if found
-
- return { layouts: [], component: not_found, params: } if not_found
-
- raise RouteError, "No 404 page configured, put one in #{@root}"
- end
- end
- end
-end
diff --git a/lib/mayu/routing/routes.rb b/lib/mayu/routing/routes.rb
deleted file mode 100644
index 332eda45..00000000
--- a/lib/mayu/routing/routes.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# typed: strict
-# frozen_string_literal: true
-
-module Mayu
- module Routing
- module Routes
- class Route
- extend T::Sig
-
- sig { returns(T.nilable(String)) }
- attr_accessor :page
- sig { returns(T.nilable(String)) }
- attr_accessor :layout
- sig { returns(T.nilable(String)) }
- attr_accessor :not_found
-
- sig { void }
- def initialize
- @page = nil
- @layout = nil
- @not_found = nil
- @named = T.let({}, T::Hash[String, Named])
- @params = T.let([], T::Array[Param])
- end
-
- sig { params(route: Route).void }
- def add_route(route)
- case route
- when Named
- @named[route.name] = route
- when Param
- @params.push(route)
- else
- raise TypeError, "Unknown route type: #{route.class}"
- end
- end
-
- sig { params(part: String).returns(T.nilable(Route)) }
- def match(part)
- @named.fetch(part) { @params.find { _1.match?(part) } }
- end
-
- sig { params(part: String).returns(T::Boolean) }
- def match?(part)
- true
- end
- end
-
- class Named < Route
- sig { returns(String) }
- attr_reader :name
-
- sig { params(name: String).void }
- def initialize(name)
- super()
- @name = name
- end
-
- sig { params(part: String).returns(T::Boolean) }
- def match?(part)
- part == @name
- end
- end
-
- class Param < Route
- sig { returns(String) }
- attr_reader :name
- sig { returns(Regexp) }
- attr_reader :format
-
- sig { params(name: String, format: Regexp).void }
- def initialize(name, format: /\A\d+\z/)
- super()
- @name = name
- @format = format
- end
-
- sig { params(part: String).returns(T::Boolean) }
- def match?(part)
- @format.match?(part)
- end
- end
- end
- end
-end
diff --git a/lib/mayu/runtime.rb b/lib/mayu/runtime.rb
new file mode 100644
index 00000000..77be052d
--- /dev/null
+++ b/lib/mayu/runtime.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Runtime
+ autoload :Engine, File.join(__dir__, "runtime", "engine")
+
+ def self.init(descriptor, metrics:, runtime_js:)
+ Engine.new(descriptor, metrics:, runtime_js:)
+ end
+ end
+end
diff --git a/lib/mayu/runtime.test.rb b/lib/mayu/runtime.test.rb
new file mode 100755
index 00000000..7d8734ad
--- /dev/null
+++ b/lib/mayu/runtime.test.rb
@@ -0,0 +1,169 @@
+#!/usr/bin/env -S ruby -rbundler/setup
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "minitest/autorun"
+require_relative "./test"
+
+class Mayu::Runtime::Test < Minitest::Test
+ H = Mayu::Runtime::H
+ include Mayu::Test::Helpers
+
+ class Counter < Mayu::Component::Base
+ def initialize
+ @count = 0
+ end
+
+ def handle_increment
+ update!(@count += 1)
+ end
+
+ def render
+ H[
+ :section,
+ H[:output, @count],
+ H[:button, "Increment", onclick: H.callback(self, :handle_increment)],
+ (H[:p, "Count is over 3", class: ["over"]] if @count > 3),
+ (H[:p, "Count is below 7"] if @count < 7)
+ ]
+ end
+ end
+
+ class Inputs < Mayu::Component::Base
+ def initialize
+ @value = ""
+ end
+
+ def handle_input(e)
+ update!(@value = e.dig(:currentTarget, :value).to_s)
+ end
+
+ def render
+ H[
+ :fieldset,
+ H[:legend, "Inputs"],
+ H[:input, name: "hello", oninput: H.callback(self, :handle_input)],
+ H[:output, @value.reverse.inspect]
+ ]
+ end
+ end
+
+ def test_engine
+ descriptor =
+ H[
+ :body,
+ H[:header, H[:h1, "My webpage"]],
+ H[
+ :main,
+ H[:h2, "Welcome"],
+ H[:p, "Welcome to my webpage"],
+ H[Counter],
+ H[Inputs]
+ ],
+ H[:footer, H[:p, "Copyright"]]
+ ]
+
+ # enable_step!
+
+ render(descriptor) do |page|
+ input = find!("input", name: "hello")
+
+ input.type_input("hello world".reverse) { page.step }
+
+ button = find!("button", text: "Increment")
+
+ 10.times do
+ button.click
+ page.step
+ end
+
+ assert_equal("10", find!("output").content)
+ end
+ end
+
+ class TitleThing < Mayu::Component::Base
+ def initialize
+ @enabled = false
+ end
+
+ def handle_toggle
+ puts "\e[3;34mTOGGLING\e[0m"
+ update!(@enabled = !@enabled)
+ end
+
+ def render
+ puts "\e[3;34mRENDERING\e[0m"
+
+ [
+ (
+ if @enabled
+ H[
+ :head,
+ H[:title, "TitleThing"],
+ H[:meta, name: "description", value: "title thing description"],
+ H[:meta, name: "keywords", value: "title, thing, titlething"]
+ ]
+ end
+ ),
+ (H[:p, "Enabled: #{@enabled.inspect}"]),
+ H[:button, "Toggle", onclick: H.callback(self, :handle_toggle)]
+ ]
+ end
+ end
+
+ def test_head
+ descriptor =
+ H[
+ :body,
+ H[
+ :head,
+ H[:title, "initial title"],
+ H[:meta, name: "description", value: "initial description"]
+ ],
+ H[:main, H[:p, "hello world"], H[TitleThing]]
+ ]
+
+ render(descriptor) do |page|
+ # enable_step!
+
+ assert_equal("initial title", at_xpath("/html/head/title").content)
+
+ assert_equal("initial title", find!("title").content)
+=begin
+ assert_equal(
+ "initial description",
+ find!("meta", name: "description")[:value]
+ )
+ assert_nil(find("meta", name: "keywords"))
+
+ button = find!("button")
+
+ button.click
+ page.step
+
+ assert_equal("TitleThing", find!("title").content)
+ assert_equal(
+ "title thing description",
+ find!("meta", name: "description")[:value]
+ )
+ assert_equal(
+ "title, thing, titlething",
+ find!("meta", name: "keywords")[:value]
+ )
+
+ button.click
+ page.step
+
+ assert_equal("initial title", find!("title").content)
+ assert_equal(
+ "initial description",
+ find!("meta", name: "description")[:value]
+ )
+ assert_nil(find("meta", name: "keywords"))
+ page.step
+=end
+ end
+ end
+end
diff --git a/lib/mayu/runtime/descriptors.rb b/lib/mayu/runtime/descriptors.rb
new file mode 100644
index 00000000..c98898c0
--- /dev/null
+++ b/lib/mayu/runtime/descriptors.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require_relative "marshalling"
+
+module Mayu
+ module Runtime
+ module Descriptors
+ Element =
+ Data.define(:type, :key, :slot, :children, :props) do
+ def self.[](type, *children, key: nil, slot: nil, **props)
+ new(type, key, slot, Children[children], props)
+ end
+
+ def same?(other)
+ if key == other.key && type == other.type
+ if type == :input
+ # Inputs are considered to be different if their type changes.
+ # Is this a good behavior? I think maybe it comes from from Preact.
+ props[:type] == other.props[:type]
+ else
+ true
+ end
+ else
+ false
+ end
+ end
+
+ def marshal_dump
+ [
+ Marshalling.dump_value(type),
+ key,
+ slot,
+ children,
+ Marshalling.dump_value(props)
+ ]
+ end
+
+ def marshal_load(a)
+ type, key, slot, children, props = a
+ initialize(
+ type: Marshalling.load_value(type),
+ key:,
+ slot:,
+ children:,
+ props: Marshalling.load_value(props)
+ )
+ end
+ end
+
+ Children =
+ Data.define(:descriptors, :slots) do
+ def self.[](descriptors)
+ new(
+ descriptors,
+ descriptors.group_by do |descriptor|
+ (descriptor in Element[slot:]) ? slot : nil
+ end
+ )
+ end
+
+ def to_ary
+ descriptors
+ end
+
+ def marshal_dump
+ [descriptors]
+ end
+
+ def marshal_load(a)
+ descriptors = a.first || []
+ initialize(
+ descriptors:,
+ slots:
+ descriptors.group_by do |descriptor|
+ (descriptor in Element[slot:]) ? slot : nil
+ end
+ )
+ end
+ end
+
+ Comment = Data.define(:content) { alias to_s content }
+ RawText = Data.define(:content) { alias to_s content }
+ Context =
+ Data.define(:values, :children) do
+ def marshal_dump
+ [Marshalling.dump_value(values), children]
+ end
+
+ def marshal_load(a)
+ values, children = a
+ initialize(values: Marshalling.load_value(values), children:)
+ end
+ end
+
+ Callback =
+ Data.define(:component, :method_name) do
+ def same?(other) =
+ self.class === other && component == other.component &&
+ method_name == other.method_name
+ end
+
+ Slot = Data.define(:component, :name, :fallback)
+
+ def self.same?(a, b)
+ case [a, b]
+ in [Element, Element]
+ a.same?(b)
+ in [^(a), ^(a.class)]
+ true
+ else
+ false
+ end
+ end
+
+ def self.descriptor_or_string(descriptor)
+ case descriptor
+ in Element | RawText | Context
+ descriptor
+ else
+ (descriptor && descriptor.to_s) || nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/runtime/dom.rb b/lib/mayu/runtime/dom.rb
new file mode 100644
index 00000000..d63e2d8e
--- /dev/null
+++ b/lib/mayu/runtime/dom.rb
@@ -0,0 +1,223 @@
+# frozen_string_literal: true
+
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "cgi"
+require_relative "patches"
+require_relative "inline_style"
+
+module Mayu
+ module Runtime
+ module DOM
+ INJECT_MAYU_ID = ENV["INJECT_MAYU_ID"] == "1"
+
+ VOID_ELEMENTS = %w[
+ area
+ base
+ br
+ col
+ embed
+ hr
+ img
+ input
+ link
+ meta
+ param
+ source
+ track
+ wbr
+ ].freeze
+
+ IdNode =
+ Data.define(:id, :name, :children) do
+ def self.[](id, name, children = nil)
+ new(id, name, children)
+ end
+
+ def inspect
+ format("%s#%s%s", name, id, (children || []).inspect)
+ end
+
+ def pretty_print(q)
+ color = name.start_with?("#") ? "36" : "34"
+
+ q.group(
+ 1,
+ "#{" " * q.indent}\e[1;#{color}m#{name}\e[0;2m #{id}\e[0m\n"
+ ) { children&.each { |child| q.pp child } }
+ end
+
+ def serialize
+ if c = children
+ { id:, name:, children: c.flatten.compact.map(&:serialize) }
+ else
+ { id:, name: }
+ end
+ end
+
+ def to_msgpack(packer)
+ packer.pack(serialize)
+ end
+ end
+
+ Document =
+ Data.define(:id, :children) do
+ def self.[](*children, id:)
+ new(id, children.flatten)
+ end
+
+ def type = "#document"
+ def text_content
+ children.map(&:text_content)
+ end
+
+ def to_html
+ "\n#{children.map(&:to_html).join}\n"
+ end
+
+ def id_node
+ IdNode[id, type, children.map(&:id_node)]
+ end
+
+ def patch_insert
+ Patches::Initialize[id_node]
+ end
+
+ def traverse(&block)
+ yield self
+ children.each { |child| child.traverse(&block) }
+ nil
+ end
+
+ def find(&block)
+ traverse { |node| return node if yield node }
+ end
+ end
+
+ Element =
+ Data.define(:name, :id, :children, :attributes) do
+ def self.[](id, type, *children, **attributes)
+ new(type, id, children.flatten.compact, attributes)
+ end
+
+ def type = name.to_s
+ def text_content
+ children.map(&:text_content).join
+ end
+
+ def to_html
+ attrs =
+ attributes
+ .except(:slot)
+ .then { { **internal_attributes, **_1 } }
+ .map do |attr, value|
+ if attr == :style && value in Hash
+ value = InlineStyle.stringify(value)
+ end
+
+ value = value.join(" ") if attr == :class && value in Array
+
+ format(
+ ' %s="%s"',
+ CGI.escape_html(attr.to_s.tr("_", "-")),
+ if value.respond_to?(:to_js)
+ value.to_js
+ else
+ CGI.escape_html(value.to_s)
+ end
+ )
+ end
+ .join
+
+ if VOID_ELEMENTS.include?(name)
+ "<#{name}#{attrs}>"
+ else
+ rendered_children = children.map(&:to_html).join
+ "<#{name}#{attrs}>#{rendered_children}#{name}>"
+ end
+ end
+
+ def id_node
+ IdNode[id, name.upcase, children.map(&:id_node)]
+ end
+
+ def patch_insert
+ Patches::CreateTree[to_html, id_node]
+ end
+
+ def patch_remove = Patches::RemoveNode[id]
+
+ def traverse(&block)
+ yield self
+ children.each { |child| child.traverse(&block) }
+ nil
+ end
+
+ def find(&block)
+ traverse { |node| return node if yield node }
+ end
+
+ private
+
+ def internal_attributes
+ INJECT_MAYU_ID ? { mayu_id: id } : {}
+ end
+ end
+
+ Text =
+ Data.define(:id, :content) do
+ def text_content = content.to_s
+
+ def type = "#text"
+
+ def to_html
+ case content.to_s
+ in ""
+ "​"
+ else
+ CGI.escape_html(content)
+ end
+ end
+
+ def id_node = IdNode[id, type]
+
+ def patch_insert = Patches::CreateTextNode[id, content]
+ def patch_remove = Patches::RemoveNode[id]
+
+ def traverse
+ yield self
+ nil
+ end
+
+ def find(&block)
+ traverse { |node| return node if yield node }
+ end
+ end
+
+ Comment =
+ Data.define(:id, :content) do
+ def type = "#comment"
+ def text_content = ""
+ def to_html = ""
+ def id_node = IdNode[id, type]
+
+ def patch_insert = Patches::CreateComment[id, content]
+ def patch_remove = Patches::RemoveNode[id]
+
+ def traverse
+ yield self
+ nil
+ end
+
+ private
+
+ def escape_comment(str) = str.to_s.gsub(/--/, "--")
+
+ def find(&block)
+ traverse { |node| return node if yield node }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/runtime/dom_nesting_validation.rb b/lib/mayu/runtime/dom_nesting_validation.rb
new file mode 100644
index 00000000..c67e5bfa
--- /dev/null
+++ b/lib/mayu/runtime/dom_nesting_validation.rb
@@ -0,0 +1,291 @@
+# frozen_string_literal: true
+#
+# This module has been ported from ReactJS.
+# https://github.com/facebook/react/blob/ec9400dc41715bb6ff0392d6320c33627fa7e2ba/packages/react-dom-bindings/src/client/validateDOMNesting.js
+# Copyright (c) Meta Platforms, Inc. and affiliates.
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+module Mayu
+ module Runtime
+ module DOMNestingValidation
+ # https://html.spec.whatwg.org/multipage/syntax.html#special
+ SPECIAL_TAGS = %i[
+ address
+ applet
+ area
+ article
+ aside
+ base
+ basefont
+ bgsound
+ blockquote
+ body
+ br
+ button
+ caption
+ center
+ col
+ colgroup
+ dd
+ details
+ dir
+ div
+ dl
+ dt
+ embed
+ fieldset
+ figcaption
+ figure
+ footer
+ form
+ frame
+ frameset
+ h1
+ h2
+ h3
+ h4
+ h5
+ h6
+ head
+ header
+ hgroup
+ hr
+ html
+ iframe
+ img
+ input
+ isindex
+ li
+ link
+ listing
+ main
+ marquee
+ menu
+ menuitem
+ meta
+ nav
+ noembed
+ noframes
+ noscript
+ object
+ ol
+ p
+ param
+ plaintext
+ pre
+ script
+ section
+ select
+ source
+ style
+ summary
+ table
+ tbody
+ td
+ template
+ textarea
+ tfoot
+ th
+ thead
+ title
+ tr
+ track
+ ul
+ wbr
+ xmp
+ ].freeze
+
+ # https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
+ IN_SCOPE_TAGS = %i[
+ applet
+ caption
+ html
+ table
+ td
+ th
+ marquee
+ object
+ template
+ foreignObject
+ desc
+ title
+ ].freeze
+
+ # https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope
+ BUTTON_SCOPE_TAGS = [*IN_SCOPE_TAGS, :button].freeze
+
+ # https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
+ IMPLIED_END_TAGS = %i[dd dt li option optgroup p rp rt].freeze
+
+ class AncestorInfo < Struct.new(
+ :current,
+ :form_tag,
+ :a_tag_in_scope,
+ :button_tag_in_scope,
+ :nobr_tag_in_scope,
+ :p_tag_in_button_scope,
+ :list_item_tag_autoclosing,
+ :dl_item_tag_autoclosing,
+ :container_tag_in_scope
+ )
+ EMPTY = new(nil, nil, nil, nil, nil, nil, nil, nil, nil).freeze
+
+ def update(tag)
+ ancestor_info = dup
+
+ if IN_SCOPE_TAGS.include?(tag)
+ ancestor_info.a_tag_in_scope = nil
+ ancestor_info.button_tag_in_scope = nil
+ ancestor_info.nobr_tag_in_scope = nil
+ end
+
+ if BUTTON_SCOPE_TAGS.include?(tag)
+ ancestor_info.p_tag_in_button_scope = nil
+ end
+
+ if SPECIAL_TAGS.include?(tag) && !(tag in :address | :div | :p)
+ ancestor_info.list_item_tag_autoclosing = nil
+ ancestor_info.dl_item_tag_autoclosing = nil
+ end
+
+ info = tag
+ ancestor_info.current = info
+
+ case tag
+ in :form
+ ancestor_info.form_tag = info
+ in :a
+ ancestor_info.a_tag_in_scope = info
+ in :button
+ ancestor_info.button_tag_in_scope = info
+ in :nobr
+ ancestor_info.nobr_tag_in_scope = info
+ in :p
+ ancestor_info.p_tag_in_button_scope = info
+ in :li
+ ancestor_info.list_item_tag_autoclosing = info
+ in :dd | :dt
+ ancestor_info.dl_item_tag_autoclosing = info
+ in :html
+ ancestor_info.container_tag_in_scope = nil
+ else
+ ancestor_info.container_tag_in_scope ||= info
+ end
+
+ ancestor_info
+ end
+
+ def find_invalid_ancestor_for_tag(tag)
+ case tag
+ in :address | :article | :aside | :blockquote | :center | :details |
+ :dialog | :dir | :div | :dl | :fieldset | :figcaption | :figure |
+ :footer | :header | :hgroup | :main | :menu | :nav | :ol | :p |
+ :section | :summary | :ul | :pre | :listing | :table | :hr |
+ :xmp | :h1 | :h2 | :h3 | :h4 | :h5 | :h6
+ p_tag_in_button_scope
+ in :form
+ form_tag || p_tag_in_button_scope
+ in :li
+ list_item_tag_autoclosing
+ in :dd | :dt
+ dl_item_tag_autoclosing
+ in :button
+ button_tag_in_scope
+ in :a
+ a_tag_in_scope
+ in :nobr
+ nobr_tag_in_scope
+ else
+ nil
+ end
+ end
+ end
+
+ def self.validate(child_tag, ancestor_info = AncestorInfo::EMPTY)
+ parent_tag = ancestor_info.current
+
+ invalid_parent =
+ valid_parent_child?(parent_tag, child_tag) ? nil : parent_tag
+ invalid_ancestor =
+ (
+ if invalid_parent
+ nil
+ else
+ ancestor_info.find_invalid_ancestor_for_tag(child_tag)
+ end
+ )
+
+ invalid_parent_or_ancestor = invalid_parent || invalid_ancestor
+
+ return nil unless invalid_parent_or_ancestor
+
+ if invalid_parent
+ info = ""
+
+ if parent_tag == :table && child_tag == :tr
+ info +=
+ " Add a , or to your code to match the browser."
+ end
+
+ format(
+ "In HTML, <%s> can not be a child of <%s>.%s",
+ child_tag,
+ invalid_parent,
+ info
+ )
+ else
+ format(
+ "In HTML, <%s> can not be a descendant of <%s>.",
+ child_tag,
+ invalid_ancestor
+ )
+ end
+ end
+
+ def self.valid_parent_child?(parent, child)
+ case parent
+ in :select
+ return(child in :hr | :option | :optgroup)
+ in :optgroup
+ return(child in :option)
+ in :tr
+ return(child in :th | :td | :style | :script | :template)
+ in :tbody | :thead | :tfoot
+ return(child in :tr | :style | :script | :template)
+ in :colgroup
+ return(child in :col | :template)
+ in :table
+ return(
+ child in
+ :caption | :colgroup | :tbody | :tfoot | :thead | :style |
+ :script | :template
+ )
+ in :head
+ return(
+ child in
+ :base | :basefont | :bgsound | :link | :meta | :title |
+ :noscript | :noframes | :style | :script | :template
+ )
+ in :html
+ return(child in :__head | :body | :frameset)
+ in :frameset
+ return(child in :frame)
+ else
+ case child
+ in :h1 | :h2 | :h3 | :h4 | :h5 | :h6
+ return !(parent in :h1 | :h2 | :h3 | :h4 | :h5 | :h6)
+ in :rp | :rt
+ return !IMPLIED_END_TAGS.include?(parent)
+ in :body | :caption | :col | :colgroup | :frameset | :frame | :html |
+ :tbody | :td | :tfoot | :th | :thead | :tr
+ return false
+ else
+ true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mayu/runtime/dom_nesting_validation.test.rb b/lib/mayu/runtime/dom_nesting_validation.test.rb
new file mode 100644
index 00000000..26998ebf
--- /dev/null
+++ b/lib/mayu/runtime/dom_nesting_validation.test.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+#
+# These tests has been ported from ReactJS.
+# https://github.com/facebook/react/blob/ec9400dc41715bb6ff0392d6320c33627fa7e2ba/packages/react-dom/src/__tests__/validate-test.js
+# Copyright (c) Meta Platforms, Inc. and affiliates.
+#
+# Copyright Andreas Alin
+# License: AGPL-3.0
+
+require "bundler/setup"
+require "minitest/autorun"
+require "rexml"
+
+require_relative "dom_nesting_validation"
+
+class Mayu::Runtime::DOMNestingValidation::Test < Minitest::Test
+ def test_valid_nestings
+ assert_nil(get_warnings(:table, :tbody, :tr, :td, :b))
+ assert_nil(get_warnings(:table, :tbody, :tr, :td, :b))
+ assert_nil(get_warnings(:body, :datalist, :option))
+ assert_nil(get_warnings(:div, :a, :object, :a))
+ assert_nil(get_warnings(:div, :p, :button, :p))
+ assert_nil(get_warnings(:p, :svg, :foreignObject, :p))
+ assert_nil(get_warnings(:html, :body, :div))
+
+ assert_nil(get_warnings(:div, :ul, :ul, :li))
+ assert_nil(get_warnings(:div, :label, :div))
+ assert_nil(get_warnings(:div, :ul, :li, :section, :li))
+ assert_nil(get_warnings(:div, :ul, :li, :dd, :li))
+ end
+
+ def test_problematic_nestings
+ assert_equal(<<~MSG.strip, get_warnings(:a, :a))
+ In HTML, can not be a descendant of .
+ MSG
+
+ assert_equal(<<~MSG.strip, get_warnings(:form, :form))
+ In HTML,