diff --git a/Gemfile.lock b/Gemfile.lock index 2464863b..c7a34daa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: mayu-live (0.0.0) + accept_language (~> 2.0.3) async (~> 2.0.3) async-container (~> 0.16.12) async-http (~> 0.59.1) @@ -28,10 +29,12 @@ PATH syntax_tree-xml (~> 0.1.0) terminal-table (~> 3.0.1) toml-rb (~> 2.2.0) + twitter_cldr (~> 6.11.4) GEM remote: https://rubygems.org/ specs: + accept_language (2.0.3) ansi (1.5.0) ast (2.4.2) async (2.0.3) @@ -55,8 +58,11 @@ GEM async (>= 1.25) brotli (0.4.0) builder (3.2.4) + camertron-eprun (1.1.1) citrus (3.0.2) + cldr-plurals-runtime-rb (1.1.0) coderay (1.1.3) + concurrent-ruby (1.1.10) console (1.15.3) fiber-local crass (1.0.6) @@ -193,6 +199,12 @@ GEM toml-rb (2.2.0) citrus (~> 3.0, > 3.0) traces (0.7.0) + twitter_cldr (6.11.4) + camertron-eprun + cldr-plurals-runtime-rb (~> 1.1) + tzinfo + tzinfo (2.0.5) + concurrent-ruby (~> 1.0) unicode-display_width (2.3.0) unparser (0.6.5) diff-lcs (~> 1.3) diff --git a/example/Gemfile.lock b/example/Gemfile.lock index fb698ce4..c69eb8b4 100644 --- a/example/Gemfile.lock +++ b/example/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: .. specs: mayu-live (0.0.0) + accept_language (~> 2.0.3) async (~> 2.0.3) async-container (~> 0.16.12) async-http (~> 0.59.1) @@ -28,10 +29,12 @@ PATH syntax_tree-xml (~> 0.1.0) terminal-table (~> 3.0.1) toml-rb (~> 2.2.0) + twitter_cldr (~> 6.11.4) GEM remote: https://rubygems.org/ specs: + accept_language (2.0.3) async (2.0.3) console (~> 1.10) io-event (~> 1.0.0) @@ -52,8 +55,11 @@ GEM async-pool (0.3.12) async (>= 1.25) brotli (0.4.0) + camertron-eprun (1.1.1) citrus (3.0.2) + cldr-plurals-runtime-rb (1.1.0) coderay (1.1.3) + concurrent-ruby (1.1.10) console (1.15.3) fiber-local crass (1.0.6) @@ -124,6 +130,12 @@ GEM toml-rb (2.2.0) citrus (~> 3.0, > 3.0) traces (0.7.0) + twitter_cldr (6.11.4) + camertron-eprun + cldr-plurals-runtime-rb (~> 1.1) + tzinfo + tzinfo (2.0.5) + concurrent-ruby (~> 1.0) unicode-display_width (2.3.0) PLATFORMS diff --git a/example/app/components/Clock.haml b/example/app/components/Clock.haml index 7dae1fec..325a83dc 100644 --- a/example/app/components/Clock.haml +++ b/example/app/components/Clock.haml @@ -1,8 +1,12 @@ :ruby + def self.get_initial_state(**) + time = Time.now.utc + 0.5 + { hour: time.hour, min: time.min, sec: time.sec } + end + def mount loop do - time = Time.now.utc - update(hour: time.hour, min: time.min, sec: time.sec) + update(self.class.get_initial_state) sleep 0.5 end end @@ -21,6 +25,10 @@ height: auto; } + .hand { + transition: transform 250ms ease-in-out; + } + :ruby stroke = props[:stroke] || "#000" fill = props[:fill] || "transparent" @@ -30,13 +38,16 @@ %svg.svg(width="100" height="100" viewBox="-100 -100 200 200" xmlns="http://www.w3.org/2000/svg") %circle(cx=0 cy=0 r=99 fill=fill stroke=stroke stroke-width="2") %g - - x1, y1 = [0, 0] - - x2, y2 = calculate_pos(state[:hour].to_f * 5, 50.0) - %line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="6") - - x2, y2 = calculate_pos(state[:min].to_f, 60.0) - %line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="3") - - x2, y2 = calculate_pos(state[:sec].to_f, 80.0) - %line.hand(x1=x1 y1=y1 x2=x2 y2=y2 stroke=stroke stroke-width="1") + - x1, y1, x2 = [0, 0, 0] + - y2 = -50 + - style = { transform: format("rotateZ(%.5fturn)", state[: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) } + %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) } + %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 = 12.times.map do |i| diff --git a/example/app/components/Form/Button.haml b/example/app/components/Form/Button.haml index 081c6fc4..656ba24e 100644 --- a/example/app/components/Form/Button.haml +++ b/example/app/components/Form/Button.haml @@ -8,6 +8,7 @@ user-select: none; font-family: inherit; color: #fff; + --icon-color: #fff; } .button:not(:disabled):active { @@ -26,8 +27,13 @@ .button:disabled { background-color: #ccc; color: #666; + --icon-color: #666; cursor: not-allowed; } -%button.button{style: { __button_color: props.fetch(:color, "var(--accent-color)") }, **props.except(:color)} +%button.button{ + class: props[:class], + style: { __button_color: props.fetch(:color, "var(--accent-color)") }, + **props.except(:color) +} %slot diff --git a/example/app/components/Form/Fieldset.haml b/example/app/components/Form/Fieldset.haml index b1db7c95..66c83dd7 100644 --- a/example/app/components/Form/Fieldset.haml +++ b/example/app/components/Form/Fieldset.haml @@ -8,5 +8,5 @@ font-size: 0.9em; } -%fieldset.fieldset +%fieldset.fieldset{**props} %slot diff --git a/example/app/components/Layout/Badges.haml b/example/app/components/Layout/Badges.haml new file mode 100644 index 00000000..1fa1ad9c --- /dev/null +++ b/example/app/components/Layout/Badges.haml @@ -0,0 +1,41 @@ +:ruby + Badge = import("./Badge") + + BADGES = [ + { + href: "https://github.com/mayu-live/framework/search?l=Ruby&type=code", + alt: "top language", + src: "https://img.shields.io/github/languages/top/mayu-live/framework", + }, { + href: "https://github.com/mayu-live/framework/actions/workflows/ruby.yml", + alt: "Ruby build status", + src: "https://img.shields.io/github/workflow/status/mayu-live/framework/Ruby/main?label=ruby" + }, { + href: "https://github.com/mayu-live/framework/actions/workflows/node.js.yml", + alt: "JavaScript build status", + src: "https://img.shields.io/github/workflow/status/mayu-live/framework/Node.js%20CI/main?label=JavaScript", + }, { + href: "https://github.com/mayu-live/framework/blob/main/COPYING", + alt: "License: AGPL-3.0", + src: "https://img.shields.io/github/license/mayu-live/framework", + }, { + href: "https://status.mayu.live/", + alt: "uptime status", + src: "https://img.shields.io/badge/uptime-up-green", + }, { + href: "https://github.com/mayu-live/framework/stargazers", + alt: "GitHub Stars", + src: "https://img.shields.io/github/stars/mayu-live/framework?style=social", + } + ] + +:css + .badges { + display: flex; + flex-wrap: wrap; + flex-direction: row; + gap: 0.5em; + } +%p.badges + = BADGES.map do |badge| + %Badge{key: badge[:src], **badge} diff --git a/example/app/components/Layout/Footer.haml b/example/app/components/Layout/Footer.haml index 12d827fb..ade66b87 100644 --- a/example/app/components/Layout/Footer.haml +++ b/example/app/components/Layout/Footer.haml @@ -1,34 +1,7 @@ :ruby MaxWidth = import("/app/components/Layout/MaxWidth") - Badge = import("./Badge") - - BADGES = [ - { - href: "https://github.com/mayu-live/framework/search?l=Ruby&type=code", - alt: "top language", - src: "https://img.shields.io/github/languages/top/mayu-live/framework", - }, { - href: "https://github.com/mayu-live/framework/actions/workflows/ruby.yml", - alt: "Ruby build status", - src: "https://img.shields.io/github/workflow/status/mayu-live/framework/Ruby/main?label=ruby" - }, { - href: "https://github.com/mayu-live/framework/actions/workflows/node.js.yml", - alt: "JavaScript build status", - src: "https://img.shields.io/github/workflow/status/mayu-live/framework/Node.js%20CI/main?label=JavaScript", - }, { - href: "https://github.com/mayu-live/framework/blob/main/COPYING", - alt: "License: AGPL-3.0", - src: "https://img.shields.io/github/license/mayu-live/framework", - }, { - href: "https://status.mayu.live/", - alt: "uptime status", - src: "https://img.shields.io/badge/uptime-up-green", - }, { - href: "https://github.com/mayu-live/framework/stargazers", - alt: "GitHub Stars", - src: "https://img.shields.io/github/stars/mayu-live/framework?style=social", - } - ] + Languages = import("./Languages") + Badges = import("./Badges") :css .footer { @@ -53,18 +26,6 @@ color: #fff; opacity: 1; } - - .badges { - display: flex; - flex-wrap: wrap; - flex-direction: row; - gap: 0.5em; - } - - .links { - display: inline-flex; - } - %footer.footer{class: props[:class]} %MaxWidth.inner %p @@ -73,6 +34,5 @@ %a.link(href="https://github.com/mayu-live" target="_blank")< github.com/mayu-live - %p.badges - = BADGES.map do |badge| - %Badge{key: badge[:src], **badge} + %Languages + %Badges diff --git a/example/app/components/Layout/Header.haml b/example/app/components/Layout/Header.haml index 161fa3cb..4ab7f369 100644 --- a/example/app/components/Layout/Header.haml +++ b/example/app/components/Layout/Header.haml @@ -1,20 +1,22 @@ :ruby + translations("sv-SE", "en-US") + MaxWidth = import("/app/components/Layout/MaxWidth") Icon = import("/app/components/UI/Icon") -%header{class: props[:class]} +%header(lang=lang){class: props[:class]} %MaxWidth.inner - %a.titleLink(href="/" title="Start page") + %a.titleLink(href="/"){title: t(:startpage)} Mayu Live %nav.nav %menu.menu %li %a.navLink(href="/demos") - Demos + = t(:demos) %li %a.navLink(href="/docs") - Docs + = t(:docs) %li %a.navLink(href="https://github.com/mayu-live/framework" target="_blank") - GitHub + = t(:github) %Icon.icon(name="up-right-from-square") diff --git a/example/app/components/Layout/Header.intl.en-US.toml b/example/app/components/Layout/Header.intl.en-US.toml new file mode 100644 index 00000000..2c8ffd9d --- /dev/null +++ b/example/app/components/Layout/Header.intl.en-US.toml @@ -0,0 +1,4 @@ +startpage = "Startsida" +demos = "Demos" +docs = "Docs" +github = "GitHub" diff --git a/example/app/components/Layout/Header.intl.sv-SE.toml b/example/app/components/Layout/Header.intl.sv-SE.toml new file mode 100644 index 00000000..38bb9702 --- /dev/null +++ b/example/app/components/Layout/Header.intl.sv-SE.toml @@ -0,0 +1,4 @@ +startpage = "Startsida" +demos = "Demo" +docs = "Dokumentation" +github = "GitHub" diff --git a/example/app/components/Layout/Highlight.haml b/example/app/components/Layout/Highlight.haml index a432d52e..4d15f665 100644 --- a/example/app/components/Layout/Highlight.haml +++ b/example/app/components/Layout/Highlight.haml @@ -20,6 +20,8 @@ Rouge::Lexers::HTML.new when :css Rouge::Lexers::CSS.new + when :json + Rouge::Lexers::JSON.new end end diff --git a/example/app/components/Layout/Languages.haml b/example/app/components/Layout/Languages.haml new file mode 100644 index 00000000..8db2bc0f --- /dev/null +++ b/example/app/components/Layout/Languages.haml @@ -0,0 +1,37 @@ +:ruby + translations("en-US", "sv-SE") + + Icon = import("/app/components/UI/Icon") + + def handle_set_language(e) + e => { target: { value: } } + helpers.set_prefer_language(value) + end +:css + .languages { + display: flex; + gap: .5em; + } + + .title { + font-size: inherit; + margin: 0; + } + + .button { + border: 0; + background: transparent; + color: #fff; + cursor: pointer; + display: inline-block; + margin: 0; + padding: 0; + } + + .button:hover { + text-decoration: underline; + } +%section.languages(lang=lang) + %Icon(name="language"){title: t(:title), style: { aspect_ratio: "1.25", width: "2em" }} + %button.button(onclick=handle_set_language value="sv-SE" lang="sv-SE") Svenska + %button.button(onclick=handle_set_language value="en-US" lang="en-US") English (US) diff --git a/example/app/components/Layout/Languages.intl.en-US.toml b/example/app/components/Layout/Languages.intl.en-US.toml new file mode 100644 index 00000000..fffdfad8 --- /dev/null +++ b/example/app/components/Layout/Languages.intl.en-US.toml @@ -0,0 +1 @@ +title = "Change language" diff --git a/example/app/components/Layout/Languages.intl.sv-SE.toml b/example/app/components/Layout/Languages.intl.sv-SE.toml new file mode 100644 index 00000000..7fdddea0 --- /dev/null +++ b/example/app/components/Layout/Languages.intl.sv-SE.toml @@ -0,0 +1 @@ +title = "Byt språk" diff --git a/example/app/components/Layout/Menu.haml b/example/app/components/Layout/Menu.haml index 1b5eba13..06dd0a48 100644 --- a/example/app/components/Layout/Menu.haml +++ b/example/app/components/Layout/Menu.haml @@ -8,5 +8,5 @@ flex-direction: var(--menu-direction, column); gap: 0 1em; } -%menu.menu +%menu.menu{lang: props[:htmllang]} %slot diff --git a/example/app/components/UI/Icon/fix.rb b/example/app/components/UI/Icon/fix.rb new file mode 100644 index 00000000..469b686c --- /dev/null +++ b/example/app/components/UI/Icon/fix.rb @@ -0,0 +1,7 @@ +puts "ICONS = {" + +Dir["*.svg"].each do |file| + puts " #{File.basename(file, ".svg").delete_suffix("-solid").inspect} => svg(#{file.inspect})," +end + +puts "}.transform_keys(&:to_s).freeze" diff --git a/example/app/components/UI/Image.haml b/example/app/components/UI/Image.haml index e946bea5..0752e459 100644 --- a/example/app/components/UI/Image.haml +++ b/example/app/components/UI/Image.haml @@ -25,7 +25,7 @@ classname = props[:class] -%img.img(style=inline_style loading=loading alt=alt class=classname){ +%img.img(style=inline_style loading=loading decoding="async" alt=alt class=classname){ src: image.src, sizes: image.sizes, srcset: image.srcset, diff --git a/example/app/pages/Counter.haml b/example/app/pages/Counter.haml index 11e5aaa3..8f817186 100644 --- a/example/app/pages/Counter.haml +++ b/example/app/pages/Counter.haml @@ -1,5 +1,6 @@ :ruby Card = import("/app/components/Layout/Card") + Icon = import("/app/components/UI/Icon") def self.get_initial_state(initial_value: 0, **) = { count: initial_value @@ -40,6 +41,9 @@ } .button { + display: flex; + align-items: center; + justify-content: center; flex: 1; border-radius: 100%; border: 0; @@ -53,14 +57,11 @@ --transition-length: 100ms; transition: filter var(--transition-length), - box-shadow var(--transition-length), - transform var(--transition-length); - transform: scale(1); + box-shadow var(--transition-length); } .button:hover { filter: brightness(110%); - transform: scale(1.2); } .button:active { @@ -77,7 +78,7 @@ .counter - decrement_disabled = state[:count].zero? %button.button(title="Decrement" onclick=handle_decrement disabled=decrement_disabled) - - + %Icon(name="minus" color="var(--bright)") %span.count= state[:count] %button.button(title="Increment" onclick=handle_increment) - + + %Icon(name="plus" color="var(--bright)") diff --git a/example/app/pages/demos/events/page.haml b/example/app/pages/demos/events/page.haml index 372e9911..3a372aa3 100644 --- a/example/app/pages/demos/events/page.haml +++ b/example/app/pages/demos/events/page.haml @@ -1,4 +1,6 @@ :ruby + translations("sv-SE", "en-US") + Heading = import("/app/components/Layout/Heading") Highlight = import("/app/components/Layout/Highlight") Card = import("/app/components/Layout/Card") @@ -9,17 +11,14 @@ } def handle_alert(e) - helpers.alert("Hello world\nCount: #{state[:count]}") + helpers.alert(t(:message, count: state[:count])) update do |state| { count: state[:count] + 1 } end end -%article - %Heading(level=2) Events - %p This page contains some experiments with events - - %button(onclick=handle_alert) Alert - - +%article(lang=lang) + %Heading(level=2)= t(:title) + %p= t(:description) + %button(onclick=handle_alert)= t(:alert) diff --git a/example/app/pages/demos/events/page.intl.en-US.toml b/example/app/pages/demos/events/page.intl.en-US.toml new file mode 100644 index 00000000..7cf56902 --- /dev/null +++ b/example/app/pages/demos/events/page.intl.en-US.toml @@ -0,0 +1,7 @@ +title = "Events" +description = "This page contains some experiments with events." +alert = "Alert" +message = """ +Hello world! +Counter: %d +""" diff --git a/example/app/pages/demos/events/page.intl.sv-SE.toml b/example/app/pages/demos/events/page.intl.sv-SE.toml new file mode 100644 index 00000000..b3f75fe3 --- /dev/null +++ b/example/app/pages/demos/events/page.intl.sv-SE.toml @@ -0,0 +1,7 @@ +title = "Händelser" +description = "Denna sida innehåller experiment med events." +alert = "Alert" +message = """ +Hej världen! +Räknare: %d +""" diff --git a/example/app/pages/demos/i18n/page.en-US.toml b/example/app/pages/demos/i18n/page.en-US.toml deleted file mode 100644 index a0397cb8..00000000 --- a/example/app/pages/demos/i18n/page.en-US.toml +++ /dev/null @@ -1,4 +0,0 @@ -title = "Internationalization" -body = """ -hello world -""" diff --git a/example/app/pages/demos/i18n/page.haml b/example/app/pages/demos/i18n/page.haml index f30b4ae6..630de00f 100644 --- a/example/app/pages/demos/i18n/page.haml +++ b/example/app/pages/demos/i18n/page.haml @@ -1,10 +1,53 @@ :ruby + require "twitter_cldr" + Heading = import("/app/components/Layout/Heading") + Fieldset = import("/app/components/Form/Fieldset") + + translations("sv-SE", "en-US") - def t - "TODO: Implement me" + def handle_set_language(e) + e => { target: { value: } } + helpers.set_prefer_language(value) end +:css + .buttons { + display: flex; + gap: .5em; + } + + .button[aria-current="true"] { + font-weight: bold; + } +%article(lang=lang) + %Heading(level=1)= t(:title) + + %p= t(:body) + %p= t(:current_language, lang:) -%article - %Heading(level=2)= t(:title) - %slot + %ul + %dt Number + %dd= TwitterCldr::Localized::LocalizedNumber.new(1337, lang).to_s + %dt Currency + %dd= TwitterCldr::Localized::LocalizedNumber.new(1337, lang).to_currency.to_s(currency: "EUR") + %dt Currency + %dd= TwitterCldr::Localized::LocalizedNumber.new(12341337, lang).to_currency.to_s(currency: "COP") + %dt Short format + %dd= TwitterCldr::Localized::LocalizedNumber.new(2337, lang).to_decimal.to_s(format: :short) + %dt Long format + %dd= TwitterCldr::Localized::LocalizedNumber.new(2337, lang).to_decimal.to_s(format: :long) + %dt Short time + %dd= TwitterCldr::Localized::LocalizedDateTime.new(DateTime.now, lang).to_short_s + %dt Formatted time + %dd= TwitterCldr::Localized::LocalizedDateTime.new(DateTime.now, lang).to_additional_s("EBhms") + %dt List + %dd= TwitterCldr::Formatters::ListFormatter.new(lang).format(["Larry", "Curly", "Moe"]) + %dt Plural rules + %dd= TwitterCldr::Formatters::Plurals::Rules.all_for(lang).inspect + %Fieldset + %legend= t(:change_language) + %div.buttons + = [*self.class.loaded_translations.keys, "sv", "en"].map do |language| + %button.button(onclick=handle_set_language value=language title=language){ + aria: { current: (props[:lang] == language).to_s }, + }= TwitterCldr::Shared::Languages.from_code_for_locale(language.to_sym, lang).capitalize diff --git a/example/app/pages/demos/i18n/page.intl.en-US.toml b/example/app/pages/demos/i18n/page.intl.en-US.toml new file mode 100644 index 00000000..de60ffe5 --- /dev/null +++ b/example/app/pages/demos/i18n/page.intl.en-US.toml @@ -0,0 +1,8 @@ +title = "Internationalization" +body = """ +hello world +""" +current_language = "Current language: %s" +change_language = "Change language" +lang.sv-SE = "Swedish" +lang.en-US = "English" diff --git a/example/app/pages/demos/i18n/page.intl.sv-SE.toml b/example/app/pages/demos/i18n/page.intl.sv-SE.toml index 50f9d3be..6b4c2f5f 100644 --- a/example/app/pages/demos/i18n/page.intl.sv-SE.toml +++ b/example/app/pages/demos/i18n/page.intl.sv-SE.toml @@ -2,3 +2,7 @@ title = "Internationalisering" body = """ hej världen """ +current_language = "Nuvarande språk: %s" +change_language = "Byt språk" +lang.sv-SE = "Svenska" +lang.en-US = "Engelska" diff --git a/example/app/pages/demos/layout.haml b/example/app/pages/demos/layout.haml index 10a988ae..d1c0bcfb 100644 --- a/example/app/pages/demos/layout.haml +++ b/example/app/pages/demos/layout.haml @@ -4,18 +4,21 @@ MenuItem = import("/app/components/Layout/MenuItem") Breadcrumbs = import("/app/components/UI/Breadcrumbs") + translations("sv-SE", "en-US") + LINKS = { - "/demos" => "Demos", - "/demos/pokemon" => "Pokémon", - "/demos/tree" => "App tree", - "/demos/form" => "Form elements", - "/demos/images" => "Images", - "/demos/svg" => "SVGs", - "/demos/fuzzy-matcher" => "Fuzzy matcher", - "/demos/todo" => "Todo app", - "/demos/exceptions" => "Exceptions", - "/demos/life" => "Game of life", - "/demos/events" => "Events", + "/demos" => :demos, + "/demos/i18n" => :i18n, + "/demos/pokemon" => :pokemon, + "/demos/tree" => :tree, + "/demos/form" => :form, + "/demos/images" => :images, + "/demos/svg" => :svg, + "/demos/fuzzy-matcher" => :fuzzymatch, + "/demos/todo" => :todo, + "/demos/exceptions" => :exceptions, + "/demos/life" => :life, + "/demos/events" => :events, } def breadcrumb_links @@ -26,7 +29,7 @@ LINKS.select { s = split_path(_1) s == splat.slice(0, s.length) - } + }.transform_values { t(_1) } end def split_path(path) @@ -44,6 +47,6 @@ %Breadcrumbs(slot="breadcrumbs" links=breadcrumb_links) .nav(slot="menu") - %Menu - = LINKS.map do |path, text| - %MenuItem(key=path href=path)= text + %Menu(htmllang=lang) + = LINKS.map do |path, label| + %MenuItem(key=path href=path)= t(label) diff --git a/example/app/pages/demos/layout.i18n.toml b/example/app/pages/demos/layout.i18n.toml new file mode 100644 index 00000000..f2032b95 --- /dev/null +++ b/example/app/pages/demos/layout.i18n.toml @@ -0,0 +1,27 @@ +[en] +demos = "Demos" +i18n = "Translations" +pokemon = "Pokémon" +tree = "App tree" +form = "Form elements" +images = "Images" +svg = "Scalable Vector Graphics" +fuzzymatch = "Fuzzy matcher" +todo = "Todo app" +life = "Game of life" +events = "Events" +exceptions = "Exceptions" + +[sv] +demos = "Demos" +i18n = "Översättningar" +pokemon = "Pokémon" +tree = "Applikationsträd" +form = "Formulärelement" +images = "Bilder" +svg = "Scalable Vector Graphics" +fuzzymatch = "Fuzzymatchning" +todo = "Att göra-app" +life = "Livets spel (Game of life)" +events = "Händelser" +exceptions = "Exceptions" diff --git a/example/app/pages/demos/layout.intl.en-US.toml b/example/app/pages/demos/layout.intl.en-US.toml new file mode 100644 index 00000000..18c95729 --- /dev/null +++ b/example/app/pages/demos/layout.intl.en-US.toml @@ -0,0 +1,12 @@ +demos = "Demos" +i18n = "Translations" +pokemon = "Pokémon" +tree = "App tree" +form = "Form elements" +images = "Images" +svg = "Scalable Vector Graphics" +fuzzymatch = "Fuzzy matcher" +todo = "Todo app" +life = "Game of life" +events = "Events" +exceptions = "Exceptions" diff --git a/example/app/pages/demos/layout.intl.sv-SE.toml b/example/app/pages/demos/layout.intl.sv-SE.toml new file mode 100644 index 00000000..6b0c9a5e --- /dev/null +++ b/example/app/pages/demos/layout.intl.sv-SE.toml @@ -0,0 +1,12 @@ +demos = "Demos" +i18n = "Översättningar" +pokemon = "Pokémon" +tree = "Applikationsträd" +form = "Formulärelement" +images = "Bilder" +svg = "Scalable Vector Graphics" +fuzzymatch = "Fuzzymatchning" +todo = "Att göra-app" +life = "Livets spel (Game of life)" +events = "Händelser" +exceptions = "Exceptions" diff --git a/example/app/pages/demos/life/page.haml b/example/app/pages/demos/life/page.haml index c4c11620..692849ff 100644 --- a/example/app/pages/demos/life/page.haml +++ b/example/app/pages/demos/life/page.haml @@ -1,8 +1,11 @@ :ruby + translations("en-US", "sv-SE") + Heading = import("/app/components/Layout/Heading") Fieldset = import("/app/components/Form/Fieldset") Input = import("/app/components/Form/Input") Button = import("/app/components/Form/Button") + Icon = import("/app/components/UI/Icon") GameGrid = import("./GameGrid") MAX_SIZE = 20 @@ -137,19 +140,36 @@ .alive { background: #333; } .alive:hover { background: #666; } + .button { + display: flex; + align-items: center; + gap: .5em; + } + :ruby state => grid:, size:, running: -%article - %Heading(level=2) Game of life +%article(lang=lang) + %Heading(level=2)= t(:title) %Fieldset - %legend Controls + %legend= t(:controls) .buttons - %Button(onclick=handle_reset) Reset - %Button(onclick=handle_randomize) Randomize - %Button(onclick=handle_step disabled=running) Step - %Button(onclick=handle_toggle_running){ - color: running ? "var(--red)" : "var(--green)" - }= running ? "Stop" : "Start" + %Button.button(onclick=handle_reset) + %Icon(name="arrows-rotate") + = t(:reset) + %Button.button(onclick=handle_randomize) + %Icon(name="dice") + = t(:randomize) + %Button.button(onclick=handle_step disabled=running) + %Icon(name="forward-step") + = t(:step) + = if running + %Button.button(onclick=handle_toggle_running color="var(--red)") + %Icon(name="pause") + = t(:stop) + = unless running + %Button.button(onclick=handle_toggle_running color="var(--green)") + %Icon(name="play") + = t(:start) %GameGrid(grid=grid size=size onmousedown=handle_toggle onmouseenter=handle_mouseenter) diff --git a/example/app/pages/demos/life/page.intl.en-US.toml b/example/app/pages/demos/life/page.intl.en-US.toml new file mode 100644 index 00000000..7afd3eed --- /dev/null +++ b/example/app/pages/demos/life/page.intl.en-US.toml @@ -0,0 +1,7 @@ +title = "Game of life" +controls = "Controls" +randomize = "Randomize" +reset = "Reset" +start = "Start" +step = "Step" +stop = "Stop" diff --git a/example/app/pages/demos/life/page.intl.sv-SE.toml b/example/app/pages/demos/life/page.intl.sv-SE.toml new file mode 100644 index 00000000..dc829f93 --- /dev/null +++ b/example/app/pages/demos/life/page.intl.sv-SE.toml @@ -0,0 +1,7 @@ +title = "Livets spel" +controls = "Kontroller" +randomize = "Slumpa" +reset = "Återställ" +start = "Starta" +step = "Stega" +stop = "Stanna" diff --git a/example/app/pages/demos/page.haml b/example/app/pages/demos/page.haml index e410dd64..f8a8137a 100644 --- a/example/app/pages/demos/page.haml +++ b/example/app/pages/demos/page.haml @@ -1,9 +1,13 @@ :ruby + translations("sv-SE", "en-US") + Heading = import("/app/components/Layout/Heading") ButtonGame = import("./ButtonGame") %article - %Heading(level=2) Demos index - %p This section demonstrates some of the capabilities of Mayu. + %Heading(level=2)= t(:title) + %p= t(:description) + + %pre props[:lang] = #{props[:lang].inspect} %ButtonGame diff --git a/example/app/pages/demos/page.intl.en-US.toml b/example/app/pages/demos/page.intl.en-US.toml new file mode 100644 index 00000000..0233ab82 --- /dev/null +++ b/example/app/pages/demos/page.intl.en-US.toml @@ -0,0 +1,2 @@ +title = "Demos index" +description = "This section demonstrates some of the capabilities of Mayu." diff --git a/example/app/pages/demos/page.intl.sv-SE.toml b/example/app/pages/demos/page.intl.sv-SE.toml new file mode 100644 index 00000000..56249c18 --- /dev/null +++ b/example/app/pages/demos/page.intl.sv-SE.toml @@ -0,0 +1,2 @@ +title = "Demos index" +description = "Denna sektion visar vad man kan göra med Mayu." diff --git a/example/app/pages/demos/pokemon/Pagination.haml b/example/app/pages/demos/pokemon/Pagination.haml index fb475d43..15e05a2f 100644 --- a/example/app/pages/demos/pokemon/Pagination.haml +++ b/example/app/pages/demos/pokemon/Pagination.haml @@ -2,6 +2,8 @@ Fieldset = import("/app/components/Form/Fieldset") Button = import("/app/components/Form/Button") + translations("en-US", "sv-SE") + def pagination_window(current_page:, total_pages:, window_size: 5) half_window_size = (window_size - 1) / 2 first = current_page - half_window_size.ceil @@ -34,14 +36,14 @@ window_size: props[:window_size] || 5, ) -%Fieldset +%Fieldset(lang=lang) %legend - Page #{page} of #{total_pages.succ}, showing #{per_page} per page + = t(:legend, page:, total_pages: total_pages.succ, per_page:) .wrap %nav.buttons(aria-label="pagination") %a.button(href=prev_page_link rel="prev") - Previous page + = t(:previous_page) %ul.pages = pages.map do |num| @@ -52,10 +54,10 @@ }= num %a.button(href=next_page_link rel="prev") - Next page + = t(:next_page) .perPage - Per page: + = t(:per_page_label) %select(value=per_page){on_change: props[:on_change_per_page]}< = [20, 40, 80].map do |num| %option(key=num value=num)= num diff --git a/example/app/pages/demos/pokemon/Pagination.intl.en-US.toml b/example/app/pages/demos/pokemon/Pagination.intl.en-US.toml new file mode 100644 index 00000000..b363a63c --- /dev/null +++ b/example/app/pages/demos/pokemon/Pagination.intl.en-US.toml @@ -0,0 +1,4 @@ +legend = "Page %d of %d, showing %d per page" +previous_page = "Previous page" +next_page = "Next page" +per_page_label = "Per page:" diff --git a/example/app/pages/demos/pokemon/Pagination.intl.sv-SE.toml b/example/app/pages/demos/pokemon/Pagination.intl.sv-SE.toml new file mode 100644 index 00000000..87e64c64 --- /dev/null +++ b/example/app/pages/demos/pokemon/Pagination.intl.sv-SE.toml @@ -0,0 +1,4 @@ +legend = "Sida %d av %d, visar %d per sida" +previous_page = "Föregående sida" +next_page = "Nästa sida" +per_page_label = "Antal per sida:" diff --git a/example/app/pages/demos/pokemon/page.haml b/example/app/pages/demos/pokemon/page.haml index 9fc2422c..5f118ed1 100644 --- a/example/app/pages/demos/pokemon/page.haml +++ b/example/app/pages/demos/pokemon/page.haml @@ -1,6 +1,8 @@ :ruby Pagination = import("./Pagination") + translations("en-US", "sv-SE") + def self.get_initial_state(**props) = { result: nil, error: nil, @@ -37,11 +39,11 @@ state => result:, error: - return if error - %p Error: #{error} + %p= t(:error, error:) - return unless result %p - Loading Pokémon from + = t(:loading_pokemon_from) %a(href="https://pokeapi.co/")< PokéAPI :ruby diff --git a/example/app/pages/demos/pokemon/page.intl.en-US.toml b/example/app/pages/demos/pokemon/page.intl.en-US.toml new file mode 100644 index 00000000..a97ef61e --- /dev/null +++ b/example/app/pages/demos/pokemon/page.intl.en-US.toml @@ -0,0 +1,2 @@ +loading_pokemon_from = "Loading Pokémon from" +error = "Error: %s" diff --git a/example/app/pages/demos/pokemon/page.intl.sv-SE.toml b/example/app/pages/demos/pokemon/page.intl.sv-SE.toml new file mode 100644 index 00000000..4e4acaac --- /dev/null +++ b/example/app/pages/demos/pokemon/page.intl.sv-SE.toml @@ -0,0 +1,2 @@ +loading_pokemon_from = "Laddar Pokémon från" +error = "Error: %s" diff --git a/example/app/pages/demos/svg/page.haml b/example/app/pages/demos/svg/page.haml index 0d554b03..c85c588e 100644 --- a/example/app/pages/demos/svg/page.haml +++ b/example/app/pages/demos/svg/page.haml @@ -2,6 +2,8 @@ Heading = import("/app/components/Layout/Heading") Clock = import("/app/components/Clock") + translations("sv-SE", "en-US") + PATHS = [ "M 10 10 C 20 20, 40 20, 50 10", "M 70 10 C 70 20, 110 20, 110 10", @@ -35,10 +37,8 @@ } %article - %Heading(level=2) SVG demo - %p - This example shows some SVGs. - The time is updated on the server. + %Heading(level=2)= t(:title) + %p= t(:description) .flex .col %Clock diff --git a/example/app/pages/demos/svg/page.intl.en-US.toml b/example/app/pages/demos/svg/page.intl.en-US.toml new file mode 100644 index 00000000..b3b52d70 --- /dev/null +++ b/example/app/pages/demos/svg/page.intl.en-US.toml @@ -0,0 +1,5 @@ +title = "SVG demo" +description = """ +This example shows some SVGs. +The clock is updated on the server. +""" diff --git a/example/app/pages/demos/svg/page.intl.sv-SE.toml b/example/app/pages/demos/svg/page.intl.sv-SE.toml new file mode 100644 index 00000000..420b0913 --- /dev/null +++ b/example/app/pages/demos/svg/page.intl.sv-SE.toml @@ -0,0 +1,5 @@ +title = "SVG demo" +description = """ +Detta exemplet visar några SVGs. +Klockan uppdateras på servern. +""" diff --git a/example/app/pages/demos/todo/page.haml b/example/app/pages/demos/todo/page.haml index d41c9ffa..1b6bed79 100644 --- a/example/app/pages/demos/todo/page.haml +++ b/example/app/pages/demos/todo/page.haml @@ -1,4 +1,6 @@ :ruby + translations("en-US", "sv-SE") + Card = import("/app/components/Layout/Card") Link = import("/app/components/UI/Link") @@ -115,15 +117,11 @@ end def pluralize(count, singular, plural) - format( - "%d %s", - count, - if count == 1 - singular - else - plural - end - ) + if count == 1 + singular + else + plural + end end def toggle_checkbox_state @@ -297,7 +295,9 @@ \. %form.form(onsubmit=handle_submit){key: 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="") + %input.input(required autofocus name="new_todo" value=""){ + placeholder: t(:what_needs_to_be_done) + } .main %ul.list = get_filtered_items.map do |item| @@ -320,13 +320,15 @@ %button.delete-button(onclick=handle_delete){name: item[:id]} .footer .footer-grid - .items-left #{pluralize(get_filtered_items("active").size, "item", "items")} left + .items-left + - count = get_filtered_items("active").size + = t(:item, pluralize(count, :one, :many), count:) .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 + }= t(:filter, name.to_sym) .clear-completed - completed = get_filtered_items("completed") = if completed.length.nonzero? - %button.clear-button(onclick=handle_clear_completed) Clear completed + %button.clear-button(onclick=handle_clear_completed)= t(:clear_completed) diff --git a/example/app/pages/demos/todo/page.intl.en-US.toml b/example/app/pages/demos/todo/page.intl.en-US.toml new file mode 100644 index 00000000..2d2e28eb --- /dev/null +++ b/example/app/pages/demos/todo/page.intl.en-US.toml @@ -0,0 +1,11 @@ +what_needs_to_be_done = "What needs to be done?" +clear_completed = "Clear completed" + +[item] + one = "%d item left" + many = "%d items left" + +[filter] + all = "All" + active = "Active" + completed = "Completed" diff --git a/example/app/pages/demos/todo/page.intl.sv-SE.toml b/example/app/pages/demos/todo/page.intl.sv-SE.toml new file mode 100644 index 00000000..e3dd2c49 --- /dev/null +++ b/example/app/pages/demos/todo/page.intl.sv-SE.toml @@ -0,0 +1,11 @@ +what_needs_to_be_done = "Vad behöver göras?" +clear_completed = "Rensa färdiga" + +[item] + one = "%d återstår" + many = "%d återstår" + +[filter] + all = "Alla" + active = "Aktiva" + completed = "Färdiga" diff --git a/example/app/pages/demos/tree/FileContents.haml b/example/app/pages/demos/tree/FileContents.haml index 813026c1..99baa822 100644 --- a/example/app/pages/demos/tree/FileContents.haml +++ b/example/app/pages/demos/tree/FileContents.haml @@ -1,6 +1,8 @@ :ruby Highlight = import("/app/components/Layout/Highlight") + translations("en-US", "sv-SE") + ALLOWED_EXTENSIONS = %w[.rb .css .haml] :css @@ -26,22 +28,22 @@ - props => { root:, path: } - return unless path - %p Please choose a file + %p= t(:please_choose_a_file) - path = File.expand_path(path, "/") - return unless path.start_with?("/app/") - %p #{path} is not valid + %p= t(:invalid_path, path:) - absolute_path = File.join(root, path) - basename = File.basename(path) - return unless File.file?(absolute_path) - %p #{basename} is not a file + %p= t(:path_is_not_a_file, path:) - extname = File.extname(basename) - return unless ALLOWED_EXTENSIONS.include?(extname) - %p #{path} is not a #{ALLOWED_EXTENSIONS.join("/")}-file + %p= t(:unallowed_extension, path:, extensions: ALLOWED_EXTENSIONS.join("/")) :ruby source = File.read(absolute_path) diff --git a/example/app/pages/demos/tree/FileContents.intl.en-US.toml b/example/app/pages/demos/tree/FileContents.intl.en-US.toml new file mode 100644 index 00000000..c5e5c73c --- /dev/null +++ b/example/app/pages/demos/tree/FileContents.intl.en-US.toml @@ -0,0 +1,4 @@ +please_choose_a_file = "Please choose a file." +invalid_path = "%s is not valid." +not_a_file = "%s is not a file." +unallowed_extension = "%s is not a %s-file." diff --git a/example/app/pages/demos/tree/FileContents.intl.sv-SE.toml b/example/app/pages/demos/tree/FileContents.intl.sv-SE.toml new file mode 100644 index 00000000..6e8054c3 --- /dev/null +++ b/example/app/pages/demos/tree/FileContents.intl.sv-SE.toml @@ -0,0 +1,4 @@ +please_choose_a_file = "Vänligen välj en fil." +invalid_path = "%s är inte tillåten." +path_is_not_a_file = "%s är inte en fil." +unallowed_extension = "%s är inte en %s-fil." diff --git a/example/app/pages/demos/tree/page.haml b/example/app/pages/demos/tree/page.haml index 31ba9780..ae351684 100644 --- a/example/app/pages/demos/tree/page.haml +++ b/example/app/pages/demos/tree/page.haml @@ -1,6 +1,8 @@ :ruby Heading = import("/app/components/Layout/Heading") + translations("en-US", "sv-SE") + Entry = import("./Entry") FileContents = import("./FileContents") @@ -36,11 +38,11 @@ - root = File.expand_path(".") -%article - %Heading(level=2) App tree +%article(lang=lang) + %Heading(level=2)= t(:title) %p - This page shows the file structure of the example app. + = t(:description) %a(href="https://github.com/mayu-live/framework/tree/main/example/app")< .browser diff --git a/example/app/pages/demos/tree/page.intl.en-US.toml b/example/app/pages/demos/tree/page.intl.en-US.toml new file mode 100644 index 00000000..b9249609 --- /dev/null +++ b/example/app/pages/demos/tree/page.intl.en-US.toml @@ -0,0 +1,2 @@ +title = "App tree" +description = "This page shows the file structure of the example app." diff --git a/example/app/pages/demos/tree/page.intl.en.toml b/example/app/pages/demos/tree/page.intl.en.toml new file mode 100644 index 00000000..169d39c3 --- /dev/null +++ b/example/app/pages/demos/tree/page.intl.en.toml @@ -0,0 +1 @@ +description = "This page shows the file structure of the example app." diff --git a/example/app/pages/demos/tree/page.intl.sv-SE.toml b/example/app/pages/demos/tree/page.intl.sv-SE.toml new file mode 100644 index 00000000..b3934e6d --- /dev/null +++ b/example/app/pages/demos/tree/page.intl.sv-SE.toml @@ -0,0 +1,2 @@ +title = "Applikationsträd" +description = "Denna sidan visar filstrukturen i exempelapplikationen." diff --git a/example/app/pages/demos/tree/page.intl.sv.toml b/example/app/pages/demos/tree/page.intl.sv.toml new file mode 100644 index 00000000..374e6af6 --- /dev/null +++ b/example/app/pages/demos/tree/page.intl.sv.toml @@ -0,0 +1 @@ +description = "Denna sidan visar filstrukturen i exempelapplikationen." diff --git a/example/app/root.haml b/example/app/root.haml index b8f2c5b4..5f97dd34 100644 --- a/example/app/root.haml +++ b/example/app/root.haml @@ -1,9 +1,10 @@ -%html +:ruby + translations("sv-SE", "en-US") +%html(lang="en") %head %meta(name="charset" value="utf-8") %meta(name="generator" value="github.com/mayu-live/framework") %meta(name="viewport" content="width=device-width, initial-scale=1") - %title - Mayu Live + %title= t(:title) %body.body %slot diff --git a/example/app/root.intl.en-US.toml b/example/app/root.intl.en-US.toml new file mode 100644 index 00000000..0831e656 --- /dev/null +++ b/example/app/root.intl.en-US.toml @@ -0,0 +1 @@ +title = "Mayu Live" diff --git a/example/app/root.intl.sv-SE.toml b/example/app/root.intl.sv-SE.toml new file mode 100644 index 00000000..0831e656 --- /dev/null +++ b/example/app/root.intl.sv-SE.toml @@ -0,0 +1 @@ +title = "Mayu Live" diff --git a/lib/mayu/client/src/main.ts b/lib/mayu/client/src/main.ts index 67e07515..ab1326f0 100644 --- a/lib/mayu/client/src/main.ts +++ b/lib/mayu/client/src/main.ts @@ -208,6 +208,9 @@ async function main(url: string) { case "session.exception": showException(payload); break; + case "session.set_prefer_language": + logger.info("Changed preferred language:", payload); + break; case "ping": const values = Object.values(payload.values) as number[]; const mean = values.reduce((a, b) => a + b, 0.0) / values.length; diff --git a/lib/mayu/component/base.rb b/lib/mayu/component/base.rb index 493a8a72..d6f085f1 100644 --- a/lib/mayu/component/base.rb +++ b/lib/mayu/component/base.rb @@ -43,6 +43,13 @@ def __mayu_resource? end end + LoadedTranslations = T.let({}.freeze, T::Hash[String, T.untyped]) + + sig { returns(T::Hash[String, T.untyped]) } + def self.loaded_translations + const_get(:LoadedTranslations) + end + sig do overridable .params(props: Component::Props, state: Component::State) @@ -52,6 +59,24 @@ def self.get_derived_state_from_props(props, state) {} end + sig { returns(String) } + def lang + props[:lang] or raise "There are no translations!" + end + + sig { params(path: Symbol, replacements: T.untyped).returns(String) } + def t(*path, **replacements) + value = + self + .class + .loaded_translations + .fetch(lang) { return "No translations" } + .dig(*path) + + return "Missing translation for #{path.join(".")}!" unless value + format(value, replacements) + end + sig { params(wrapper: Wrapper).void } def initialize(wrapper) @__wrapper = wrapper diff --git a/lib/mayu/component/helpers.rb b/lib/mayu/component/helpers.rb index a97d7731..cdf05fa0 100644 --- a/lib/mayu/component/helpers.rb +++ b/lib/mayu/component/helpers.rb @@ -37,6 +37,16 @@ def alert(message) @vnode.action(:alert, message) end + sig { params(languages: T::Array[String]).returns(T.nilable(String)) } + def get_accepted_language(languages) + @vnode.get_accepted_language(languages) || languages.first + end + + sig { params(language: String).void } + def set_prefer_language(language) + @vnode.set_prefer_language(language) + end + # sig { returns(Mayu::State::Store) } # def store # @vnode.store diff --git a/lib/mayu/component/wrapper.rb b/lib/mayu/component/wrapper.rb index ac2a9e97..ea647591 100644 --- a/lib/mayu/component/wrapper.rb +++ b/lib/mayu/component/wrapper.rb @@ -53,6 +53,11 @@ def assets @instance.class.assets end + sig { returns(T::Array[String]) } + def available_languages + @instance.class.loaded_translations.keys + end + sig { returns(T.nilable(Resources::Resource)) } def resource if @instance.class.respond_to?(:__resource) diff --git a/lib/mayu/environment.rb b/lib/mayu/environment.rb index 60fb87c3..a5852400 100644 --- a/lib/mayu/environment.rb +++ b/lib/mayu/environment.rb @@ -101,11 +101,13 @@ def create_store(initial_state: {}) end sig do - params(request_path: String, headers: T::Hash[String, String]).returns( - VDOM::Descriptor - ) + params( + request_path: String, + accept_language: AcceptLanguage::Parser, + headers: T::Hash[String, String] + ).returns(VDOM::Descriptor) end - def load_root(request_path, headers: {}) + def load_root(request_path, accept_language:, 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... @@ -123,7 +125,13 @@ def load_root(request_path, headers: {}) resources.load_resource(File.join("/", "app", "root")).type => Resources::Types::Component => root - request_info = { path:, params:, query:, headers: }.freeze + request_info = { + path:, + params:, + query:, + headers:, + accept_language: + }.freeze # Apply the layouts. # NOTE: Pages should probably be their own diff --git a/lib/mayu/resources/resource.rb b/lib/mayu/resources/resource.rb index a110d3fc..4d1ea67d 100644 --- a/lib/mayu/resources/resource.rb +++ b/lib/mayu/resources/resource.rb @@ -94,6 +94,9 @@ def generate_assets(assets_dir) end end + sig { returns(String) } + def basename_without_extension = File.basename(@path, ".*") + sig { returns(String) } def app_root = @registry.root diff --git a/lib/mayu/resources/transformers/css/crass_patches.rb b/lib/mayu/resources/transformers/css/crass_patches.rb new file mode 100644 index 00000000..a8338376 --- /dev/null +++ b/lib/mayu/resources/transformers/css/crass_patches.rb @@ -0,0 +1,55 @@ +# typed: false +require "crass" + +# This patch parses @media and @layer as style rules. + +module Crass + class Parser + def consume_at_rule(input = @tokens) + rule = {} + + rule[:tokens] = input.collect do + rule[:name] = input.consume[:value] + rule[:prelude] = [] + + while token = input.consume + node = token[:node] + + if node == :comment + # Non-standard. + next + elsif node == :semicolon + break + elsif node === :"{" + case rule[:name] + in "media" | "layer" + rule[:rules] = consume_rules.map do |rule| + rule[:node] == :qualified_rule ? create_style_rule(rule) : rule + end + else + # Note: The spec says the block should _be_ the consumed simple + # block, but Simon Sapin's CSS parsing tests and tinycss2 expect + # only the _value_ of the consumed simple block here. I assume I'm + # interpreting the spec too literally, so I'm going with the + # tinycss2 behavior. + rule[:block] = consume_simple_block(input)[:value] + end + break + elsif node == :simple_block && token[:start] == "{" + # Note: The spec says the block should _be_ the simple block, but + # Simon Sapin's CSS parsing tests and tinycss2 expect only the + # _value_ of the simple block here. I assume I'm interpreting the + # spec too literally, so I'm going with the tinycss2 behavior. + rule[:block] = token[:value] + break + else + input.reconsume + rule[:prelude] << consume_component_value(input) + end + end + end + + create_node(:at_rule, rule) + end + end +end diff --git a/lib/mayu/resources/transformers/css/transformer.rb b/lib/mayu/resources/transformers/css/transformer.rb index 825b5a1a..bd7de712 100644 --- a/lib/mayu/resources/transformers/css/transformer.rb +++ b/lib/mayu/resources/transformers/css/transformer.rb @@ -5,6 +5,7 @@ require "base64" require "digest/sha2" require "set" +require_relative "crass_patches" module Mayu module Resources diff --git a/lib/mayu/resources/types.rb b/lib/mayu/resources/types.rb index 069ac4d9..8bd693ec 100644 --- a/lib/mayu/resources/types.rb +++ b/lib/mayu/resources/types.rb @@ -6,6 +6,7 @@ require_relative "types/image" require_relative "types/stylesheet" require_relative "types/javascript" +require_relative "types/translations" require_relative "types/svg" module Mayu @@ -22,6 +23,8 @@ def self.for_path(path) return Component when /\.js\z/ return JavaScript + when Translations::FILENAME_RE + return Translations when /\.css\z/ return Stylesheet when /\.(png|jpe?g|gif|webp)$\z/ diff --git a/lib/mayu/resources/types/component.rb b/lib/mayu/resources/types/component.rb index 50c6e014..e4eb56ec 100644 --- a/lib/mayu/resources/types/component.rb +++ b/lib/mayu/resources/types/component.rb @@ -36,6 +36,21 @@ def self.svg(path) __resource.import(path) => SVG => impl impl end + + sig { params(locales: String).void } + def self.translations(*locales) + const_set( + :LoadedTranslations, + locales + .each_with_object({}) do |locale, hash| + __resource.import( + "./#{__resource.basename_without_extension}.intl.#{locale}.toml" + ) => Translations => impl + hash[locale] = impl.to_h.freeze + end + .freeze + ) + end end end end diff --git a/lib/mayu/resources/types/translations.rb b/lib/mayu/resources/types/translations.rb new file mode 100644 index 00000000..5c30832e --- /dev/null +++ b/lib/mayu/resources/types/translations.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# typed: strict + +require "toml-rb" +require_relative "base" + +module Mayu + module Resources + module Types + class Translations < Base + extend T::Sig + + FILENAME_RE = /\.intl\.(.+)\.toml\z/ + + sig { params(resource: Resource).void } + def initialize(resource) + @resource = resource + + @locale_identifier = + T.let(File.basename(resource.path)[FILENAME_RE, 1].to_s, String) + + @translations = + T.let( + TomlRB.parse( + resource.read(encoding: "utf-8"), + symbolize_keys: true + ), + T::Hash[Symbol, T.untyped] + ) + end + + sig { returns(T::Array[Asset]) } + def assets + [] + end + + sig { returns(T::Hash[Symbol, T.untyped]) } + def to_h + @translations + end + + MarshalFormat = T.type_alias { [String, T::Hash[Symbol, T.untyped]] } + + sig { returns(MarshalFormat) } + def marshal_dump + [@locale_identifier, @translations] + end + + sig { params(args: MarshalFormat).void } + def marshal_load(args) + @locale_identifier, @translations = args + end + end + end + end +end diff --git a/lib/mayu/server/app.rb b/lib/mayu/server/app.rb index 5d0d5d1a..3eb620aa 100644 --- a/lib/mayu/server/app.rb +++ b/lib/mayu/server/app.rb @@ -16,6 +16,9 @@ class App PING_INTERVAL = 2 # seconds NANOID_RE = /[\w-]{21}/ + PREFER_LANGUAGE_COOKIE = "mayu-prefer-language" + TOKEN_COOKIE = "mayu-token" + MIME_TYPES = T.let( { @@ -256,6 +259,7 @@ def handle_session_post(request, session_id, path) "content-type": "application/json", "set-cookie": set_token_cookie_value(session) } + session.log.push( :pong, pong: ping, @@ -270,11 +274,23 @@ def handle_session_post(request, session_id, path) session.handle_callback("navigate", { path: }) Protocol::HTTP::Response[200, headers, ["ok"]] in ["callback", String => callback_id] + prefer_language = session.prefer_language + session.handle_callback( callback_id, JSON.parse(request.read, symbolize_names: true) ) - headers = { "set-cookie": set_token_cookie_value(session) } + + headers = Protocol::HTTP::Headers.new + + unless session.prefer_language == prefer_language + headers.add( + "set-cookie", + set_prefer_language_cookie_value(session.prefer_language.to_s) + ) + end + + headers.add("set-cookie", set_token_cookie_value(session)) Protocol::HTTP::Response[200, headers, ["ok"]] end end @@ -305,11 +321,14 @@ def handle_session_init(request) "Expected sec-fetch-dest to equal document but got #{value.inspect}" end + cookies = get_cookies(request) + session = Session.new( environment: @environment, path: request.path, - headers: request.headers.to_h.freeze + headers: request.headers.to_h.freeze, + prefer_language: cookies[PREFER_LANGUAGE_COOKIE] ) body = Async::HTTP::Body::Writable.new @@ -465,25 +484,45 @@ def perform_transfer(session, stream, task: Async::Task.current) stream.close end - sig { params(request: Protocol::HTTP::Request).returns(String) } - def get_token_cookie_value(request) - Array(request.headers["cookie"]).each do |str| - if match = str.match(/^mayu-token=(\w+)/) - return match[1].to_s.tap { Session.validate_token!(_1) } - end + sig do + params(request: Protocol::HTTP::Request).returns( + T::Hash[String, String] + ) + end + def get_cookies(request) + Array(request.headers["cookie"]).each_with_object({}) do |str, obj| + key, value = str.split("=", 2) + obj[key] = value end + end + + sig { params(language: String).returns(String) } + def set_prefer_language_cookie_value(language) + return "#{PREFER_LANGUAGE_COOKIE}=; max-age=0" if language.empty? - raise Errors::CookieNotSet + [ + "#{PREFER_LANGUAGE_COOKIE}=#{language}", + "expires=", + "path=/", + "secure", + "SameSite=Strict" + ].join("; ") + end + + sig { params(request: Protocol::HTTP::Request).returns(String) } + def get_token_cookie_value(request) + get_cookies(request) + .fetch(TOKEN_COOKIE) { raise Errors::CookieNotSet } + .to_s + .tap { Session.validate_token!(_1) } end sig { params(session: Session, ttl_seconds: Integer).returns(String) } def set_token_cookie_value(session, ttl_seconds: 60) - expires = Time.now.utc + ttl_seconds - [ - "mayu-token=#{session.token}", + "#{TOKEN_COOKIE}=#{session.token}", "path=/__mayu/session/#{session.id}/", - "expires=#{expires.httpdate}", + "max-age=#{ttl_seconds}", "secure", "HttpOnly", "SameSite=Strict" diff --git a/lib/mayu/server/errors.rb b/lib/mayu/server/errors.rb index 92c037cc..234aeff9 100644 --- a/lib/mayu/server/errors.rb +++ b/lib/mayu/server/errors.rb @@ -45,6 +45,7 @@ def self.handle_exceptions(&block) def self.log_exceptions(&block) yield rescue => e + Console.logger.error(self, e) Console.logger.error(self, "#{e.class.name}: #{e.message}") raise end diff --git a/lib/mayu/session.rb b/lib/mayu/session.rb index 848a07ab..35653339 100644 --- a/lib/mayu/session.rb +++ b/lib/mayu/session.rb @@ -2,6 +2,7 @@ require "time" require "nanoid" +require "accept_language" require_relative "environment" require_relative "vdom/vtree" require_relative "vdom/marshalling" @@ -92,6 +93,8 @@ def authorized?(token) attr_reader :token sig { returns(String) } attr_reader :path + sig { returns(T.nilable(String)) } + attr_reader :prefer_language sig { returns(T::Hash[String, String]) } attr_reader :headers sig { returns(Environment) } @@ -101,6 +104,12 @@ def authorized?(token) sig { returns(EventStream::Log) } attr_reader :log + sig { params(language: String).returns(String) } + def prefer_language=(language) + @accept_language = nil + @prefer_language = language + end + sig { params(timeout_seconds: T.any(Float, Integer)).returns(T::Boolean) } def expired?(timeout_seconds = 30) seconds_since_last_ping > timeout_seconds @@ -116,16 +125,30 @@ def seconds_since_last_ping environment: Environment, path: String, headers: T::Hash[String, String], + prefer_language: T.nilable(String), vtree: T.nilable(VDOM::VTree), store: T.nilable(State::Store) ).void end - def initialize(environment:, path:, headers: {}, vtree: nil, store: nil) + def initialize( + environment:, + path:, + headers: {}, + prefer_language: nil, + vtree: nil, + store: nil + ) @environment = environment + @id = T.let(Nanoid.generate, String) @token = T.let(self.class.generate_token, String) + @path = path @headers = headers + + @prefer_language = prefer_language + @accept_language = T.let(nil, T.nilable(AcceptLanguage::Parser)) + @vtree = T.let(vtree || VDOM::VTree.new(session: self), VDOM::VTree) @log = T.let(EventStream::Log.new, EventStream::Log) @store = @@ -133,11 +156,36 @@ def initialize(environment:, path:, headers: {}, vtree: nil, store: nil) store || environment.create_store(initial_state: {}), State::Store ) - @app = T.let(environment.load_root(path, headers:), VDOM::Descriptor) + + @app = + T.let( + environment.load_root(path, headers:, accept_language:), + VDOM::Descriptor + ) @last_ping_at = T.let(Time.now.to_f, Float) @barrier = T.let(Async::Barrier.new, Async::Barrier) end + sig { returns(AcceptLanguage::Parser) } + def accept_language + @accept_language ||= + begin + accept_language = + AcceptLanguage.parse(Array(@headers["accept-language"]).join(",")) + + if @prefer_language + accept_language + .instance_variable_get(:@languages_range) + .tap do |languages_range| + languages_range.store(@prefer_language, BigDecimal(2)) + languages_range["en-US"] ||= BigDecimal("0.01") + end + end + + accept_language + end + end + sig { void } def stop! @barrier.stop @@ -231,6 +279,7 @@ def marshal_dump @token, @path, @headers, + @prefer_language, VDOM::Marshalling.dump(@vtree), Marshal.dump(@store.state) ] @@ -238,11 +287,11 @@ def marshal_dump sig { params(a: T::Array[T.untyped]).void } def marshal_load(a) - @id, @token, @path, @headers, dumped_vtree, state = a + @id, @token, @path, @headers, @prefer_language, dumped_vtree, state = a @last_ping_at = Time.now.to_f @vtree = VDOM::Marshalling.restore(dumped_vtree, session: self) @store = @environment.create_store(initial_state: Marshal.restore(state)) - @app = @environment.load_root(@path, headers:) + @app = @environment.load_root(@path, headers:, accept_language:) @barrier = Async::Barrier.new @log = EventStream::Log.new end @@ -274,14 +323,14 @@ def handle_callback(callback_id, payload = {}) sig { void } def rerender - @app = @environment.load_root(path, headers:) + @app = @environment.load_root(path, headers:, accept_language:) @vtree.replace_root(@app) end sig { params(path: String).void } def navigate(path) Console.logger.info(self, "navigate: #{path.inspect}") - @app = @environment.load_root(path, headers:) + @app = @environment.load_root(path, headers:, accept_language:) @path = path @vtree.replace_root(@app) end @@ -330,6 +379,10 @@ def run(task: Async::Task.current, &block) yield [:navigate, path: href.force_encoding("utf-8")] in [:action, payload] yield [:action, payload] + in [:set_prefer_language, language] + self.prefer_language = language + rerender + yield [:set_prefer_language, language] in [:update_finished, *] # noop else diff --git a/lib/mayu/vdom/vnode.rb b/lib/mayu/vdom/vnode.rb index 19ea6973..4f738fb2 100644 --- a/lib/mayu/vdom/vnode.rb +++ b/lib/mayu/vdom/vnode.rb @@ -17,7 +17,6 @@ class VNode sig { returns(Id) } attr_reader :id - sig { returns(Id) } attr_accessor :dom_parent_id @@ -28,18 +27,26 @@ def dom_id sig { returns(Descriptor) } attr_accessor :descriptor + sig { returns(Descriptor::ElementType) } def type = descriptor.type + sig { returns(Component::Props) } def props = descriptor.props + sig { returns(T.untyped) } def key = descriptor.key + sig { returns(Children) } attr_accessor :children sig { returns(T.nilable(Component::Wrapper)) } attr_reader :component + sig { returns(T.nilable(String)) } + def lang + end + sig { returns(T::Boolean) } def dom? = type.is_a?(Symbol) @@ -127,7 +134,15 @@ def fetch(url, method: :GET, headers: {}, body: nil) sig { returns(T.nilable(Component::Wrapper)) } def init_component - @component ||= Component.wrap(self, type, props) + type = self.type + return unless Component.component_class?(type) + type = T.cast(type, T.class_of(Component::Base)) + + @component ||= + begin + lang = @vtree.get_accepted_language(type.loaded_translations.keys) + Component.wrap(self, type, { **props, lang: }) + end end sig { params(path: String).void } @@ -140,6 +155,16 @@ def action(type, payload) @vtree.action(type, payload) end + sig { params(languages: T::Array[String]).returns(T.nilable(String)) } + def get_accepted_language(languages) + @vtree.get_accepted_language(languages) + end + + sig { params(language: String).void } + def set_prefer_language(language) + @vtree.set_prefer_language(language) + end + sig { void } def enqueue_update! @vtree.enqueue_update!(self) diff --git a/lib/mayu/vdom/vtree.rb b/lib/mayu/vdom/vtree.rb index 53fc5556..56ff9b05 100644 --- a/lib/mayu/vdom/vtree.rb +++ b/lib/mayu/vdom/vtree.rb @@ -111,6 +111,8 @@ def update(assets: T::Set[String].new, metrics: nil, &block) yield [:action, payload] in [:exception, error] yield [:exception, error] + in [:set_prefer_language, language] + yield [:set_prefer_language, language] in [:pong, timestamp] yield [:pong, timestamp] in VNode => vnode @@ -185,6 +187,21 @@ def initialize(session:, task: Async::Task.current) @asset_refs = T.let(RefCounter.new, RefCounter[String]) end + DEFAULT_ACCEPT_LANGUAGE = + T.let(AcceptLanguage.parse("en, *;q=0.5"), AcceptLanguage::Parser) + + sig { params(languages: T::Array[String]).returns(T.nilable(String)) } + def get_accepted_language(languages) + T.unsafe(@session.accept_language).match(*languages) || + T.unsafe(DEFAULT_ACCEPT_LANGUAGE).match(*languages) || languages.first + end + + sig { params(language: String).void } + def set_prefer_language(language) + @session.prefer_language = language + @update_queue.enqueue([:set_prefer_language, language]) + end + sig { returns(T::Array[T.untyped]) } def marshal_dump [@root, @id_generator, @sent_stylesheets] @@ -337,7 +354,8 @@ def patch_vnode(ctx, vnode, descriptor, lifecycles:) if component.should_update?(descriptor.props, component.next_state) vnode.descriptor = descriptor prev_props, prev_state = component.props, component.state - component.props = descriptor.props + lang = get_accepted_language(component.available_languages) + component.props = { **descriptor.props, lang: } component.state = component.next_state.clone descriptors = add_comments_between_texts( diff --git a/mayu-live.gemspec b/mayu-live.gemspec index 0abbffbd..2c3c7c09 100644 --- a/mayu-live.gemspec +++ b/mayu-live.gemspec @@ -36,6 +36,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency "accept_language", "~> 2.0.3" spec.add_dependency "async", "~> 2.0.3" spec.add_dependency "async-container", "~> 0.16.12" spec.add_dependency "async-http", "~> 0.59.1" @@ -62,4 +63,5 @@ Gem::Specification.new do |spec| spec.add_dependency "syntax_tree-xml", '~> 0.1.0' spec.add_dependency "terminal-table", "~> 3.0.1" spec.add_dependency "toml-rb", "~> 2.2.0" + spec.add_dependency "twitter_cldr", "~> 6.11.4" end diff --git a/sorbet/rbi/gems/accept_language@2.0.3.rbi b/sorbet/rbi/gems/accept_language@2.0.3.rbi new file mode 100644 index 00000000..4200ae05 --- /dev/null +++ b/sorbet/rbi/gems/accept_language@2.0.3.rbi @@ -0,0 +1,132 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `accept_language` gem. +# Please instead update this file by running `bin/tapioca gem accept_language`. + +# Tiny library for parsing the Accept-Language header. +# +# @example +# AcceptLanguage.parse("da, en-GB;q=0.8, en;q=0.7") # => #0.1e1, "en-GB"=>0.8e0, "en"=>0.7e0}> +# @see https://tools.ietf.org/html/rfc2616#section-14.4 +# +# source://accept_language//lib/accept_language.rb#7 +module AcceptLanguage + class << self + # @example + # parse("da, en-GB;q=0.8, en;q=0.7") # => #0.1e1, "en-GB"=>0.8e0, "en"=>0.7e0}> + # @note Parse an Accept-Language header field into a language range. + # @return [#match] a parser that responds to #match. + # + # source://accept_language//lib/accept_language.rb#12 + def parse(field); end + end +end + +# @example +# Matcher.new("da" => 1.0, "en-GB" => 0.8, "en" => 0.7).call(:ug, :kk, :ru, :en) # => :en +# Matcher.new("da" => 1.0, "en-GB" => 0.8, "en" => 0.7).call(:fr, :en, :"en-GB") # => :"en-GB" +# @note Compare an Accept-Language header value with your application's +# supported languages to find the common languages that could be presented +# to a user. +# +# source://accept_language//lib/accept_language/matcher.rb#10 +class AcceptLanguage::Matcher + # @param languages_range [Hash] A list of accepted + # languages with their respective qualities. + # @return [Matcher] a new instance of Matcher + # + # source://accept_language//lib/accept_language/matcher.rb#17 + def initialize(**languages_range); end + + # @example Uyghur, Kazakh, Russian and English languages are available. + # call(:ug, :kk, :ru, :en) + # @param available_langtags [Array] The list of available + # languages. + # @return [String, Symbol, nil] The language that best matches. + # + # source://accept_language//lib/accept_language/matcher.rb#38 + def call(*available_langtags); end + + # Returns the value of attribute excluded_langtags. + # + # source://accept_language//lib/accept_language/matcher.rb#13 + def excluded_langtags; end + + # Returns the value of attribute preferred_langtags. + # + # source://accept_language//lib/accept_language/matcher.rb#13 + def preferred_langtags; end + + private + + # source://accept_language//lib/accept_language/matcher.rb#57 + def any_other_langtag(*available_langtags); end + + # source://accept_language//lib/accept_language/matcher.rb#67 + def drop_unacceptable(*available_langtags); end + + # @return [Boolean] + # + # source://accept_language//lib/accept_language/matcher.rb#75 + def unacceptable?(langtag); end + + # @return [Boolean] + # + # source://accept_language//lib/accept_language/matcher.rb#81 + def wildcard?(value); end +end + +# source://accept_language//lib/accept_language/matcher.rb#11 +AcceptLanguage::Matcher::WILDCARD = T.let(T.unsafe(nil), String) + +# @example +# Parser.new("da, en-GB;q=0.8, en;q=0.7") # => #0.1e1, "en-GB"=>0.8e0, "en"=>0.7e0}> +# @note Parser for Accept-Language header fields. +# @see https://tools.ietf.org/html/rfc2616#section-14.4 +# +# source://accept_language//lib/accept_language/parser.rb#10 +class AcceptLanguage::Parser + # @param field [String] The Accept-Language header field to parse. + # @return [Parser] a new instance of Parser + # @see https://tools.ietf.org/html/rfc2616#section-14.4 + # + # source://accept_language//lib/accept_language/parser.rb#20 + def initialize(field); end + + # Returns the value of attribute languages_range. + # + # source://accept_language//lib/accept_language/parser.rb#16 + def languages_range; end + + # @example Uyghur, Kazakh, Russian and English languages are available. + # match(:ug, :kk, :ru, :en) + # @param available_langtags [Array] The list of available + # languages. + # @return [String, Symbol, nil] The language that best matches. + # + # source://accept_language//lib/accept_language/parser.rb#29 + def match(*available_langtags); end + + private + + # @example + # import('da, en-GB;q=0.8, en;q=0.7') # => {"da"=>0.1e1, "en-GB"=>0.8e0, "en"=>0.7e0} + # @return [Hash] A list of accepted languages with their + # respective qualities. + # + # source://accept_language//lib/accept_language/parser.rb#39 + def import(field); end +end + +# source://accept_language//lib/accept_language/parser.rb#11 +AcceptLanguage::Parser::DEFAULT_QUALITY = T.let(T.unsafe(nil), BigDecimal) + +# source://accept_language//lib/accept_language/parser.rb#12 +AcceptLanguage::Parser::SEPARATOR = T.let(T.unsafe(nil), String) + +# source://accept_language//lib/accept_language/parser.rb#13 +AcceptLanguage::Parser::SPACE = T.let(T.unsafe(nil), String) + +# source://accept_language//lib/accept_language/parser.rb#14 +AcceptLanguage::Parser::SUFFIX = T.let(T.unsafe(nil), String) diff --git a/sorbet/tapioca/require.rb b/sorbet/tapioca/require.rb index ddfdbe3a..8294545c 100644 --- a/sorbet/tapioca/require.rb +++ b/sorbet/tapioca/require.rb @@ -3,6 +3,7 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "vendor", "patches")) +require "accept_language" require "async/barrier" require "async/clock" require "async/container"