From 5188eb357ca20f19b1e37ebbd84efbd464b3e770 Mon Sep 17 00:00:00 2001 From: Andreas Alin Date: Sat, 23 Mar 2024 12:59:41 -0500 Subject: [PATCH] Add all the code from the rewrite repo This is the code from the rewrite that I started writing in August 2023. --- Gemfile.lock | 91 +- lib/mayu.rb | 4 - lib/mayu/__test__/configuration/test.toml | 33 + lib/mayu/__test__/routes/layout.haml | 0 lib/mayu/__test__/routes/not_found.haml | 0 lib/mayu/__test__/routes/page.haml | 0 lib/mayu/__test__/routes/params/:id/page.haml | 0 lib/mayu/__test__/routes/subpage/page.haml | 0 .../__test__/routes/subpage2/hello/page.haml | 0 lib/mayu/__test__/routes/subpage2/layout.haml | 0 .../__test__/routes/subpage2/not_found.haml | 0 lib/mayu/__test__/routes/subpage2/page.haml | 0 lib/mayu/app_metrics.rb | 93 - lib/mayu/banner.rb | 12 - lib/mayu/client/.dockerignore | 1 - lib/mayu/client/.gitignore | 4 +- lib/mayu/client/README.md | 17 - lib/mayu/client/package-lock.json | 1940 +++++++++++++++++ lib/mayu/client/package.json | 24 +- lib/mayu/client/rollup.config.js | 14 +- lib/mayu/client/src/DecompressionStream.ts | 15 - .../client/src/DecompressionStreamPolyfill.ts | 43 - lib/mayu/client/src/MimeTypes.ts | 4 - lib/mayu/client/src/NodeTree.ts | 445 ---- lib/mayu/client/src/constants.ts | 7 + .../src/custom-elements/mayu-alert.html | 137 -- .../client/src/custom-elements/mayu-alert.ts | 62 - .../custom-elements/mayu-disconnected.html | 134 -- .../src/custom-elements/mayu-disconnected.ts | 51 - .../src/custom-elements/mayu-exception.html | 61 +- .../src/custom-elements/mayu-exception.ts | 7 +- .../client/src/custom-elements/mayu-log.html | 70 - .../client/src/custom-elements/mayu-log.ts | 42 - .../client/src/custom-elements/mayu-ping.html | 51 +- .../client/src/custom-elements/mayu-ping.ts | 77 +- .../custom-elements/mayu-progress-bar.html | 44 - .../src/custom-elements/mayu-progress-bar.ts | 40 - lib/mayu/client/src/global.d.ts | 26 - lib/mayu/client/src/h.ts | 2 +- lib/mayu/client/src/logger.ts | 56 - lib/mayu/client/src/main.ts | 398 ++-- lib/mayu/client/src/patches.ts | 41 + lib/mayu/client/src/ping.ts | 17 + lib/mayu/client/src/renderError.ts | 105 + lib/mayu/client/src/runtime.ts | 357 +++ lib/mayu/client/src/serializeEvent.ts | 125 +- lib/mayu/client/src/stream.ts | 275 ++- lib/mayu/client/src/supportsRequestStreams.ts | 17 + lib/mayu/client/src/throttle.ts | 31 + lib/mayu/client/src/transfer.ts | 9 + lib/mayu/client/src/types.ts | 1 - lib/mayu/client/src/utils.ts | 71 - lib/mayu/client/tsconfig.json | 2 +- lib/mayu/colors.rb | 34 - lib/mayu/commands.rb | 60 - lib/mayu/commands/base.rb | 22 - lib/mayu/commands/build.rb | 82 - lib/mayu/commands/init.rb | 122 -- lib/mayu/commands/init/.gitignore | 2 - lib/mayu/commands/init/template/.dockerignore | 4 - .../commands/init/template/.fly/entrypoint.sh | 12 - .../init/template/.fly/healthcheck.sh | 6 - lib/mayu/commands/init/template/.gitignore | 2 - lib/mayu/commands/init/template/Dockerfile | 29 - lib/mayu/commands/init/template/Gemfile | 5 - lib/mayu/commands/init/template/Gemfile.lock | 137 -- lib/mayu/commands/init/template/README.md | 43 - .../app/components/Layout/Footer.haml | 50 - .../app/components/Layout/Header.haml | 21 - .../template/app/components/Layout/header.svg | 93 - .../commands/init/template/app/favicon.png | Bin 2361 -> 0 bytes .../commands/init/template/app/hexagons.svg | 5 - .../commands/init/template/app/pages/Box.haml | 12 - .../init/template/app/pages/layout.haml | 24 - .../init/template/app/pages/mayu-dolphin.svg | 1 - .../init/template/app/pages/page.haml | 84 - .../commands/init/template/app/robots.txt | 2 - lib/mayu/commands/init/template/app/root.css | 42 - lib/mayu/commands/init/template/app/root.haml | 11 - lib/mayu/commands/init/template/bin/mayu | 9 - lib/mayu/commands/init/template/fly.toml | 53 - lib/mayu/commands/init/template/mayu.toml | 57 - lib/mayu/component.rb | 48 - lib/mayu/component/base.rb | 184 +- lib/mayu/component/css_units.rb | 130 ++ lib/mayu/component/handler_ref.rb | 99 - lib/mayu/component/helpers.rb | 93 - lib/mayu/component/interface.rb | 18 - lib/mayu/component/wrapper.rb | 165 -- lib/mayu/configuration.rb | 235 +- lib/mayu/configuration.test.rb | 17 + lib/mayu/custom_element.rb | 3 + lib/mayu/disable_sorbet.rb | 23 - lib/mayu/encrypted_marshal.rb | 148 +- lib/mayu/encrypted_marshal.test.rb | 56 +- lib/mayu/environment.rb | 171 +- lib/mayu/event_stream.rb | 158 -- lib/mayu/fetch.rb | 88 - lib/mayu/html.rb | 53 - lib/mayu/html.yaml | 767 ------- lib/mayu/image.rb | 12 + lib/mayu/metrics.rb | 82 - lib/mayu/metrics/collector.rb | 161 -- lib/mayu/metrics/exporter.rb | 47 - lib/mayu/metrics/reporter.rb | 187 -- lib/mayu/modules/assets.rb | 90 + lib/mayu/modules/import.rb | 23 + lib/mayu/modules/loaders.rb | 37 + .../loaders/__test__/haml/HelloWorld.haml | 22 + .../loaders/__test__/haml/HelloWorld.rb | 66 + .../modules/loaders/__test__/js/MyElement.js | 24 + .../modules/loaders/__test__/js/MyElement.rb | 21 + lib/mayu/modules/loaders/css.rb | 16 + lib/mayu/modules/loaders/haml.rb | 35 + lib/mayu/modules/loaders/haml.test.rb | 32 + lib/mayu/modules/loaders/image.rb | 66 + lib/mayu/modules/loaders/java_script.rb | 93 + lib/mayu/modules/loaders/java_script.test.rb | 36 + lib/mayu/modules/loaders/ruby.rb | 13 + .../transformers/__test__/css/basic.in.css | 4 + .../transformers/__test__/css/basic.out.rb | 14 + .../transformers/__test__/haml/basic.haml | 1 + .../transformers/__test__/haml/basic.rb | 14 + .../transformers/__test__/haml/case.haml | 0 .../transformers/__test__/haml/case.rb | 25 + .../__test__/haml/class_names.haml | 0 .../transformers/__test__/haml/class_names.rb | 49 + .../transformers/__test__/haml/comments.haml | 0 .../transformers/__test__/haml/comments.rb | 19 + .../transformers/__test__/haml/context.haml | 6 + .../transformers/__test__/haml/context.rb | 28 + .../transformers/__test__/haml/css.haml | 0 .../loaders/transformers/__test__/haml/css.rb | 28 + .../transformers/__test__/haml/dashes.haml | 0 .../transformers/__test__/haml/dashes.rb | 29 + .../__test__/haml/early_return.haml | 0 .../__test__/haml/early_return.rb | 29 + .../__test__/haml/early_return2.haml | 0 .../__test__/haml/early_return2.rb | 23 + .../transformers/__test__/haml/handlers.haml | 0 .../transformers/__test__/haml/handlers.rb | 27 + .../transformers/__test__/haml/if_else.haml | 0 .../transformers/__test__/haml/if_else.rb | 21 + .../__test__/haml/interpolation.haml | 0 .../__test__/haml/interpolation.rb | 43 + .../__test__/haml/object_ref_as_key.haml | 0 .../__test__/haml/object_ref_as_key.rb | 14 + .../transformers/__test__/haml/plain.haml | 7 + .../transformers/__test__/haml/plain.rb | 26 + .../transformers/__test__/haml/props.haml | 0 .../transformers/__test__/haml/props.rb | 39 + .../transformers/__test__/haml/slots.haml | 0 .../transformers/__test__/haml/slots.rb | 23 + .../__test__/haml/slots_dynamic.haml | 0 .../__test__/haml/slots_dynamic.rb | 23 + .../__test__/haml/slots_fallback.haml | 0 .../__test__/haml/slots_fallback.rb | 20 + .../transformers/__test__/haml/spacing.haml | 0 .../transformers/__test__/haml/spacing.rb | 24 + .../transformers/__test__/haml/spacing2.haml | 0 .../transformers/__test__/haml/spacing2.rb | 21 + .../transformers/__test__/haml/spacing3.haml | 0 .../transformers/__test__/haml/spacing3.rb | 21 + .../transformers/__test__/haml/state.haml | 26 + .../transformers/__test__/haml/state.rb | 108 + .../__test__/haml/stylesheets.haml | 10 + .../transformers/__test__/haml/stylesheets.rb | 34 + .../haml/whitespace_preservation.haml | 1 + .../__test__/haml/whitespace_preservation.rb | 15 + lib/mayu/modules/loaders/transformers/css.rb | 190 ++ .../modules/loaders/transformers/css.test.rb | 32 + lib/mayu/modules/loaders/transformers/haml.rb | 1180 ++++++++++ .../modules/loaders/transformers/haml.test.rb | 37 + .../loaders/transformers/mutation_visitor.rb | 62 + lib/mayu/modules/loaders/transformers/ruby.rb | 336 +++ .../modules/loaders/transformers/xml_utils.rb | 279 +++ lib/mayu/modules/mod.rb | 125 ++ lib/mayu/modules/registry.rb | 45 + lib/mayu/modules/resolver.rb | 67 + lib/mayu/modules/rules.rb | 14 + lib/mayu/modules/source_map.rb | 127 ++ lib/mayu/modules/source_map.test.rb | 90 + lib/mayu/modules/system.rb | 239 ++ lib/mayu/modules/watcher.rb | 72 + lib/mayu/ref_counter.rb | 57 - lib/mayu/resources/README.md | 14 - lib/mayu/resources/asset.rb | 71 - lib/mayu/resources/assets.rb | 76 - lib/mayu/resources/dependency_graph.rb | 306 --- lib/mayu/resources/dot_exporter.rb | 167 -- lib/mayu/resources/generators/base.rb | 18 - lib/mayu/resources/generators/copy_file.rb | 26 - lib/mayu/resources/generators/image.rb | 46 - lib/mayu/resources/generators/write_file.rb | 39 - lib/mayu/resources/hot_swap.rb | 46 - lib/mayu/resources/hot_swap/file_watcher.rb | 69 - lib/mayu/resources/mermaid_exporter.rb | 210 -- lib/mayu/resources/registry.rb | 190 -- lib/mayu/resources/resolver.rb | 13 - lib/mayu/resources/resolver/base.rb | 32 - lib/mayu/resources/resolver/filesystem.rb | 94 - lib/mayu/resources/resolver/static.rb | 27 - lib/mayu/resources/resource.rb | 150 -- .../__test__/css/adjacent_selectors.in.css | 3 - .../__test__/css/adjacent_selectors.out.css | 6 - .../__test__/css/attributes.in.css | 3 - .../__test__/css/attributes.out.css | 6 - .../transformers/__test__/css/composes.in.css | 6 - .../__test__/css/composes.out.css | 9 - .../__test__/css/element_selectors.in.css | 3 - .../__test__/css/element_selectors.out.css | 6 - .../transformers/__test__/css/has.in.css | 7 - .../transformers/__test__/css/has.out.css | 10 - .../__test__/css/media_queries.in.css | 8 - .../__test__/css/media_queries.out.css | 12 - .../__test__/css/pseudo_classes.in.css | 5 - .../__test__/css/pseudo_classes.out.css | 6 - .../transformers/__test__/haml/README.md | 10 - .../transformers/__test__/haml/case.rb | 15 - .../transformers/__test__/haml/class_names.rb | 26 - .../transformers/__test__/haml/comments.rb | 5 - .../transformers/__test__/haml/css.rb | 11 - .../transformers/__test__/haml/dashes.rb | 11 - .../__test__/haml/early_return.rb | 9 - .../__test__/haml/early_return2.rb | 6 - .../transformers/__test__/haml/handlers.rb | 12 - .../transformers/__test__/haml/if_else.rb | 12 - .../__test__/haml/interpolation.rb | 11 - .../__test__/haml/object_ref_as_key.rb | 5 - .../transformers/__test__/haml/props.rb | 11 - .../transformers/__test__/haml/slots.rb | 9 - .../__test__/haml/slots_dynamic.rb | 9 - .../__test__/haml/slots_fallback.rb | 5 - .../transformers/__test__/haml/spacing.rb | 14 - .../transformers/__test__/haml/spacing2.rb | 11 - .../transformers/__test__/haml/spacing3.rb | 10 - lib/mayu/resources/transformers/css.rb | 145 -- lib/mayu/resources/transformers/css.test.rb | 108 - .../resources/transformers/css/rouge_lexer.rb | 841 ------- lib/mayu/resources/transformers/haml.rb | 985 --------- lib/mayu/resources/transformers/haml.test.rb | 114 - lib/mayu/resources/types.rb | 37 - lib/mayu/resources/types/README.md | 36 - lib/mayu/resources/types/base.rb | 35 - lib/mayu/resources/types/component.rb | 198 -- lib/mayu/resources/types/image.rb | 169 -- lib/mayu/resources/types/javascript.rb | 50 - lib/mayu/resources/types/nil.rb | 23 - lib/mayu/resources/types/stylesheet.rb | 119 - lib/mayu/resources/types/svg.rb | 54 - lib/mayu/routes.rb | 330 +-- lib/mayu/routes.test.rb | 68 + lib/mayu/routing.rb | 17 - lib/mayu/routing/builder.rb | 108 - lib/mayu/routing/matcher.rb | 58 - lib/mayu/routing/routes.rb | 85 - lib/mayu/runtime.rb | 9 + lib/mayu/runtime.test.rb | 152 ++ lib/mayu/runtime/descriptors.rb | 81 + lib/mayu/runtime/dom.rb | 224 ++ lib/mayu/runtime/engine.rb | 73 + lib/mayu/runtime/h.rb | 31 + lib/mayu/runtime/inline_style.rb | 113 + lib/mayu/runtime/patches.rb | 53 + lib/mayu/runtime/vnodes.rb | 1012 +++++++++ lib/mayu/server.rb | 103 +- lib/mayu/server/app.rb | 673 +++--- lib/mayu/server/controller.rb | 152 -- lib/mayu/server/cookies.rb | 38 + lib/mayu/server/errors.rb | 110 - lib/mayu/server/event_stream.rb | 103 + lib/mayu/server/file_server.rb | 140 -- lib/mayu/server/request_refinements.rb | 17 + lib/mayu/server/session_store.rb | 52 + lib/mayu/server/static_files.rb | 100 + lib/mayu/server/static_files.test.rb | 62 + lib/mayu/session.rb | 396 +--- lib/mayu/session/token.rb | 28 + lib/mayu/state.rb | 8 - lib/mayu/state.test.rb | 97 - lib/mayu/state/README.md | 6 - lib/mayu/state/action_creator.rb | 191 -- lib/mayu/state/action_wrapper.rb | 30 - lib/mayu/state/loader.rb | 220 -- lib/mayu/state/store.rb | 82 - lib/mayu/style_sheet.rb | 101 + lib/mayu/test.rb | 336 +++ lib/mayu/utils.rb | 114 - lib/mayu/vdom.rb | 8 - lib/mayu/vdom.test.rb | 73 - lib/mayu/vdom/children.rb | 117 - lib/mayu/vdom/component_marshaler.rb | 53 - lib/mayu/vdom/css_attributes.rb | 131 -- lib/mayu/vdom/descriptor.rb | 151 -- lib/mayu/vdom/descriptor.test.rb | 26 - lib/mayu/vdom/dom.rb | 239 -- lib/mayu/vdom/h.rb | 22 - lib/mayu/vdom/id_generator.rb | 55 - lib/mayu/vdom/interfaces.rb | 186 -- lib/mayu/vdom/marshalling.rb | 78 - lib/mayu/vdom/reconciliation.rb | 205 -- lib/mayu/vdom/reconciliation.test.rb | 56 - lib/mayu/vdom/special_elements.rb | 108 - lib/mayu/vdom/update_context.rb | 180 -- lib/mayu/vdom/vdom.perf.test.rb | 149 -- lib/mayu/vdom/vnode.rb | 266 --- lib/mayu/vdom/vtree.rb | 672 ------ lib/mayu/vdom/vtree.test.rb | 68 - mayu-live.gemspec | 49 +- 309 files changed, 11011 insertions(+), 15577 deletions(-) create mode 100644 lib/mayu/__test__/configuration/test.toml create mode 100644 lib/mayu/__test__/routes/layout.haml create mode 100644 lib/mayu/__test__/routes/not_found.haml create mode 100644 lib/mayu/__test__/routes/page.haml create mode 100644 lib/mayu/__test__/routes/params/:id/page.haml create mode 100644 lib/mayu/__test__/routes/subpage/page.haml create mode 100644 lib/mayu/__test__/routes/subpage2/hello/page.haml create mode 100644 lib/mayu/__test__/routes/subpage2/layout.haml create mode 100644 lib/mayu/__test__/routes/subpage2/not_found.haml create mode 100644 lib/mayu/__test__/routes/subpage2/page.haml delete mode 100644 lib/mayu/app_metrics.rb delete mode 100644 lib/mayu/banner.rb delete mode 100644 lib/mayu/client/.dockerignore delete mode 100644 lib/mayu/client/README.md create mode 100644 lib/mayu/client/package-lock.json delete mode 100644 lib/mayu/client/src/DecompressionStream.ts delete mode 100644 lib/mayu/client/src/DecompressionStreamPolyfill.ts delete mode 100644 lib/mayu/client/src/MimeTypes.ts delete mode 100644 lib/mayu/client/src/NodeTree.ts create mode 100644 lib/mayu/client/src/constants.ts delete mode 100644 lib/mayu/client/src/custom-elements/mayu-alert.html delete mode 100644 lib/mayu/client/src/custom-elements/mayu-alert.ts delete mode 100644 lib/mayu/client/src/custom-elements/mayu-disconnected.html delete mode 100644 lib/mayu/client/src/custom-elements/mayu-disconnected.ts delete mode 100644 lib/mayu/client/src/custom-elements/mayu-log.html delete mode 100644 lib/mayu/client/src/custom-elements/mayu-log.ts delete mode 100644 lib/mayu/client/src/custom-elements/mayu-progress-bar.html delete mode 100644 lib/mayu/client/src/custom-elements/mayu-progress-bar.ts delete mode 100644 lib/mayu/client/src/global.d.ts delete mode 100644 lib/mayu/client/src/logger.ts create mode 100644 lib/mayu/client/src/patches.ts create mode 100644 lib/mayu/client/src/ping.ts create mode 100644 lib/mayu/client/src/renderError.ts create mode 100644 lib/mayu/client/src/runtime.ts create mode 100644 lib/mayu/client/src/supportsRequestStreams.ts create mode 100644 lib/mayu/client/src/throttle.ts create mode 100644 lib/mayu/client/src/transfer.ts delete mode 100644 lib/mayu/client/src/types.ts delete mode 100644 lib/mayu/client/src/utils.ts delete mode 100644 lib/mayu/colors.rb delete mode 100644 lib/mayu/commands.rb delete mode 100644 lib/mayu/commands/base.rb delete mode 100644 lib/mayu/commands/build.rb delete mode 100644 lib/mayu/commands/init.rb delete mode 100644 lib/mayu/commands/init/.gitignore delete mode 100644 lib/mayu/commands/init/template/.dockerignore delete mode 100755 lib/mayu/commands/init/template/.fly/entrypoint.sh delete mode 100755 lib/mayu/commands/init/template/.fly/healthcheck.sh delete mode 100644 lib/mayu/commands/init/template/.gitignore delete mode 100644 lib/mayu/commands/init/template/Dockerfile delete mode 100644 lib/mayu/commands/init/template/Gemfile delete mode 100644 lib/mayu/commands/init/template/Gemfile.lock delete mode 100644 lib/mayu/commands/init/template/README.md delete mode 100644 lib/mayu/commands/init/template/app/components/Layout/Footer.haml delete mode 100644 lib/mayu/commands/init/template/app/components/Layout/Header.haml delete mode 100644 lib/mayu/commands/init/template/app/components/Layout/header.svg delete mode 100644 lib/mayu/commands/init/template/app/favicon.png delete mode 100644 lib/mayu/commands/init/template/app/hexagons.svg delete mode 100644 lib/mayu/commands/init/template/app/pages/Box.haml delete mode 100644 lib/mayu/commands/init/template/app/pages/layout.haml delete mode 100644 lib/mayu/commands/init/template/app/pages/mayu-dolphin.svg delete mode 100644 lib/mayu/commands/init/template/app/pages/page.haml delete mode 100644 lib/mayu/commands/init/template/app/robots.txt delete mode 100644 lib/mayu/commands/init/template/app/root.css delete mode 100644 lib/mayu/commands/init/template/app/root.haml delete mode 100755 lib/mayu/commands/init/template/bin/mayu delete mode 100644 lib/mayu/commands/init/template/fly.toml delete mode 100644 lib/mayu/commands/init/template/mayu.toml create mode 100644 lib/mayu/component/css_units.rb delete mode 100644 lib/mayu/component/handler_ref.rb delete mode 100644 lib/mayu/component/helpers.rb delete mode 100644 lib/mayu/component/interface.rb delete mode 100644 lib/mayu/component/wrapper.rb create mode 100755 lib/mayu/configuration.test.rb create mode 100644 lib/mayu/custom_element.rb delete mode 100644 lib/mayu/disable_sorbet.rb mode change 100644 => 100755 lib/mayu/encrypted_marshal.test.rb delete mode 100644 lib/mayu/event_stream.rb delete mode 100644 lib/mayu/fetch.rb delete mode 100644 lib/mayu/html.rb delete mode 100644 lib/mayu/html.yaml create mode 100644 lib/mayu/image.rb delete mode 100644 lib/mayu/metrics.rb delete mode 100644 lib/mayu/metrics/collector.rb delete mode 100644 lib/mayu/metrics/exporter.rb delete mode 100644 lib/mayu/metrics/reporter.rb create mode 100644 lib/mayu/modules/assets.rb create mode 100644 lib/mayu/modules/import.rb create mode 100644 lib/mayu/modules/loaders.rb create mode 100644 lib/mayu/modules/loaders/__test__/haml/HelloWorld.haml create mode 100644 lib/mayu/modules/loaders/__test__/haml/HelloWorld.rb create mode 100644 lib/mayu/modules/loaders/__test__/js/MyElement.js create mode 100644 lib/mayu/modules/loaders/__test__/js/MyElement.rb create mode 100644 lib/mayu/modules/loaders/css.rb create mode 100644 lib/mayu/modules/loaders/haml.rb create mode 100755 lib/mayu/modules/loaders/haml.test.rb create mode 100644 lib/mayu/modules/loaders/image.rb create mode 100644 lib/mayu/modules/loaders/java_script.rb create mode 100755 lib/mayu/modules/loaders/java_script.test.rb create mode 100644 lib/mayu/modules/loaders/ruby.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/css/basic.in.css create mode 100644 lib/mayu/modules/loaders/transformers/__test__/css/basic.out.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/basic.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/basic.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/case.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/case.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/class_names.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/class_names.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/comments.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/comments.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/context.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/context.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/css.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/css.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/dashes.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/dashes.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/early_return.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/early_return.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/early_return2.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/early_return2.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/handlers.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/handlers.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/if_else.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/if_else.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/interpolation.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/interpolation.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/object_ref_as_key.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/object_ref_as_key.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/plain.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/plain.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/props.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/props.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/slots.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/slots.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/slots_dynamic.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/slots_dynamic.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/slots_fallback.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/slots_fallback.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/spacing.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/spacing.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/spacing2.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/spacing2.rb rename lib/mayu/{resources => modules/loaders}/transformers/__test__/haml/spacing3.haml (100%) create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/spacing3.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/state.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/state.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/stylesheets.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/stylesheets.rb create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.haml create mode 100644 lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.rb create mode 100644 lib/mayu/modules/loaders/transformers/css.rb create mode 100755 lib/mayu/modules/loaders/transformers/css.test.rb create mode 100644 lib/mayu/modules/loaders/transformers/haml.rb create mode 100755 lib/mayu/modules/loaders/transformers/haml.test.rb create mode 100644 lib/mayu/modules/loaders/transformers/mutation_visitor.rb create mode 100644 lib/mayu/modules/loaders/transformers/ruby.rb create mode 100644 lib/mayu/modules/loaders/transformers/xml_utils.rb create mode 100644 lib/mayu/modules/mod.rb create mode 100644 lib/mayu/modules/registry.rb create mode 100644 lib/mayu/modules/resolver.rb create mode 100644 lib/mayu/modules/rules.rb create mode 100644 lib/mayu/modules/source_map.rb create mode 100755 lib/mayu/modules/source_map.test.rb create mode 100644 lib/mayu/modules/system.rb create mode 100644 lib/mayu/modules/watcher.rb delete mode 100644 lib/mayu/ref_counter.rb delete mode 100644 lib/mayu/resources/README.md delete mode 100644 lib/mayu/resources/asset.rb delete mode 100644 lib/mayu/resources/assets.rb delete mode 100644 lib/mayu/resources/dependency_graph.rb delete mode 100644 lib/mayu/resources/dot_exporter.rb delete mode 100644 lib/mayu/resources/generators/base.rb delete mode 100644 lib/mayu/resources/generators/copy_file.rb delete mode 100644 lib/mayu/resources/generators/image.rb delete mode 100644 lib/mayu/resources/generators/write_file.rb delete mode 100644 lib/mayu/resources/hot_swap.rb delete mode 100644 lib/mayu/resources/hot_swap/file_watcher.rb delete mode 100644 lib/mayu/resources/mermaid_exporter.rb delete mode 100644 lib/mayu/resources/registry.rb delete mode 100644 lib/mayu/resources/resolver.rb delete mode 100644 lib/mayu/resources/resolver/base.rb delete mode 100644 lib/mayu/resources/resolver/filesystem.rb delete mode 100644 lib/mayu/resources/resolver/static.rb delete mode 100644 lib/mayu/resources/resource.rb delete mode 100644 lib/mayu/resources/transformers/__test__/css/adjacent_selectors.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/adjacent_selectors.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/attributes.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/attributes.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/composes.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/composes.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/element_selectors.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/element_selectors.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/has.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/has.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/media_queries.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/media_queries.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/pseudo_classes.in.css delete mode 100644 lib/mayu/resources/transformers/__test__/css/pseudo_classes.out.css delete mode 100644 lib/mayu/resources/transformers/__test__/haml/README.md delete mode 100644 lib/mayu/resources/transformers/__test__/haml/case.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/class_names.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/comments.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/css.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/dashes.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/early_return.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/early_return2.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/handlers.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/if_else.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/interpolation.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/object_ref_as_key.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/props.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/slots.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/slots_dynamic.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/slots_fallback.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/spacing.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/spacing2.rb delete mode 100644 lib/mayu/resources/transformers/__test__/haml/spacing3.rb delete mode 100644 lib/mayu/resources/transformers/css.rb delete mode 100644 lib/mayu/resources/transformers/css.test.rb delete mode 100644 lib/mayu/resources/transformers/css/rouge_lexer.rb delete mode 100644 lib/mayu/resources/transformers/haml.rb delete mode 100644 lib/mayu/resources/transformers/haml.test.rb delete mode 100644 lib/mayu/resources/types.rb delete mode 100644 lib/mayu/resources/types/README.md delete mode 100644 lib/mayu/resources/types/base.rb delete mode 100644 lib/mayu/resources/types/component.rb delete mode 100644 lib/mayu/resources/types/image.rb delete mode 100644 lib/mayu/resources/types/javascript.rb delete mode 100644 lib/mayu/resources/types/nil.rb delete mode 100644 lib/mayu/resources/types/stylesheet.rb delete mode 100644 lib/mayu/resources/types/svg.rb create mode 100755 lib/mayu/routes.test.rb delete mode 100644 lib/mayu/routing.rb delete mode 100644 lib/mayu/routing/builder.rb delete mode 100644 lib/mayu/routing/matcher.rb delete mode 100644 lib/mayu/routing/routes.rb create mode 100644 lib/mayu/runtime.rb create mode 100755 lib/mayu/runtime.test.rb create mode 100644 lib/mayu/runtime/descriptors.rb create mode 100644 lib/mayu/runtime/dom.rb create mode 100644 lib/mayu/runtime/engine.rb create mode 100644 lib/mayu/runtime/h.rb create mode 100644 lib/mayu/runtime/inline_style.rb create mode 100644 lib/mayu/runtime/patches.rb create mode 100644 lib/mayu/runtime/vnodes.rb delete mode 100644 lib/mayu/server/controller.rb create mode 100644 lib/mayu/server/cookies.rb delete mode 100644 lib/mayu/server/errors.rb create mode 100644 lib/mayu/server/event_stream.rb delete mode 100644 lib/mayu/server/file_server.rb create mode 100644 lib/mayu/server/request_refinements.rb create mode 100644 lib/mayu/server/session_store.rb create mode 100644 lib/mayu/server/static_files.rb create mode 100755 lib/mayu/server/static_files.test.rb create mode 100644 lib/mayu/session/token.rb delete mode 100644 lib/mayu/state.rb delete mode 100644 lib/mayu/state.test.rb delete mode 100644 lib/mayu/state/README.md delete mode 100644 lib/mayu/state/action_creator.rb delete mode 100644 lib/mayu/state/action_wrapper.rb delete mode 100644 lib/mayu/state/loader.rb delete mode 100644 lib/mayu/state/store.rb create mode 100644 lib/mayu/style_sheet.rb create mode 100644 lib/mayu/test.rb delete mode 100644 lib/mayu/utils.rb delete mode 100644 lib/mayu/vdom.rb delete mode 100644 lib/mayu/vdom.test.rb delete mode 100644 lib/mayu/vdom/children.rb delete mode 100644 lib/mayu/vdom/component_marshaler.rb delete mode 100644 lib/mayu/vdom/css_attributes.rb delete mode 100644 lib/mayu/vdom/descriptor.rb delete mode 100644 lib/mayu/vdom/descriptor.test.rb delete mode 100644 lib/mayu/vdom/dom.rb delete mode 100644 lib/mayu/vdom/h.rb delete mode 100644 lib/mayu/vdom/id_generator.rb delete mode 100644 lib/mayu/vdom/interfaces.rb delete mode 100644 lib/mayu/vdom/marshalling.rb delete mode 100644 lib/mayu/vdom/reconciliation.rb delete mode 100644 lib/mayu/vdom/reconciliation.test.rb delete mode 100644 lib/mayu/vdom/special_elements.rb delete mode 100644 lib/mayu/vdom/update_context.rb delete mode 100644 lib/mayu/vdom/vdom.perf.test.rb delete mode 100644 lib/mayu/vdom/vnode.rb delete mode 100644 lib/mayu/vdom/vtree.rb delete mode 100644 lib/mayu/vdom/vtree.test.rb diff --git a/Gemfile.lock b/Gemfile.lock index cf7b7db7..4b9f8ea3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,34 +2,29 @@ PATH remote: . specs: mayu-live (0.0.6) - async (~> 2.8.0) - async-container (~> 0.16.12) - async-http (~> 0.61.0) + async (~> 2.8) + async-http (~> 0.62.0) + async-io (~> 1.41) 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) + filewatcher (~> 2.1) + image_size (~> 3.4) + localhost (~> 1.1) 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) + mime-types (~> 3.5) + minitest (~> 5.21) + msgpack (~> 1.7) + nokogiri (~> 1.16) 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) + rake (~> 13.1) + rbnacl (~> 7.1) + rouge (~> 4.2) + syntax_tree (~> 6.2) + syntax_tree-haml (~> 4.0) syntax_tree-xml (~> 0.1.0) - terminal-table (~> 3.0.2) - toml-rb (~> 2.2.0) + toml (~> 0.3.0) + tsort (~> 0.2.0) GEM remote: https://rubygems.org/ @@ -41,10 +36,7 @@ GEM fiber-annotation io-event (~> 1.1) timers (~> 4.1) - async-container (0.16.12) - async - async-io - async-http (0.61.0) + async-http (0.62.0) async (>= 1.25) async-io (>= 1.28) async-pool (>= 0.2) @@ -52,7 +44,7 @@ GEM protocol-http1 (~> 0.16.0) protocol-http2 (~> 0.15.0) traces (>= 0.10.0) - async-io (1.38.1) + async-io (1.41.0) async async-pool (0.4.0) async (>= 1.25) @@ -60,7 +52,6 @@ GEM benchmark (0.3.0) brotli (0.4.0) builder (3.2.4) - citrus (3.0.2) coderay (1.1.3) console (1.23.3) fiber-annotation @@ -68,6 +59,8 @@ GEM ffi (1.16.3) fiber-annotation (0.2.0) fiber-local (1.0.0) + filewatcher (2.1.0) + module_methods (~> 0.1.0) formatador (1.1.0) fuzzy_match (2.1.0) guard (2.18.1) @@ -83,12 +76,9 @@ GEM temple (>= 0.8.2) thor tilt - image_size (3.2.0) + image_size (3.4.0) io-console (0.7.1) 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) @@ -98,17 +88,17 @@ GEM 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 (3.5.2) mime-types-data (~> 3.2015) mime-types-data (3.2023.1205) - minitest (5.20.0) + minitest (5.22.3) minitest-reporters (1.6.1) ansi builder minitest (>= 5.0) ruby-progressbar - msgpack (1.6.1) - nanoid (2.0.0) + module_methods (0.1.0) + msgpack (1.7.2) nenv (0.3.0) netrc (0.11.0) nokogiri (1.16.2-arm64-darwin) @@ -121,14 +111,13 @@ GEM nenv (~> 0.1) shellany (~> 0.0) parallel (1.24.0) - pkg-config (1.5.6) + parslet (2.0.0) 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) @@ -141,7 +130,7 @@ GEM method_source (~> 1.0) racc (1.7.3) rack (3.0.9.1) - rake (13.0.6) + rake (13.1.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -155,9 +144,7 @@ GEM reline (0.4.2) io-console (~> 0.5) rexml (3.2.6) - rmagick (5.3.0) - pkg-config (~> 1.4) - rouge (4.0.1) + rouge (4.2.1) ruby-prof (1.7.0) ruby-progressbar (1.13.0) shellany (0.0.1) @@ -169,18 +156,16 @@ GEM 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) + syntax_tree (6.2.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 @@ -198,15 +183,13 @@ GEM 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) tilt (2.3.0) timers (4.3.5) - toml-rb (2.2.0) - citrus (~> 3.0, > 3.0) + toml (0.3.0) + parslet (>= 1.8.0, < 3.0.0) traces (0.11.1) - unicode-display_width (2.5.0) + tsort (0.2.0) yard (0.9.36) yard-sorbet (0.8.1) sorbet-runtime (>= 0.5) diff --git a/lib/mayu.rb b/lib/mayu.rb index d1b7a5c1..9d5651b9 100644 --- a/lib/mayu.rb +++ b/lib/mayu.rb @@ -1,8 +1,4 @@ -# typed: strict - -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..e4bdf798 --- /dev/null +++ b/lib/mayu/__test__/configuration/test.toml @@ -0,0 +1,33 @@ +[dev] + secret_key = "dev" + + [dev.server] + listen = "https://localhost:9292" + + hmr = true + + render_exceptions = true + self_signed_cert = true + + generate_assets = true + + [dev.metrics] + enabled = true + listen = "http://localhost:9293" + +[prod] + secret_key = "$SECRET_KEY" + + [prod.server] + listen = "http://localhost:3000" + + hmr = false + + render_exceptions = false + self_signed_cert = false + + generate_assets = false + + [prod.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/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-lock.json b/lib/mayu/client/package-lock.json new file mode 100644 index 00000000..0570c508 --- /dev/null +++ b/lib/mayu/client/package-lock.json @@ -0,0 +1,1940 @@ +{ + "name": "@mayu-live/client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mayu-live/client", + "version": "0.0.0", + "license": "AGPL-3.0", + "dependencies": { + "@rollup/plugin-terser": "^0.4.4" + }, + "devDependencies": { + "@msgpack/msgpack": "^2.8.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.5", + "@types/serviceworker": "^0.0.76", + "esbuild": "^0.19.5", + "fflate": "^0.8.1", + "html-minifier-terser": "^7.2.0", + "msgpackr": "^1.9.9", + "rollup": "^4.3.1", + "rollup-plugin-delete": "^2.0.0", + "tslib": "^2.6.2", + "typescript": "^5.2.2" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.5.tgz", + "integrity": "sha512-bhvbzWFF3CwMs5tbjf3ObfGqbl/17ict2/uwOSfr3wmxDE6VdS2GqY/FuzIPe0q0bdhj65zQsvqfArI9MY6+AA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.5.tgz", + "integrity": "sha512-5d1OkoJxnYQfmC+Zd8NBFjkhyCNYwM4n9ODrycTFY6Jk1IGiZ+tjVJDDSwDt77nK+tfpGP4T50iMtVi4dEGzhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.5.tgz", + "integrity": "sha512-9t+28jHGL7uBdkBjL90QFxe7DVA+KGqWlHCF8ChTKyaKO//VLuoBricQCgwhOjA1/qOczsw843Fy4cbs4H3DVA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.5.tgz", + "integrity": "sha512-mvXGcKqqIqyKoxq26qEDPHJuBYUA5KizJncKOAf9eJQez+L9O+KfvNFu6nl7SCZ/gFb2QPaRqqmG0doSWlgkqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.5.tgz", + "integrity": "sha512-Ly8cn6fGLNet19s0X4unjcniX24I0RqjPv+kurpXabZYSXGM4Pwpmf85WHJN3lAgB8GSth7s5A0r856S+4DyiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.5.tgz", + "integrity": "sha512-GGDNnPWTmWE+DMchq1W8Sd0mUkL+APvJg3b11klSGUDvRXh70JqLAO56tubmq1s2cgpVCSKYywEiKBfju8JztQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.5.tgz", + "integrity": "sha512-1CCwDHnSSoA0HNwdfoNY0jLfJpd7ygaLAp5EHFos3VWJCRX9DMwWODf96s9TSse39Br7oOTLryRVmBoFwXbuuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.5.tgz", + "integrity": "sha512-lrWXLY/vJBzCPC51QN0HM71uWgIEpGSjSZZADQhq7DKhPcI6NH1IdzjfHkDQws2oNpJKpR13kv7/pFHBbDQDwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.5.tgz", + "integrity": "sha512-o3vYippBmSrjjQUCEEiTZ2l+4yC0pVJD/Dl57WfPwwlvFkrxoSO7rmBZFii6kQB3Wrn/6GwJUPLU5t52eq2meA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.5.tgz", + "integrity": "sha512-MkjHXS03AXAkNp1KKkhSKPOCYztRtK+KXDNkBa6P78F8Bw0ynknCSClO/ztGszILZtyO/lVKpa7MolbBZ6oJtQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.5.tgz", + "integrity": "sha512-42GwZMm5oYOD/JHqHska3Jg0r+XFb/fdZRX+WjADm3nLWLcIsN27YKtqxzQmGNJgu0AyXg4HtcSK9HuOk3v1Dw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.5.tgz", + "integrity": "sha512-kcjndCSMitUuPJobWCnwQ9lLjiLZUR3QLQmlgaBfMX23UEa7ZOrtufnRds+6WZtIS9HdTXqND4yH8NLoVVIkcg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.5.tgz", + "integrity": "sha512-yJAxJfHVm0ZbsiljbtFFP1BQKLc8kUF6+17tjQ78QjqjAQDnhULWiTA6u0FCDmYT1oOKS9PzZ2z0aBI+Mcyj7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.5.tgz", + "integrity": "sha512-5u8cIR/t3gaD6ad3wNt1MNRstAZO+aNyBxu2We8X31bA8XUNyamTVQwLDA1SLoPCUehNCymhBhK3Qim1433Zag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.5.tgz", + "integrity": "sha512-Z6JrMyEw/EmZBD/OFEFpb+gao9xJ59ATsoTNlj39jVBbXqoZm4Xntu6wVmGPB/OATi1uk/DB+yeDPv2E8PqZGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.5.tgz", + "integrity": "sha512-psagl+2RlK1z8zWZOmVdImisMtrUxvwereIdyJTmtmHahJTKb64pAcqoPlx6CewPdvGvUKe2Jw+0Z/0qhSbG1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.5.tgz", + "integrity": "sha512-kL2l+xScnAy/E/3119OggX8SrWyBEcqAh8aOY1gr4gPvw76la2GlD4Ymf832UCVbmuWeTf2adkZDK+h0Z/fB4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.5.tgz", + "integrity": "sha512-sPOfhtzFufQfTBgRnE1DIJjzsXukKSvZxloZbkJDG383q0awVAq600pc1nfqBcl0ice/WN9p4qLc39WhBShRTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.5.tgz", + "integrity": "sha512-dGZkBXaafuKLpDSjKcB0ax0FL36YXCvJNnztjKV+6CO82tTYVDSH2lifitJ29jxRMoUhgkg9a+VA/B03WK5lcg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.5.tgz", + "integrity": "sha512-dWVjD9y03ilhdRQ6Xig1NWNgfLtf2o/STKTS+eZuF90fI2BhbwD6WlaiCGKptlqXlURVB5AUOxUj09LuwKGDTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.5.tgz", + "integrity": "sha512-4liggWIA4oDgUxqpZwrDhmEfAH4d0iljanDOK7AnVU89T6CzHon/ony8C5LeOdfgx60x5cnQJFZwEydVlYx4iw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.5.tgz", + "integrity": "sha512-czTrygUsB/jlM8qEW5MD8bgYU2Xg14lo6kBDXW6HdxKjh8M5PzETGiSHaz9MtbXBYDloHNUAUW2tMiKW4KM9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz", + "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz", + "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz", + "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz", + "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz", + "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz", + "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "25.0.7", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", + "integrity": "sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.2.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", + "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.5.tgz", + "integrity": "sha512-rnMHrGBB0IUEv69Q8/JGRD/n4/n6b3nfpufUu26axhUcboUzv/twfZU8fIBbTOphRAe0v8EyxzeDpKXqGHfyDA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.5.tgz", + "integrity": "sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.3.1.tgz", + "integrity": "sha512-D+opNc1CnFmN6EcpG2BXUo9dI/vgoqo6xijv/nUPE1t7Y0Iz9IaXkSjaqw5MJq7B1DUawXfEaIdVCod27IsAOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.3.1.tgz", + "integrity": "sha512-3UbtU+7ocBMxYoMCDymHnFYB8tALVaEOjTe5pzAB65AJwXfDFAxADYGCJnBzDXD9u/G+7ktoYnMGYhitYphFkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-F19xNgrLNnLTS/LFnTdlmxYvkIjFttDSQmJ6/oXLRZpGX+LAoYZpFcz2sYk5l/umk3M34Dfgnvt1fcMfTuIjzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.3.1.tgz", + "integrity": "sha512-+63fn9QVEHsDz+ZafHN1R7tAjqfVG4LaFEPeHVcM0YWSNc6vq7UOdi7IUTdQ++RZHev5rYm8GTGwJccULX1XnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-eG/9q+W0KPLu4xG3EwqDsG+wz9VoPMW0IDZ4bXdq2yyi2qA/CcmHb5956ZOw9PPAmL2krHvDaPyQIzFkZP0BLA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-zjnPmrnXz59M6SaVwJSD0bWQ3ljFxpDMDVDi94Xn60/XX/qokZco9/psvu4hSvV+3A4OKwt4XwAULygXwN8y5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-/QqGJI0Jk/Ln32EmpkJYmwpKIe+Da40zmJL8YYvJKYQWhvj7qYOJM6HntQndTWNpF5/33vpLVhngCaHqmiVhNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Q1nbux0VbjeSSYns31wa4r8pssxg/bmYD7kH9ArSfSLxN0OaJaDTaBfHuGC/Ou7dWbg83ca0YQTYHQ6rzZVvgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-5i71ndo6vZ/EaYpWV8h0TypEc5lCmPru6hST35XiTzV9XUtvbLDfbD2T3nSU5MeQMZVgQHCHXelsH3KCGTA8WA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aYKKmlrLL7C0oY43B2Q4uMIlfF1BsSlSYf3R7q7SGB/SrK7Tkj2DHuxqBSYuFqSxuYuAP4PaHt230McvMpZg5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.3.1.tgz", + "integrity": "sha512-/B5g1WqoXecmHyVsXsSGWfGE+QqiSIMk2I4+EOGcziXfZsUHoUbwXwaiAy5Sir/xUwdi9nEZDqj4jxwMchZPkQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-2cRSO5SflYT21SKh1G+2zchLUotL2g7/jhYxbeFpJ8gfVU6CMd2YiIfN++Rs8kzTsuwaTqrE8CAK8GORqoVOeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", + "integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/serviceworker": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@types/serviceworker/-/serviceworker-0.0.76.tgz", + "integrity": "sha512-IAG9uWxdAkWwWBdptN7+e/aQRb3zWBuaFQhxGmDW3GyDruFuG4VRtW14Hntp1AfMM1MMD/bX+1gLo6QAsyjgzQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/clean-css": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", + "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", + "integrity": "sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==", + "dev": true, + "dependencies": { + "globby": "^10.0.1", + "graceful-fs": "^4.2.2", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.1", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.5.tgz", + "integrity": "sha512-bUxalY7b1g8vNhQKdB24QDmHeY4V4tw/s6Ak5z+jJX9laP5MoQseTOMemAr0gxssjNcH0MCViG8ONI2kksvfFQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.19.5", + "@esbuild/android-arm64": "0.19.5", + "@esbuild/android-x64": "0.19.5", + "@esbuild/darwin-arm64": "0.19.5", + "@esbuild/darwin-x64": "0.19.5", + "@esbuild/freebsd-arm64": "0.19.5", + "@esbuild/freebsd-x64": "0.19.5", + "@esbuild/linux-arm": "0.19.5", + "@esbuild/linux-arm64": "0.19.5", + "@esbuild/linux-ia32": "0.19.5", + "@esbuild/linux-loong64": "0.19.5", + "@esbuild/linux-mips64el": "0.19.5", + "@esbuild/linux-ppc64": "0.19.5", + "@esbuild/linux-riscv64": "0.19.5", + "@esbuild/linux-s390x": "0.19.5", + "@esbuild/linux-x64": "0.19.5", + "@esbuild/netbsd-x64": "0.19.5", + "@esbuild/openbsd-x64": "0.19.5", + "@esbuild/sunos-x64": "0.19.5", + "@esbuild/win32-arm64": "0.19.5", + "@esbuild/win32-ia32": "0.19.5", + "@esbuild/win32-x64": "0.19.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz", + "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==", + "dev": true + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", + "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", + "dev": true, + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/globby/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/globby/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globby/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/msgpackr": { + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.9.tgz", + "integrity": "sha512-sbn6mioS2w0lq1O6PpGtsv6Gy8roWM+o3o4Sqjd6DudrL/nOugY+KyJUimoWzHnf9OkO0T6broHFnYE/R05t9A==", + "dev": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz", + "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.0.7" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz", + "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==", + "dev": true, + "optional": true, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rollup": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.3.1.tgz", + "integrity": "sha512-gkvK/OnwbyacmUVjxNzuMMqSihBVQSdX9OtZkThN946cpMHA7izVzc03tHg3NVAeWXUNPzkrP7RW/rV68a42BA==", + "devOptional": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.3.1", + "@rollup/rollup-android-arm64": "4.3.1", + "@rollup/rollup-darwin-arm64": "4.3.1", + "@rollup/rollup-darwin-x64": "4.3.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.3.1", + "@rollup/rollup-linux-arm64-gnu": "4.3.1", + "@rollup/rollup-linux-arm64-musl": "4.3.1", + "@rollup/rollup-linux-x64-gnu": "4.3.1", + "@rollup/rollup-linux-x64-musl": "4.3.1", + "@rollup/rollup-win32-arm64-msvc": "4.3.1", + "@rollup/rollup-win32-ia32-msvc": "4.3.1", + "@rollup/rollup-win32-x64-msvc": "4.3.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-delete": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-delete/-/rollup-plugin-delete-2.0.0.tgz", + "integrity": "sha512-/VpLMtDy+8wwRlDANuYmDa9ss/knGsAgrDhM+tEwB1npHwNu4DYNmDfUL55csse/GHs9Q+SMT/rw9uiaZ3pnzA==", + "dev": true, + "dependencies": { + "del": "^5.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/smob": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.1.tgz", + "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terser": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", + "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + } + } +} diff --git a/lib/mayu/client/package.json b/lib/mayu/client/package.json index 4c86560d..0d6cfdec 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": { @@ -20,20 +21,21 @@ }, "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", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/serviceworker": "^0.0.82", + "esbuild": "^0.20.0", + "fflate": "^0.8.2", "html-minifier-terser": "^7.2.0", - "rollup": "^3.24.0", + "morphdom": "^2.7.2", + "msgpackr": "^1.10.1", + "rollup": "^4.9.6", "rollup-plugin-delete": "^2.0.0", - "rollup-plugin-visualizer": "^5.9.0", - "tslib": "^2.5.3", - "typescript": "^5.1.3" + "tslib": "^2.6.2", + "typescript": "^5.3.3" }, "dependencies": { - "@rollup/plugin-terser": "^0.4.3" + "@rollup/plugin-terser": "^0.4.4" } } 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(``) - .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/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 @@ - - -

Alert

-

-
- -
-
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 @@ - - -

Connection lost

-

-

Please check your internet connection.

-

- Reload the page -

-
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..7029669a 100644 --- a/lib/mayu/client/src/custom-elements/mayu-exception.html +++ b/lib/mayu/client/src/custom-elements/mayu-exception.html @@ -1,5 +1,6 @@
@@ -74,6 +110,23 @@

-
+
+

Backtrace:

+
    + +
+
+
+

Tree path:

+
    + +
+
+
+

Source

+
    + +
+
diff --git a/lib/mayu/client/src/custom-elements/mayu-exception.ts b/lib/mayu/client/src/custom-elements/mayu-exception.ts index 3d8e5b01..f6e4a7cc 100644 --- a/lib/mayu/client/src/custom-elements/mayu-exception.ts +++ b/lib/mayu/client/src/custom-elements/mayu-exception.ts @@ -3,7 +3,7 @@ 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; connectedCallback() { @@ -13,8 +13,9 @@ 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.dialog!.addEventListener("close", this.remove); this.dialog!.showModal(); } @@ -24,5 +25,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 -
- -
-
- - - - - - - - - -
IdEventPayload
-
-
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..f36e0864 100644 --- a/lib/mayu/client/src/custom-elements/mayu-ping.html +++ b/lib/mayu/client/src/custom-elements/mayu-ping.html @@ -2,14 +2,15 @@ .mayu-ping { position: fixed; bottom: 0; - left: 0; + right: 0; z-index: 10000; background: #0003; + backdrop-filter: blur(5px); border: 0 solid #0003; - border-width: 1px 1px 0 0; - font-size: 0.9em; - padding: 0.2em 0.5em; - border-top-right-radius: 3px; + border-width: 1px 0 0 1px; + font-size: 0.8rem; + padding: .5em; + border-top-left-radius: 3px; text-shadow: 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000, 0 0 2px #000; @@ -17,6 +18,8 @@ font-weight: bold; font-family: monospace; transition: background 500ms 0s ease-in-out; + text-align: center; + min-width: 3em; } .status-disconnected { background: #d0060699; @@ -27,9 +30,39 @@ .status-transferring { background: #0866f799; } + + meter { + width: 100%; + height: 2rem; + display: none; + } + + meter::-webkit-meter-inner-element { + grid-template-rows: 0 [line1] 1fr [line2] 0 + } + + meter::-webkit-meter-bar { + border-radius: 0; + border: none; + } + + meter::-webkit-meter-optimum-value { + background: #0c0; + } + meter::-webkit-meter-suboptimum-value { + background: #fc0; + } + meter::-webkit-meter-even-less-good-value { + background: #c00; + } + + meter::-webkit-meter-optimum-value, + meter::-webkit-meter-suboptimum-value, + meter::-webkit-meter-even-less-good-value { + transition: 500ms width, 500ms background-color; + } -
- Ping: - N/A - (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..be4addec 100644 --- a/lib/mayu/client/src/custom-elements/mayu-ping.ts +++ b/lib/mayu/client/src/custom-elements/mayu-ping.ts @@ -3,87 +3,50 @@ 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; + #meter?: HTMLMeterElement; - static observedAttributes = ["ping", "region", "status"]; + static observedAttributes = ["ping", "status"] connectedCallback() { if (!this.shadowRoot) { this.attachShadow({ mode: "open" }); } - this.shadowRoot!.appendChild( + this.shadowRoot!.replaceChildren( template.content.cloneNode(true) - ) as DocumentFragment; + ) 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.#meter = this.shadowRoot!.querySelector("meter") as HTMLMeterElement; + + 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; + // this.#meter!.setAttribute("value", String(Number(newValue.replace(/ms$/, '')) * 10)); 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}`); } break; } 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..29c25b9d 100644 --- a/lib/mayu/client/src/main.ts +++ b/lib/mayu/client/src/main.ts @@ -1,283 +1,203 @@ -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 serializeEvent from "./serializeEvent"; +import { + initInputStream, + initCallbackStream, + JSONEncoderStream, +} from "./stream.js"; -import logger from "./logger"; +import serializeEvent from "./serializeEvent.js"; +import { decodeMultiStream, ExtensionCodec } from "@msgpack/msgpack"; -const onDOMContentLoaded = new Promise((resolve) => { - if (document.readyState !== "loading") { - return resolve(); - } +import { SESSION_MIME_TYPE, SESSION_PATH, PING_INTERVAL } from "./constants"; +import { updateConnectionStatus } from "./ping"; +import { getTransferState, setTransferState } from "./transfer"; +import throttle from './throttle' - window.addEventListener("DOMContentLoaded", () => resolve()); -}); +import "./custom-elements/mayu-exception"; -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); + interface Document { + startViewTransition?: (callback: () => void) => void + } } -class MayuGlobal { - #sessionId: string; +class Mayu { + #writer: WritableStreamDefaultWriter | null; + #pingTimer: NodeJS.Timeout; - constructor(sessionId: string) { - this.#sessionId = sessionId; + constructor() { + this.#writer = null; - onDOMContentLoaded.then(() => { - window.addEventListener("popstate", () => { - return navigateTo(this.#sessionId, location.pathname); - }); + window.addEventListener("popstate", () => { + this.navigate(location.pathname + location.search, false); }); + + this.#pingTimer = setTimeout(() => this.ping(), 100); } - async handle(e: Event, handlerId: string) { - if (shouldPreventDefault(e)) { - e.preventDefault(); + setWriter(writer: WritableStreamDefaultWriter) { + this.#writer = writer; + } + + async #write(message: any) { + try { + await this.#writer?.write(message) + } catch (e) { + console.error("Write error") } + } - const payload = serializeEvent(e); - console.log(payload); - // progressBar.setAttribute("progress", "0"); + callback(event: Event, id: string) { + const serializedEvent = serializeEvent(event) + throttle(event.currentTarget!, () => { + this.#write({ + type: "callback", + payload: { id, event: serializedEvent }, + ping: performance.now(), + }); + }) + } - await mayuCallback(this.#sessionId, handlerId, payload); + navigate(href: string, pushState: boolean = true) { + console.warn("navigate", href); + this.#write({ + type: "navigate", + payload: { href, pushState }, + ping: performance.now(), + }); + } - let didRun = false; - const timeout = setTimeout(() => { - // progressBar.setAttribute("progress", "25"); - didRun = true; - }, 1); + ping() { + clearTimeout(this.#pingTimer); - clearTimeout(timeout); + this.#pingTimer = setTimeout(() => this.ping(), PING_INTERVAL); - // progressBar.setAttribute("progress", "100"); + this.#write({ + type: "ping", + ping: performance.now(), + }); } +} - async navigate(e: MouseEvent) { - if (e.metaKey || e.ctrlKey) return; +async function sleep(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} - e.preventDefault(); - const anchor = (e.target as HTMLElement).closest("a"); +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") + + console.warn(`%cmorphing dom`, "font-size: 4em; font-weight: bold; font-family: monospace;") + + if (document.startViewTransition) { + document.startViewTransition(async () => { + morphdom.default(document.documentElement, html) + }) + } else { + morphdom.default(document.documentElement, html) + } - if (!anchor) { - logger.error("Could not find anchor element for", e.target); - return; - } + setTransferState(null); - const url = new URL((anchor as HTMLAnchorElement).href); - // progressBar.setAttribute("progress", "0"); - return navigateTo(this.#sessionId, url.pathname + url.search); - } + return `${SESSION_PATH}/${sessionId}`; } -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 startPatchStream(runtime: Runtime, endpoint: string) { + const extensionCodec = createExtensionCodec(); + let failures = 0; -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); -} + while (true) { + try { + const state = getTransferState(); -function logRequestTime(res: Response) { - const requestTime = res.headers.get("x-request-time"); + updateConnectionStatus(state ? "transferring" : "disconnected"); - if (requestTime) { - console.log("Pong:", performance.now() - Number(requestTime), "ms"); - } + const input = await initInputStream(endpoint, state); + setTransferState(null); - return res; -} + const callbackStream = new TransformStream(); + window.Mayu.setWriter(callbackStream.writable.getWriter()) + const output = initCallbackStream(endpoint); -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); -} + failures = 0; -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"); -} + callbackStream.readable + .pipeThrough(new JSONEncoderStream()) + .pipeThrough(new TextEncoderStream()) + .pipeTo(output); -async function main(url: string) { - const sessionId = getSessionIdFromUrl(url); - const mayu = new MayuGlobal(sessionId); - 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; - } - } + updateConnectionStatus("connected"); - function handleAction(type: string, payload: any) { - switch (type) { - case "scroll_into_view": { - scrollIntoView(payload.selector, payload.options || {}); - break; - } - case "alert": { - showAlert(payload); - break; + for await (const patch of decodeMultiStream(input, { extensionCodec })) { + updateConnectionStatus("connected"); + runtime.apply(patch as any); } - default: { - logger.error("Unhandled action:", type, payload); - break; + } catch (e: any) { + failures += 1; + + if (e.message === 'expired' || e.message === "cipher error") { + console.warn("Resetting session because of:", e.message) + endpoint = await resetSessionEntirely() + } else { + await sleep(Math.min(10_000, 1000 * failures + 1)); } } } +} - function scrollIntoView(selector: string, options: Record) { - const elem = document.querySelector(selector); +function createExtensionCodec() { + const extensionCodec = new ExtensionCodec(); - if (elem) { - elem.scrollIntoView({ - block: "start", - inline: "nearest", - behavior: "smooth", - ...options, - }); - } else { - console.error( - "Could not find element to scrollIntoView, selector:", - selector - ); - } + extensionCodec.register({ + type: 0x01, + encode() { + throw new Error("Not implemented"); + }, + decode(buffer) { + return new Blob([buffer], { type: SESSION_MIME_TYPE }); + }, + }); + + return extensionCodec; +} + +function main() { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(` + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 1s; } + `); + document.adoptedStyleSheets.push(sheet) + + const runtime = new Runtime(); + + window.Mayu = new Mayu(); + + const sessionId = import.meta.url.split("#").at(-1); + const endpoint = `${SESSION_PATH}/${sessionId}`; + startPatchStream(runtime, endpoint) } -export default main(import.meta.url); +if (window.Mayu) { + console.error("%cwindow.Mayu is already defined", "font-size: 1.5em; color: #c00;") +} else { + main() +} 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..87b2fcbb --- /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"); + +function getPingElement() { + return document.querySelector("mayu-ping"); +} + +export function updatePing(value: number) { + getPingElement()?.setAttribute("ping", `${value.toFixed(2)}ms`); +} + +export function updateConnectionStatus( + status: "disconnected" | "connected" | "transferring", +) { + 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..a7691a5e --- /dev/null +++ b/lib/mayu/client/src/renderError.ts @@ -0,0 +1,105 @@ +import h from "./h"; + +export default function renderError( + file: string, + type: string, + message: string, + backtrace: string[], + source: string, + treePath: { name: string; path?: string }[], +) { + const formats = []; + const buf = []; + + 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: deeppink;", "color: deepskyblue;", "color: gray;"); + } else { + buf.push(`%c${indent}%%%c${path.name}`); + formats.push("color: deeppink;", "color: deepskyblue;"); + } + }); + + 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); + + const existing = Array.from(document.getElementsByTagName("mayu-exception")); + existing.forEach((e) => { + e.remove(); + }); + + const element = + document.getElementsByTagName("mayu-exception")[0] || + document.createElement("mayu-exception"); + + const interestingLines = new Set(); + + backtrace.forEach((line) => { + if (line.startsWith(`${file}:`)) { + interestingLines.add(Number(line.split(":")[1])); + } + }); + + console.log("INTERESTING LINES", interestingLines); + + element.replaceChildren( + h("span", [`${type}: ${message}`], { slot: "title" }), + ...treePath.map((path, i) => + h( + "li", + [ + h("span", [" ".repeat(i)]), + h("span", ["%"], { style: "color: deeppink;" }), + h("span", [path.name], { style: "color: deepskyblue;" }), + " ", + path.path && + h("span", [`(${path.path})`], { style: "opacity: 50%;" }), + ], + { slot: "tree-path" }, + ), + ), + ...backtrace.map((line) => + h( + "li", + [ + line.startsWith(`${file}:`) + ? h("strong", [line], { style: "color: red;" }) + : line, + ], + { + slot: "backtrace", + }, + ), + ), + ...source.split("\n").map((line, i) => + h( + "li", + [ + interestingLines.has(i + 1) + ? h("strong", [line], { style: "color: red;" }) + : line, + ], + { + slot: "source", + }, + ), + ), + ); + console.log("FILE", file); + + 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..0775c4c5 --- /dev/null +++ b/lib/mayu/client/src/runtime.ts @@ -0,0 +1,357 @@ +// Copyright Andreas Alin +// License: AGPL-3.0 + +// const startViewTransition = +// document.startViewTransition?.bind(document) || ((cb: () => void) => cb()) + +import { updatePing } from "./ping"; +import { setTransferState } from "./transfer"; +import renderError from "./renderError"; + +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(); + + apply(patches: PatchSet) { + for (const patch of patches) { + const [name, ...args] = patch; + console.debug(name, args); + const patchFn = Patches[name as PatchType] as any; + if (!patchFn) { + throw new Error(`Not implemented: ${name}`); + } + try { + patchFn.apply(this.#nodeSet, args as any); + } catch (e) { + console.error(e) + } + } + } +} + +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; + } + + 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`); + } +} + +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"); +} + +function configureLink(a: HTMLAnchorElement) { + a.addEventListener("click", (e) => { + if (a.host !== location.host) { + return; + } + + e.preventDefault(); + window.Mayu.navigate(a.pathname + a.search); + }); +} + +function setupTree(nodeSet: NodeSet, domNode: Node, idNode: IdNode) { + if (!domNode) return; + + // console.log("Visiting", domNode, domNode.nodeName, idNode.name, JSON.stringify(domNode.textContent)); + + if (domNode.nodeName !== idNode.name) { + 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 = { + 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) { + const template = document + .createRange() + .createContextualFragment( + ``, + ).firstElementChild!; + const content = (template as HTMLTemplateElement).content; + + setupTree(this, content.firstChild!, tree); + }, + CreateElement(this: NodeSet, id: string, type: string) { + this.setNode(id, document.createElement(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).className = value; + }, + SetAttribute(this: NodeSet, id: string, name: string, value: string) { + this.getElement(id).setAttribute(name, value); + }, + RemoveAttribute(this: NodeSet, id: string, name: string) { + this.getElement(id).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)); + }, + 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); + }, +} as const; diff --git a/lib/mayu/client/src/serializeEvent.ts b/lib/mayu/client/src/serializeEvent.ts index 300e0115..24f81c77 100644 --- a/lib/mayu/client/src/serializeEvent.ts +++ b/lib/mayu/client/src/serializeEvent.ts @@ -1,90 +1,91 @@ -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; + + if (e.currentTarget) { + payload.currentTarget = serializeElement(e.currentTarget as Element); + } + + if (e.target) { + payload.target = serializeElement(e.target as Element); + } + + if (e instanceof MouseEvent) { + payload.buttons = e.buttons; + } + + if (e instanceof SubmitEvent) { + if (e.submitter instanceof HTMLElement) { + payload.submitter = serializeElement(e.submitter); + } + } + + return payload; +} + +function serializeElement(elem: Element) { + if (elem instanceof HTMLFormElement) { + const formData = Object.fromEntries(new FormData(elem).entries()); return { - tagName: obj.tagName, - id: obj.id, - method: obj.method, - target: obj.target, - name: obj.name, + tagName: elem.tagName, + id: elem.id, + method: elem.method, + target: elem.target, + name: elem.name, formData, }; } - if (obj instanceof HTMLSelectElement) { + if (elem instanceof HTMLSelectElement) { return { - tagName: obj.tagName, - id: obj.id, - type: obj.type, - name: obj.name, - value: obj.value, + tagName: elem.tagName, + id: elem.id, + type: elem.type, + name: elem.name, + value: elem.value, }; } - if (obj instanceof HTMLDetailsElement) { + if (elem instanceof HTMLDetailsElement) { return { - tagName: obj.tagName, - id: obj.id, - open: obj.open, + tagName: elem.tagName, + id: elem.id, + open: elem.open, }; } - if (obj instanceof HTMLInputElement) { + if (elem instanceof HTMLInputElement) { return { - tagName: obj.tagName, - id: obj.id, - type: obj.type, - name: obj.name, - value: obj.value, - checked: obj.checked, + tagName: elem.tagName, + id: elem.id, + type: elem.type, + name: elem.name, + value: elem.value, + checked: elem.checked, }; } - if (obj instanceof HTMLButtonElement) { + if (elem instanceof HTMLButtonElement) { return { - tagName: obj.tagName, - id: obj.id, - type: obj.type, - name: obj.name, - value: obj.value, + tagName: elem.tagName, + id: elem.id, + type: elem.type, + name: elem.name, + value: elem.value, }; } - if (obj instanceof HTMLElement) { + if (elem instanceof HTMLElement) { return { - tagName: obj.tagName, - id: obj.id, + tagName: elem.tagName, + id: elem.id, }; } return {}; } - -function serializeEvent(e: Event) { - const payload: Record = {}; - - payload.type = e.constructor.name; - - if (e.currentTarget) { - payload.currentTarget = serializeElement(e.currentTarget); - } - - if (e.target) { - payload.target = serializeElement(e.target); - } - - if (e instanceof MouseEvent) { - payload.buttons = e.buttons; - } - - if (e instanceof SubmitEvent) { - if (e.submitter instanceof HTMLElement) { - payload.submitter = serializeElement(e.submitter); - } - } - - return payload; -} - -export default serializeEvent; diff --git a/lib/mayu/client/src/stream.ts b/lib/mayu/client/src/stream.ts index c2105d42..2fe2b3aa 100644 --- a/lib/mayu/client/src/stream.ts +++ b/lib/mayu/client/src/stream.ts @@ -1,175 +1,152 @@ -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, +): Promise> { + const res = await connect(endpoint, state); - 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 {} + +export async function connect( + endpoint: string, + state: Blob | null = null, +): Promise { + console.info("🟡 Connecting to", endpoint); + + let res: Response | null = null; + + try { + res = state + ? await fetch(endpoint, { + method: "POST", + credentials: "include", + 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", + headers: new Headers({ + accept: STREAM_MIME_TYPE, + "accept-encoding": STREAM_CONTENT_ENCODING, + }), + }); + } catch (e) { + throw new StreamError(); + } + + if (!res.ok) { + const message = await res.json(); + throw new StreamError(message.error); + } + + const contentType = res.headers.get("content-type"); -function resume(sessionId: string, encryptedState?: Blob) { - if (!encryptedState) { - return retry(() => - fetch(`/__mayu/session/${sessionId}/init`, { - method: "POST", - }) - ); + if (contentType !== STREAM_MIME_TYPE) { + // alert(`Unexpected content type: ${contentType}`); + // console.error(res); + throw new StreamError(`Unexpected content type: ${contentType}`); } - return retry(() => - fetch(`/__mayu/session/${sessionId}/resume`, { - method: "POST", - headers: { "content-type": MimeTypes.MAYU_SESSION }, - body: encryptedState, - }) - ); + console.info("🟢 Connected to", endpoint); + + return res; } -function errorMessage(e: any) { - if (e instanceof Error) { - return e.message; +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()); } - if (typeof e === "string") { - return e; + flush() { + this.raf = null; + const queue = this.queue; + if (queue.length === 0) return; + this.queue = []; + this.onFlush(queue); } +} - return String(e); +export class JSONEncoderStream extends TransformStream { + constructor() { + super({ + transform(chunk, controller) { + controller.enqueue(JSON.stringify(chunk) + "\n"); + }, + }); + } } -export async function* sessionStream( - sessionId: string -): AsyncGenerator { - let isRunning = true; - let encryptedState: Blob | undefined; - let isConnected = false; - const extensionCodec = createExtensionCodec(); - let reason: string | undefined; - - while (isRunning) { - try { - const stream = await retry(() => startStream(sessionId, encryptedState)); - - 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); - } - - isConnected = false; - - if (isRunning) { - reason ||= "Stream ended unexpectedly"; - } - - yield ["system.disconnected", { transferring: !!encryptedState, reason }]; - } catch (e) { - logger.error(e); - - if (e instanceof FatalError) { - isRunning = false; - isConnected = false; - yield ["system.disconnected", { reason: e.message }]; - return; - } - - await sleep(1000); - } +export function initCallbackStream(endpoint: string) { + if (!supportsRequestStreams) { + console.warn("Request streams not supported, using fallback."); + return initCallbackStreamFetchFallback(endpoint); } + + const contentEncoding = "identity"; // STREAM_CONTENT_ENCODING; + const { readable, writable } = new TransformStream(); // new CompressionStream(contentEncoding); + + fetch(endpoint, { + method: CALLBACK_STREAM_METHOD, + headers: new Headers({ + "content-type": STREAM_MIME_TYPE, + "content-encoding": contentEncoding, + }), + duplex: "half", + mode: "cors", + body: readable, + } as any) + + return writable; } -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), +function initCallbackStreamFetchFallback(endpoint: string) { + return new WritableStream({ + write(body) { + fetch(endpoint, { + method: CALLBACK_STREAM_METHOD, + headers: new Headers({ + "content-type": "application/json", + }), + mode: "cors", + body: body, + }); + }, }); } diff --git a/lib/mayu/client/src/supportsRequestStreams.ts b/lib/mayu/client/src/supportsRequestStreams.ts new file mode 100644 index 00000000..3421a667 --- /dev/null +++ b/lib/mayu/client/src/supportsRequestStreams.ts @@ -0,0 +1,17 @@ +function supportsRequestStreams() { + // https://developer.chrome.com/articles/fetch-streaming-requests/#feature-detection + let duplexAccessed = false; + + const hasContentType = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + } as any).headers.has("Content-Type"); + + return duplexAccessed && !hasContentType; +} + +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..b5ec6d8a --- /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: NodeJS.Timeout; + 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/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/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 deleted file mode 100644 index cfde95bd..00000000 --- a/lib/mayu/commands.rb +++ /dev/null @@ -1,60 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "configuration" -require_relative "commands/base" -require_relative "colors" -require_relative "banner" - -module Mayu - module Commands - extend T::Sig - - sig { params(argv: T::Array[String]).void } - def self.call(argv) - puts Colors.rainbow(BANNER) - - 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 - end - end - - sig do - params(env: Symbol, overrides: T::Hash[String, T.untyped]).returns( - Configuration - ) - end - def self.load_config(env, overrides: {}) - Mayu::Configuration.load_config(env, pwd: Dir.pwd, overrides:) - 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 deleted file mode 100644 index d3734a9d..00000000 --- a/lib/mayu/commands/build.rb +++ /dev/null @@ -1,82 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "base" -require_relative "../environment" - -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")) - - environment.routes.each do |route| - route.layouts.each do |layout| - components.push(File.join("/app", "pages", layout)) - 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 - ) - - puts - puts "View the app graph:" - puts "\e[34;4m#{resources.mermaid_url}\e[0m" - end - end - end - end -end diff --git a/lib/mayu/commands/init.rb b/lib/mayu/commands/init.rb deleted file mode 100644 index 2f1cb961..00000000 --- a/lib/mayu/commands/init.rb +++ /dev/null @@ -1,122 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require "reline" -require "shellwords" -require_relative "base" -require_relative "../environment" - -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 - - extend T::Sig - - sig { void } - def initialize - end - - sig { params(argv: T::Array[String]).void } - def call(argv) - app_name = T.let(argv.first.to_s, String) - - app_name = read_app_name unless valid_app_name?(app_name) - - if File.exist?(app_name) - puts "#{app_name} already exists" - exit 1 - end - - 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?") - ) - - puts "\nInitializing #{config.name}" - - FileUtils.cp_r(File.join(__dir__, "init", "template"), config.path) - update_fly_toml(config) - - puts "Installing dependencies" - system("bundle install > /dev/null") - - puts "\n\e[32mSuccess!\e[0m Created \e[1m#{config.name}\e[0m 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 - 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/ - 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" - end - end - - sig { returns(String) } - def read_region - puts - puts "See all valid regions with \e[1;34mfly platform regions\e[0m" - - 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" - end - end - - sig { params(question: String).returns(String) } - def readline(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 - end - end - end - 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 deleted file mode 100644 index 0221226d..00000000 --- a/lib/mayu/commands/init/template/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -vendor -metrics.ipc -fly.toml -.assets/* diff --git a/lib/mayu/commands/init/template/.fly/entrypoint.sh b/lib/mayu/commands/init/template/.fly/entrypoint.sh deleted file mode 100755 index 2c78c719..00000000 --- a/lib/mayu/commands/init/template/.fly/entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -flags="" - -if [[ "$ENABLE_YJIT" == t* ]]; then - echo "Enabling YJIT" - flags+=" --yjit" -else - echo "YJIT is not enabled. Enable by setting ENABLE_YJIT=true in fly.toml" -fi - -exec /usr/bin/env ruby $flags $* diff --git a/lib/mayu/commands/init/template/.fly/healthcheck.sh b/lib/mayu/commands/init/template/.fly/healthcheck.sh deleted file mode 100755 index 8d84429e..00000000 --- a/lib/mayu/commands/init/template/.fly/healthcheck.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -set -e - -response=$(curl -s --http2-prior-knowledge http://localhost:3000/__mayu/status) -[[ "${response}" == "ok" ]] diff --git a/lib/mayu/commands/init/template/.gitignore b/lib/mayu/commands/init/template/.gitignore deleted file mode 100644 index 95e02432..00000000 --- a/lib/mayu/commands/init/template/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -vendor/ -metrics.ipc diff --git a/lib/mayu/commands/init/template/Dockerfile b/lib/mayu/commands/init/template/Dockerfile deleted file mode 100644 index 6decc356..00000000 --- a/lib/mayu/commands/init/template/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM ruby:3.3.0-alpine3.18 as base - -ARG BUNDLER_VERSION=2.5.3 -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 -SHELL ["/bin/bash", "-c"] -WORKDIR /app - -FROM base AS install -RUN apk update && apk add --no-cache \ - build-base gzip brotli \ - pkgconfig imagemagick-dev -COPY Gemfile* . -RUN bundle install -COPY . . -RUN bin/mayu build - -FROM base AS final -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"] diff --git a/lib/mayu/commands/init/template/Gemfile b/lib/mayu/commands/init/template/Gemfile deleted file mode 100644 index 4bd88265..00000000 --- a/lib/mayu/commands/init/template/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "mayu-live" 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 deleted file mode 100644 index c53abeea..00000000 --- a/lib/mayu/commands/init/template/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Mayu app - -## Getting started - -Start the development server: - -`bin/mayu dev` - -Open [`https://localhost:9292`](https://localhost:9292) with your browser. - -Open `app/pages/page.haml` with your text editor to make changes. - -## Learn more - -Find the documentation on [mayu.live/docs][https://mayu.live/docs]. - -Check out the [GitHub repository](https://github.com/mayu-live/framework). - -## Deploy on Fly.io - -The easiest way to deploy a Mayu app is on [Fly.io](https://fly.io/). - -Make sure you have [`flyctl`](https://fly.io/docs/hands-on/install-flyctl/) -installed, and that you have authenticated. -(Create an account with `fly auth signup` or login with `fly auth login`). - -Run the following command to launch: - -`fly launch --build-only` - -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. - -Set a secret key with the following command: - -```bash -fly secrets set MAYU_SECRET_KEY=`ruby -rsecurerandom -e "puts SecureRandom.alphanumeric(128)"` -``` - -Then deploy with `fly deploy` diff --git a/lib/mayu/commands/init/template/app/components/Layout/Footer.haml b/lib/mayu/commands/init/template/app/components/Layout/Footer.haml deleted file mode 100644 index 108e8924..00000000 --- a/lib/mayu/commands/init/template/app/components/Layout/Footer.haml +++ /dev/null @@ -1,50 +0,0 @@ -%footer - %p - Made with - %a(target="_blank" href="https://mayu.live/")< Mayu Live - %a(target="_blank" href="https://github.com/mayu-live/framework")< (GitHub) - %dl - %dt Mayu - %dd= Mayu::VERSION - %dt Ruby - %dd= RUBY_VERSION - %dt YJIT - %dd= RubyVM::YJIT.enabled? ? "enabled" : "disabled" - -:css - footer { - margin-inline: auto; - text-align: center; - } - - a { - transition: filter 100ms; - - &:any-link { - color: var(--pink); - text-decoration: none; - } - - &:hover { - text-decoration: underline; - filter: brightness(120%); - } - } - - dl { - display: flex; - } - - :is(dt, dd) { - margin: 0; - padding: 0; - } - - dt { - font-weight: bold; - margin-right: .5ch; - } - - dd { - margin-right: 1em; - } diff --git a/lib/mayu/commands/init/template/app/components/Layout/Header.haml b/lib/mayu/commands/init/template/app/components/Layout/Header.haml deleted file mode 100644 index a1c1b222..00000000 --- a/lib/mayu/commands/init/template/app/components/Layout/Header.haml +++ /dev/null @@ -1,21 +0,0 @@ -:ruby - Image = svg('./header.svg') - -%header - %h1 - %img(src=Image alt="mayu live") - -:css - header { - margin-inline: auto; - } - - h1 { - font-size: inherit; - } - - img { - max-width: 20em; - display: block; - filter: var(--pink-shadow); - } diff --git a/lib/mayu/commands/init/template/app/components/Layout/header.svg b/lib/mayu/commands/init/template/app/components/Layout/header.svg deleted file mode 100644 index 3616daed..00000000 --- a/lib/mayu/commands/init/template/app/components/Layout/header.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/mayu/commands/init/template/app/favicon.png b/lib/mayu/commands/init/template/app/favicon.png deleted file mode 100644 index 34dcb7424a2f190566b74bc8daf2438762282a32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2361 zcmZ{lc{J4f8^^y9jmdt+WD7OJNYl*Nmryg4WsEJtC1T8IWXnt@L}YA}w8+@kFeSTt z-P~{)H56Au$X-K)L^9cKe$)A#^E>Bve&=^S=lQ(P^L{?B^L)p9#_0nE(J$FaU^S0Km4uidz7Ht4IJ?ya)g$*#Mvrmj9=d znV=x*hquB4dp}QcD?3Brh=o~uPys-)^Jnbq3Vq}+Fb~jd2)G0D;$SJH+(QW5PT)-2 zU{AZaca~>Sg1p=gb={b6Gt>>0udSaP@>JHW$!O}Uc|e;UxNxDA<*el*S&o7*i9v(!;5jOLrdq zmDm#3FNIW;ZVMYV@qB(`@7Ny`O*d1|r26dsIHD6+DH7aYau&ev?{QA4mL4xHF()_# z96aus_vjaOS^$cBT^&HL&Gv91m@4+6`HxUL!s}venCulld*vtG)B67XkBwr>ath(P zdO7pm{rex*^biaq!lx@qLzgj*YvvYl%esG|ZN>aNQi9cgO{i%=X}AvS+YH?&S4|mI zHJx>t;9_&{tV8({`Md@HbWcNm`1rumo57X&$|SkA?Yzsi2BP9K&+AxC7i&G3fj*2; zFqG5p&aw!aBw(gx+u9;etvQQSZ`=ePC3eU;3dLIMA(?yIu{R5n-!vcAAT9(^W}=J-@hXF}Vtg@~D>9qVGwXsZgGec%0*@uc>M?wDL2?%o?*r z4QU4BpMX`mvYLqn=2b)FfNcYBgMNj`*N1*!0%pW(P{S9uGI+a>+4tOZIqow)S9N(& zZ&f?$&;=EryjW6ww~3~1wHy+lBO`g!)<5K(Ln5`4k&A1 zKetXjhm8NTmv1X_#2vQksG&ctD~U3IDakp zi!Y3dTk#yn;0eleB8!HzB*DB#}8>*d~TSZfu~Cn)KItDl*Zou)^hu)>8*4<^u}^6 zp|{~<#uKi1pW#Zo_aM$6ZX1$#Av%5a)f3bAvmhx?1Er;qO zJpCYl46#fa@qK#?zOxU*GmhXFsm(`T-+4ngHVGcq{rmEb^I7%}rH^H*ZE-Fqe(7IZbt$iO!3q{9Be~Z!&Nk9+AO@rBpE8Nky=u zRgJRZD@rpMmAWXLX83k}Q6AS$6fd{dHT=PhKJKd0m)gkNrFKQ<#}F-oiN+!J$0)Vc z<%mp}jDvLJXYFGAGeu$Bcu=W?Zv5+$@b$ls+z@%zTqCJMnXpS6nl$tt^xlZ@arlDw zgiM4V*qJiv9S>Et6ivER0AZg}Wxuq!AEBJ@eqA*!_enY`$=XWXULhyMzU0A0B_ia+ ztnt)4?k^*68~PUlLo1fHcHqIy1@XL2Bn4JUtM`G+?hPYtJeNpA|N`4>2i~)y= zBSL2G&3|FPZK~5;kBpe3(k7!U_=)6QWv7hr(Wf=Zx0j;A0%EwZK3!BU)U=S-+in#U zy$)0pu1<`Or=H6qdwBlh@a?z3%m@&ZwEPE*Yy7k_kNsugnV(Rwc!)I0gP)z91NB_f zO3uxeMPJFw#pW9+(rMt&$)K6@X6A(w-7kVvZ@gDmo$Bu?&AxrVG>oOKxC3OV809lfuy1cnwRygjL?X2F{4t%(g3F6#Q?X{D|D z5Rp+<+nQ?ScJ*8+T!YREs|dehi*V%(vsW~xTso)kNDQ)UJ|xOCGrP8!4$vVi#1hGA z!MWMKXL?27Ee^f12)FLpBe55bdg%7GM@?mYB>6W6a7@)r!%Jl9^ zcPnFrUAfe%PBp*HP3fbi7RBqMYBA+t%0sy3xwKPt=|!=7lzhLO#BINEuhJu~Acd|G zulu7Aqn}@1NhmWn>Pv{sdf|k<C8ayZ+?z3AvZ{_KjxDlv{Dm?piuBdi{*Bg-&%YS(~Qyd-1b)l@855mEs2l$YEB*j8EShsY&=YOTMJ*N>sP3 zR&dOJtX6&SS@x)jYUj!3>)(C=X?9=)U13fKI0)C?S`rMBgwHA~ZZ8Z^cYe@CdNM L*<)+YT)g=&Fikek diff --git a/lib/mayu/commands/init/template/app/hexagons.svg b/lib/mayu/commands/init/template/app/hexagons.svg deleted file mode 100644 index a8c02687..00000000 --- a/lib/mayu/commands/init/template/app/hexagons.svg +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/lib/mayu/commands/init/template/app/pages/Box.haml b/lib/mayu/commands/init/template/app/pages/Box.haml deleted file mode 100644 index e13de6be..00000000 --- a/lib/mayu/commands/init/template/app/pages/Box.haml +++ /dev/null @@ -1,12 +0,0 @@ -.box(class=$class) - %slot -:css - .box { - background: var(--box-background); - border: 1px solid var(--box-border); - border-radius: .5rem; - margin: 0; - padding: 1rem; - backdrop-filter: blur(3px); - box-shadow: 0 0 1em #0006; - } diff --git a/lib/mayu/commands/init/template/app/pages/layout.haml b/lib/mayu/commands/init/template/app/pages/layout.haml deleted file mode 100644 index 92678196..00000000 --- a/lib/mayu/commands/init/template/app/pages/layout.haml +++ /dev/null @@ -1,24 +0,0 @@ -:ruby - Header = import('/app/components/Layout/Header') - Footer = import('/app/components/Layout/Footer') - -.layout - %Header - %main - %slot - %Footer - -:css - .layout { - height: 100%; - min-height: 100dvh; - display: flex; - justify-items: center; - flex-direction: column; - justify-content: center; - } - - main { - display: flex; - padding: 1rem; - } diff --git a/lib/mayu/commands/init/template/app/pages/mayu-dolphin.svg b/lib/mayu/commands/init/template/app/pages/mayu-dolphin.svg deleted file mode 100644 index 729a2015..00000000 --- a/lib/mayu/commands/init/template/app/pages/mayu-dolphin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lib/mayu/commands/init/template/app/pages/page.haml b/lib/mayu/commands/init/template/app/pages/page.haml deleted file mode 100644 index 81a63a8b..00000000 --- a/lib/mayu/commands/init/template/app/pages/page.haml +++ /dev/null @@ -1,84 +0,0 @@ -:ruby - Box = import("./Box") - Dolphin = svg("./mayu-dolphin.svg") - -.grid - %Box - Get started by editing - %code< app/pages/page.haml - - %img.dolphin(src=Dolphin alt="Mayu logo") - - %Box.links - %h3 Links - %a.link(target="_blank" href="https://mayu.live/docs") - Documentation - %a.link(target="_blank" href="https://github.com/mayu-live/framework") - GitHub - -:css - .grid { - display: grid; - grid-template-rows: auto 1fr auto; - max-width: 30rem; - gap: 3rem; - align-self: center; - margin: 0 auto; - } - - .center { - display: flex; - } - - code { - font-weight: bold; - user-select: all; - } - - @keyframes dolphin-animation { - 0% { rotate: 0deg; } - 10% { rotate: 10deg; } - 20% { rotate: -10deg; } - 30% { rotate: 20deg; } - 40% { rotate: -5deg; } - 50% { rotate: 0deg; } - 100% { rotate: 0deg; } - } - - .dolphin { - align-self: center; - display: block; - width: 10rem; - margin: 0 auto; - filter: var(--pink-shadow); - animation: 5s ease-in-out 1s infinite running dolphin-animation; - } - - .links { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem; - - background: light-dark(#0001, #fff1); - border: 1px solid light-dark(#0002, #fff2); - border-radius: .5rem; - - & h3 { - margin: 0; - } - } - - a { - transition: filter 100ms; - - &:any-link { - color: var(--pink); - text-decoration: none; - } - - &:hover { - text-decoration: underline; - filter: brightness(120%); - } - } diff --git a/lib/mayu/commands/init/template/app/robots.txt b/lib/mayu/commands/init/template/app/robots.txt deleted file mode 100644 index c2a49f4f..00000000 --- a/lib/mayu/commands/init/template/app/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Allow: / diff --git a/lib/mayu/commands/init/template/app/root.css b/lib/mayu/commands/init/template/app/root.css deleted file mode 100644 index 0d29ffcb..00000000 --- a/lib/mayu/commands/init/template/app/root.css +++ /dev/null @@ -1,42 +0,0 @@ -:root { - --body-background: #1b2b5f; - --body-color: #f0e0e9; - --pink: #df6492; - --pink-shadow: drop-shadow(0 0 0.5rem #fff) drop-shadow(0 0 2rem var(--pink)); - --box-background: #fff1; - --box-border: #fff2; - - color-scheme: light dark; -} - -@media (prefers-color-scheme: light) { - :root { - --body-background: #fff; - --body-color: #333; - --box-background: #0001; - --box-border: #0002; - } -} - -body { - margin: 0; - padding: 0; - min-height: 100dvh; - background: radial-gradient( - ellipse at bottom, - color-mix(in srgb, var(--pink) 50%, var(--body-background)), - transparent - ), - var(--body-background); - color: var(--body-color); - font-family: sans-serif; -} - -.background { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - background: radial-gradient(at bottom, #0002, #0000); - mask-position: 50% 50%; -} diff --git a/lib/mayu/commands/init/template/app/root.haml b/lib/mayu/commands/init/template/app/root.haml deleted file mode 100644 index ecde8588..00000000 --- a/lib/mayu/commands/init/template/app/root.haml +++ /dev/null @@ -1,11 +0,0 @@ -: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 diff --git a/lib/mayu/commands/init/template/bin/mayu b/lib/mayu/commands/init/template/bin/mayu deleted file mode 100755 index 1897de41..00000000 --- a/lib/mayu/commands/init/template/bin/mayu +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -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 deleted file mode 100644 index a71609c7..00000000 --- a/lib/mayu/commands/init/template/fly.toml +++ /dev/null @@ -1,53 +0,0 @@ -# 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" -kill_signal = "SIGINT" -kill_timeout = "5s" - -[build] - -[deploy] - strategy = "rolling" - -[env] - ENABLE_YJIT = "true" - -[[services]] - protocol = "tcp" - internal_port = 3000 - auto_stop_machines = true - auto_start_machines = true - min_machines_running = 1 - - [[services.ports]] - port = 80 - handlers = ["http"] - force_https = true - - [[services.ports]] - port = 443 - handlers = ["tls"] - [services.ports.tls_options] - alpn = ["h2"] - [services.concurrency] - type = "connections" - hard_limit = 25 - soft_limit = 20 - - [[services.tcp_checks]] - interval = "15s" - timeout = "2s" - grace_period = "1s" - -[[vm]] - cpu_kind = "shared" - cpus = 1 - memory_mb = 512 - -[[metrics]] - port = 9092 - path = "/metrics" diff --git a/lib/mayu/commands/init/template/mayu.toml b/lib/mayu/commands/init/template/mayu.toml deleted file mode 100644 index 4d952287..00000000 --- a/lib/mayu/commands/init/template/mayu.toml +++ /dev/null @@ -1,57 +0,0 @@ -[dev] - secret_key = "dev" - - [dev.server] - scheme = "https" - host = "localhost" - port = 9292 - - count = 1 - hot_swap = true - - render_exceptions = true - self_signed_cert = true - - generate_assets = true - - [dev.metrics] - enabled = true - -[devbundle] - secret_key = "dev" - use_bundle = true - - [devbundle.server] - scheme = "https" - host = "localhost" - port = 9292 - render_exceptions = true - self_signed_cert = true - - hot_swap = false - - count = 4 - forks = 2 - - [devbundle.metrics] - enabled = true - port = 9091 - host = "0.0.0.0" - -[prod] - use_bundle = true - - [prod.server] - scheme = "http" - host = "0.0.0.0" - port = 3000 - - hot_swap = false - - count = 2 - forks = 1 - - [prod.metrics] - enabled = true - port = 9091 - host = "0.0.0.0" diff --git a/lib/mayu/component.rb b/lib/mayu/component.rb index 7f057d0d..0df705fa 100644 --- a/lib/mayu/component.rb +++ b/lib/mayu/component.rb @@ -1,54 +1,6 @@ -# typed: strict - -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..8ca96c88 100644 --- a/lib/mayu/component/base.rb +++ b/lib/mayu/component/base.rb @@ -1,177 +1,89 @@ -# typed: strict - -require_relative "handler_ref" +require_relative "../style_sheet" +require_relative "../runtime/h" +require_relative "css_units" 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 - self + def self.init(**props) + component = allocate + component.instance_variable_set(:@__props, props) + component.send(:initialize) + component end - sig { overridable.params(props: T.untyped).returns(Component::State) } - def self.get_initial_state(**props) - {} + def self.module_path end - class << self - extend T::Sig + def self.import(filename) = + Modules::System.import(filename, caller.first.split(":", 2).first) - 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, caller.first.split(":", 2).first) - # 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.join(" ") unless classnames.empty? end - sig { returns(T::Boolean) } - def __mayu_resource? - !!@__mayu_resource - end + result 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 do |ivar| + [ivar, instance_variable_get(ivar)] + end.to_h end - sig { params(wrapper: Wrapper).void } - def initialize(wrapper) - @__wrapper = wrapper + def marshal_load(ivars) + ivars.each do |ivar, value| + instance_variable_set(ivar, value) + end 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 - 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 should_update?(old_props, old_state) 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({}) - 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) - - sig { abstract.returns(ChildType) } def render 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 __children + @__children end - sig { returns(Helpers) } - def mayu = @__wrapper.helpers - alias helpers mayu + private - sig do - params( - state: T.nilable(State), - blk: T.nilable(Wrapper::UpdateProc) - ).void - end - def update(state = nil, &blk) - @__wrapper.update(state, &blk) + def rerender! end - sig { returns(VDOM::Children) } - def children = props[:children] + def update!(value) + rerender! + value + end 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/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 - "# 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..ae8fe433 100644 --- a/lib/mayu/configuration.rb +++ b/lib/mayu/configuration.rb @@ -1,194 +1,95 @@ -# typed: strict +#!/usr/bin/env ruby -rbundler/setup # 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 - - 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) - - sig { returns(URI::HTTP) } - def uri - URI.for(scheme, nil, host, port, nil, "/", nil, nil, nil).normalize - end - 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") - end +require "toml" - 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" +module Mayu + module Configuration + class ConfigNotFound < 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[var] + 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) - - parent = File.expand_path("..", dir) - - if dir == parent - raise "Could not find #{CONFIG_FILE} in any parent directory." + 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 - - resolve_config_file(parent) end - sig do - params( - mode: Symbol, - pwd: String, - overrides: T::Hash[String, T.untyped] - ).returns(T.attached_class) + ServerConfig = Data.define( + :listen, + :hmr?, + :render_exceptions?, + :self_signed_cert?, + :generate_assets?, + ) 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("self_signed_cert", false), + ) + end 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]] + MetricsConfig = Data.define(:enabled?, :listen) do + def self.parse(config) + new( + enabled?: config.fetch("enabled", true), + listen: config.fetch("listen", "http://localhost:9293"), ) + end + end - 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 + def self.load(path) + path + .then { File.read(_1) } + .then { TOML.load(_1) } + .map do + [_1.to_sym, Config.parse(File.dirname(path), _2)] end - - from_hash!( - { - **merged_config, - "root" => root, - "secret_key" => secret_key, - "mode" => mode - } - ) + .to_h end - sig { params(configuration: T::Struct).void } - def self.log_config(configuration) - Console.logger.info(self) { make_table(configuration) } - end + def self.find(filename = "mayu.toml", dir = Dir.pwd) + path = File.join(dir, filename) - sig do - params( - configuration: T::Struct, - style: T::Hash[Symbol, T.untyped] - ).returns(String) + if File.exist?(path) + path + else + parent = File.join(dir, "..") + return if dir == parent + find_config_file(filename, parent) + end 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 - end - .to_s - end + def self.with(&) + path = find - 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 + unless path + raise ConfigNotFound, "Could not find mayu.toml in #{Dir.pwd}" 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 - else - 0 + config = self.load(path) + + Dir.chdir(File.dirname(path)) do + yield config end end end diff --git a/lib/mayu/configuration.test.rb b/lib/mayu/configuration.test.rb new file mode 100755 index 00000000..cc5da9c3 --- /dev/null +++ b/lib/mayu/configuration.test.rb @@ -0,0 +1,17 @@ +#!/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) + pp config + end +end diff --git a/lib/mayu/custom_element.rb b/lib/mayu/custom_element.rb new file mode 100644 index 00000000..c01842c0 --- /dev/null +++ b/lib/mayu/custom_element.rb @@ -0,0 +1,3 @@ +module Mayu + CustomElement = Data.define(:name, :filename) +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..874a3ca5 100644 --- a/lib/mayu/encrypted_marshal.rb +++ b/lib/mayu/encrypted_marshal.rb @@ -1,119 +1,95 @@ -# 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 - 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: @default_ttl_seconds) + 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" + 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..d9668dd3 --- a/lib/mayu/encrypted_marshal.test.rb +++ b/lib/mayu/encrypted_marshal.test.rb @@ -1,66 +1,72 @@ +#!/usr/bin/env ruby -rbundler/setup +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + require "minitest/autorun" -# require "test_helper" 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) + now = Time.now + 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") + now = Time.now + 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_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..5786594a 100644 --- a/lib/mayu/environment.rb +++ b/lib/mayu/environment.rb @@ -1,154 +1,37 @@ -# typed: strict - -require "async" -require "async/http/internet" -require_relative "vdom" -require_relative "state/store" -require_relative "state/loader" require_relative "routes" -require_relative "metrics" -require_relative "app_metrics" -require_relative "resources/registry" -require_relative "fetch" require_relative "encrypted_marshal" -require_relative "configuration" 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" - - 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 :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) - @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 - ) - @metrics = metrics - @fetch = T.let(Fetch.new, Fetch) - @init_js = T.let(nil, T.nilable(String)) - end - - sig { returns(String) } - def init_js - @init_js ||= - JSON.parse(File.read(File.join(js_runtime_path, "entries.json"))).fetch( - "main" - ) - end - - sig { params(name: Symbol).returns(String) } - def path(name) - File.join(@root, @config.paths.send(name)) - end - - sig { returns(String) } - def js_runtime_path - File.join(__dir__, "client", "dist") - end - - sig do - params(initial_state: T::Hash[Symbol, T.untyped]).returns(State::Store) - end - def create_store(initial_state: {}) - State::Store.new(initial_state, reducers:) - end - - sig do - params(request_path: String, headers: T::Hash[String, String]).returns( - VDOM::Descriptor + Environment = Data.define(:config, :app_dir, :pages_dir, :client_path, :runtime_js, :router, :marshaller) do + def self.from_config(config) + app_dir = File.join(config.root, "app") + pages_dir = File.join(app_dir, "pages") + router = Mayu::Routes::Router.build(pages_dir) + + client_path = File.join(__dir__, "..", "..", "client", "dist") + + runtime_js = + File + .read(File.join(client_path, "entries.json")) + .then { JSON.parse(_1) } + .fetch("main") + .then { File.join("/.mayu/runtime", _1) } + + marshaller = EncryptedMarshal.new("TODO: Get this from config", ttl: 5) + + new( + config:, + app_dir:, + pages_dir:, + client_path:, + runtime_js:, + router:, + marshaller:, ) 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] - end - .then { VDOM::H[root.component, _1] } - end - sig { params(request_path: String).returns(Routes::RouteMatch) } - def match_route(request_path) - Routes.match_route(@routes, request_path) + def runtime_js_for_session_id(session_id) + [runtime_js, session_id].join("#") 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..7e6487e5 --- /dev/null +++ b/lib/mayu/image.rb @@ -0,0 +1,12 @@ +module Mayu + Image = Data.define(:path, :format, :size, :digest) do + def public_path + Kernel.format( + "/.mayu/assets/%s.%s?%s", + File.basename(path, ".*"), + format, + digest + ) + end + end +end diff --git a/lib/mayu/metrics.rb b/lib/mayu/metrics.rb deleted file mode 100644 index b0e98115..00000000 --- a/lib/mayu/metrics.rb +++ /dev/null @@ -1,82 +0,0 @@ -# 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" - -require_relative "metrics/collector" -require_relative "metrics/exporter" -require_relative "metrics/reporter" - -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] } - - class Wrapper < MessagePack::Factory - extend T::Sig - - sig { void } - def initialize - super() - - self.register_type(0x01, Symbol) - 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 - end - def self.start_collect_and_export( - container, - exporter_endpoint:, - collector_endpoint:, - &block - ) - collector = Metrics::Collector::Server.new(collector_endpoint) - collector.start - - container.spawn( - name: "Metrics collector/exporter", - restart: true - ) do |instance| - Async do - internal_store = {} - - Prometheus::Client.config.data_store = - Metrics::Collector::DataStore.new(internal_store) - - registry = Prometheus::Client::Registry.new - - yield registry - - Metrics::Exporter::Server.setup( - endpoint: exporter_endpoint, - registry: - ).run - - collector.run(internal_store) - - instance.ready! - end - end - end - end -end diff --git a/lib/mayu/metrics/collector.rb b/lib/mayu/metrics/collector.rb deleted file mode 100644 index bc724656..00000000 --- a/lib/mayu/metrics/collector.rb +++ /dev/null @@ -1,161 +0,0 @@ -# typed: strict - -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) - end - - sig do - params( - val: T.any(Integer, Float), - labels: T::Hash[Symbol, T.untyped] - ).void - end - def set(val:, labels: {}) - end - - sig { returns(T::Hash[T::Array[Symbol], Float]) } - def all_values - @store - .values_for_metric(@metric_name) - .transform_values { aggregate(_1) } - 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 - values.sum - else - raise "Invalid aggregation setting" - 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 } - 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: {}) - 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 - end - - sig { void } - def stop - 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) - wrapper = Wrapper.new - - Console.logger.info( - self, - "Starting metrics collection on #{File.expand_path(@endpoint.path)}" - ) - - @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) - else - Console - .logger - .warn(self) { "Unhandled mesage: #{message.inspect}" } - end - end - ensure - internal_store.delete(peer) - end - end - end - end - end -end 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 deleted file mode 100644 index c3e9f589..00000000 --- a/lib/mayu/metrics/reporter.rb +++ /dev/null @@ -1,187 +0,0 @@ -# typed: strict - -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) - 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 - 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( - self, - "Connecting to #{File.expand_path(collector_endpoint.path)}" - ) - - collector_endpoint.connect { |peer| yield 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, - task: Async::Task.current - ) - task.async do - connect(collector_endpoint) do |client| - loop do - client.sync(data_store) - sleep(interval) - 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) - end - - sig { params(data_store: DataStore, task: Async::Task).void } - def sync(data_store, task: Async::Task.current) - send(:store, data_store.store) - end - - private - - sig { params(args: T.untyped).void } - def send(*args) - @packer.write(args) - @packer.flush - end - end - - 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 - @metric_name = metric_name - @semaphore = T.let(Async::Semaphore.new, Async::Semaphore) - 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 - 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) - 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) - end - - sig { params(labels: T::Hash[Symbol, T.untyped]).void } - def get(labels:) - @store.fetch(labels) - end - end - - extend T::Sig - - sig { returns(MetricHash) } - attr_reader :store - - sig { void } - def initialize - @store = T.let(init_metric_hash, MetricHash) - 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( - T.must(@store[metric_name]), - 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 } - end - - sig { returns(ValueHash) } - def init_value_hash - Hash.new { |hash, labels| hash[labels] = 0 } - end - end - end - end -end diff --git a/lib/mayu/modules/assets.rb b/lib/mayu/modules/assets.rb new file mode 100644 index 00000000..96b9d84d --- /dev/null +++ b/lib/mayu/modules/assets.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "mime/types" +require "brotli" +require "digest/sha2" +require "base64" + +MIME::Types["application/json"].first.add_extensions(%w[map]) + +module Mayu + module Modules + class Assets + Asset = + Data.define( + :content_type, + :content_hash, + :encoded_content, + :filename + ) do + def self.build(filename, content) + MIME::Types.type_for(filename).first => MIME::Type => mime_type + + encoded_content = + EncodedContent.for_mime_type_and_content(mime_type, content) + content_hash = Digest::SHA256.digest(encoded_content.content) + content_type = mime_type.to_s + + filename = + format( + "%s.%s", + Base64.urlsafe_encode64(content_hash, padding: false), + mime_type.preferred_extension, + ) + + new(content_type:, content_hash:, encoded_content:, filename:) + end + + def headers + { + "content-type": content_type, + "content-length": content_length, + **encoded_content.headers + } + end + + def content_length + encoded_content.content.bytesize + end + end + + 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 + if encoding + { "content-encoding": encoding.to_s } + else + {} + end + end + end + + def initialize + @assets = {} + end + + def get(filename) + @assets.fetch(filename) + end + + def store(asset) + puts "\e[34mStoring asset: #{asset.filename}\e[0m" + @assets.store(asset.filename, asset) + end + end + end +end diff --git a/lib/mayu/modules/import.rb b/lib/mayu/modules/import.rb new file mode 100644 index 00000000..27bc8d65 --- /dev/null +++ b/lib/mayu/modules/import.rb @@ -0,0 +1,23 @@ +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/loaders.rb b/lib/mayu/modules/loaders.rb new file mode 100644 index 00000000..f7be90c6 --- /dev/null +++ b/lib/mayu/modules/loaders.rb @@ -0,0 +1,37 @@ +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 + digest ? self : with(digest: Digest::SHA256.file(absolute_path).digest) + 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") + 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..a7a99261 --- /dev/null +++ b/lib/mayu/modules/loaders/__test__/haml/HelloWorld.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +class HelloWorld < Mayu::Component::Base + def self.module_path + __FILE__ + end + Self = self + FILENAME = __FILE__ + Styles = + Mayu::StyleSheet[ + source_filename: "HelloWorld.haml (inline css)", + content_hash: "_xsfOrzqR-0dWREVaALo-ixZnypIrVXL1tvu828-nTM", + classes: { + __button: "HelloWorld_button?N2Q7U-wl" + }, + 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..ee0803cb --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/__test__/haml/whitespace_preservation.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class Test < Mayu::Component::Base + def self.module_path + __FILE__ + end + Self = self + FILENAME = __FILE__ + Styles = Mayu::NullStyleSheet[self] + public def render + # SourceMapMark:1:IkZvb1xuPHByZT5CYXJcbkJhejwvcHJlPiI= + "Foo\n
Bar\nBaz
" + end +end +Default = Test +Default::Styles.each { add_asset(Assets::Asset.build(_1.filename, _1.content)) } diff --git a/lib/mayu/modules/loaders/transformers/css.rb b/lib/mayu/modules/loaders/transformers/css.rb new file mode 100644 index 00000000..0e8f8dee --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/css.rb @@ -0,0 +1,190 @@ +# 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" + +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 { new(source_path, _1).build_inline_ast(assign_default: true) } + .then { SyntaxTree::Formatter.format("", _1) } + 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)], '"')), + 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) + ) + else + new_style_sheet + end + ] + ) + end + + private + + def build_imports + @parse_result.dependencies.map do |dep| + dep => { placeholder:, url: } + + Assign( + VarField(Const(@dependency_const_prefix + placeholder)), + build_import(url) + ) + end + 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( + Label("#{key}:"), + StringLiteral([TStringContent(value.to_s)], '"') + ) + 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( + Const(@dependency_const_prefix + placeholder) + ), + Period("."), + Ident("public_path"), + nil + ) + ] + ) + ) + ) + ] + ) + ) + ) + end + + parts.push(TStringContent(remains)) unless remains.empty? + + parts + 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..13923dcc --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/css.test.rb @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby -rbundler/setup + +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), 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.rb b/lib/mayu/modules/loaders/transformers/haml.rb new file mode 100644 index 00000000..a6ec4e83 --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/haml.rb @@ -0,0 +1,1180 @@ +# 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_relative "css" +require_relative "mutation_visitor" +require_relative "../../../style_sheet" +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: "x", + 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 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( + "Styles", + if styles.empty? + ARef( + ConstPathRef( + VarRef(Const("Mayu")), + Const("NullStyleSheet") + ), + Args([VarRef(Kw("self"))]) + ) + else + CallNode( + CSS.transform_inline( + @options.source_path_without_extension + + ".haml (inline css)", + styles.join("\n"), + dependency_const_prefix: "CSS_Dep_" + ), + Period("."), + Ident("merge"), + ArgParen( + Args( + [ + CallNode( + nil, + nil, + Ident("import?"), + ArgParen( + Args( + [ + StringLiteral( + [ + TStringContent( + @options.source_path_without_extension + + ".css" + ) + ], + '"' + ) + ] + ) + ) + ) + ] + ) + ) + ) + end + ) + 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 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.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 + Statements(statements).then do + Begin(BodyStmt(_1, nil, nil, nil, nil)) + end + 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 + + class ParseError < StandardError + end + + class Transformer < SyntaxTree::Haml::Visitor + 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 + + 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" } }, *] + 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(_1) } + .then { parse_ruby(_1, 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(_1) } + .then { wrap_multiple_expressions_in_array(_1) } + ) + 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.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 = SecureRandom.alphanumeric + @builder.Heredoc( + @builder.HeredocBeg("<<~PLAIN_#{id}"), + @builder.HeredocEnd("PLAIN_#{id}"), + true, + lines.map { @builder.TStringContent(_1.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 + _1.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 + + 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 + + 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 + + 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 => 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") + @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 + + 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 + + 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.test.rb b/lib/mayu/modules/loaders/transformers/haml.test.rb new file mode 100755 index 00000000..6aa5a790 --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/haml.test.rb @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby -rbundler/setup +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, + "/app/components/Test.haml", + ).output + end + .then do + Mayu::Modules::Loaders::Transformers::Ruby.transform( + _1, + "/app/components/Test.haml", + component_base_class: "Mayu::Component::Base" + ) + end + + if File.exist?(ruby) + assert_equal(File.read(ruby), output) + else + puts "\e[33mWriting #{ruby}\e[0m" + File.write(ruby, output) + 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..6657dbc0 --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/mutation_visitor.rb @@ -0,0 +1,62 @@ +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_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..26197fdd --- /dev/null +++ b/lib/mayu/modules/loaders/transformers/ruby.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "syntax_tree" +require_relative "mutation_visitor" +require_relative "xml_utils" + +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 + + class FrozenStringLiteralsVisitor < 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 + + include SyntaxTree::DSL + + COLLECTIONS = { SyntaxTree::IVar => "state", SyntaxTree::GVar => "props" } + + def self.transform(source, path, using: [], component_base_class:) + transformer = new + puts "\e[33m#{source}\e[0m" + SyntaxTree.parse(component_base_class).statements.body => [component_base_path] + + using = using.map do + SyntaxTree.parse(_1).statements.body => [mod] + mod + end + + SyntaxTree + .parse(source) + .accept(transformer.heredoc_html) + .then { transformer.wrap_in_class(_1, path, component_base_path:, using:) } + .accept(transformer.frozen_strings) + .then { Formatter.format(source, _1) } + end + + def frozen_strings = FrozenStringLiteralsVisitor.new + + def wrap_in_class(program, path, component_base_path:, using:) + class_name = File.basename(path, ".*").sub(/\A[[:lower:]]/) { _1.upcase } + + statements = + Statements( + [ + ClassDeclaration( + VarRef(Const(class_name)), + component_base_path, + BodyStmt( + Statements( + [ + DefNode( + VarRef(Kw("self")), + Period("."), + Ident("module_path"), + nil, + BodyStmt(Statements([VarRef(Kw("__FILE__"))]), nil, nil, nil, nil) + ), + 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, + MethodAddBlock( + CallNode( + ConstPathRef( + VarRef(Const("Default")), + Const("Styles") + ), + Period("."), + Ident("each"), + nil + ), + BlockNode( + BlockVar( + Params( + [], + [], + [], + [], + [], + [], + nil + ), + nil + ), + nil, + Statements([ + CallNode( + nil, + nil, + Ident("add_asset"), + ArgParen( + Args([ + CallNode( + ConstPathRef( + VarRef(Const("Assets")), + Const("Asset") + ), + Period("."), + Ident("build"), + ArgParen( + Args([ + CallNode( + VarRef(Ident("_1")), + Period("."), + Ident("filename"), + nil + ), + CallNode( + VarRef(Ident("_1")), + Period("."), + Ident("content"), + nil + ), + ]) + ), + ) + ]) + ) + ) + ]) + ) + ) + ] + ) + program.copy(statements:) + end + + def using_statements(using) + using.map do + Command( + Ident("using"), + Args([_1]), + nil + ) + end + 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 + + 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(//) 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..5c10fed5 --- /dev/null +++ b/lib/mayu/modules/mod.rb @@ -0,0 +1,125 @@ +module Mayu + module Modules + class Exports < Module + def initialize(mod, source, path) + @mod = mod + @source = source + @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_reader :order + attr_reader :path + attr_reader :dependants + attr_reader :dependencies + attr_reader :system + attr_reader :source_map + + def initialize(system, path) + @order = Float::INFINITY + @system = system + @path = path + @dependants = Set.new + @dependencies = Set.new + @state = :dirty + @system.register(@path, self) + @source = nil + @source_map = nil + @assets = Set.new + end + + def assets = @assets.to_a + + 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, @state, @source, @assets, @source_map] + end + + def marshal_load(a) + @order, @path, @dependants, @dependencies, @state, @source, @assets, @source_map = a + Registry[@path] = self + end + + def reload(reload_source: true) + if const_defined?(:Exports) + puts "Reloading #@path" + old_exports = const_get(:Exports) + remove_const(:Exports) + else + puts "Loading #@path" + end + + begin + reload_source! + rescue => e + if old_exports + const_set(:Exports, old_exports) + end + pp e + puts e.backtrace + return + end if reload_source + + @assets.clear + + path = @path + + exports = + begin + Exports.new(self, @source, path) + rescue => e + puts e + puts e.backtrace.first(5) + Exports.new(self, "", path) + end + + const_set(:Exports, exports) + ensure + @dirty = false + end + + def reload_source! + @source, @source_map = @system.read_source(@path) + end + + def dirty? = @dirty + def dirty! = @dirty = true + + def import(path) + @system.import(path, @path) + end + + def add_asset(asset) + @assets.add(asset) + @system.add_asset(asset, @path) + end + + def exports + self::Exports.constants + end + + def absolute_path + File.join(@system.root, @path) + end + end + end +end diff --git a/lib/mayu/modules/registry.rb b/lib/mayu/modules/registry.rb new file mode 100644 index 00000000..287aca65 --- /dev/null +++ b/lib/mayu/modules/registry.rb @@ -0,0 +1,45 @@ +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..74d5b036 --- /dev/null +++ b/lib/mayu/modules/resolver.rb @@ -0,0 +1,67 @@ +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) + $stderr.puts "\e[1mFound #{absolute_path_with_extension}\e[0m" if @verbose + yield extension + else + $stderr.puts "\e[2mTried #{absolute_path_with_extension}\e[0m" if @verbose + 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..fca339af --- /dev/null +++ b/lib/mayu/modules/rules.rb @@ -0,0 +1,14 @@ +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..91620240 --- /dev/null +++ b/lib/mayu/modules/source_map.rb @@ -0,0 +1,127 @@ +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 + + Pos = 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, :mappings) do + def self.parse(input, output) + input_lines = input.each_line.to_a + + mappings = + 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] = Pos[line_no, column] + end + end + + new(input, output, mappings) + end + + def rewrite_backtrace(backtrace, file) + backtrace.map do |entry| + rewrite_backtrace_entry(entry, file, mappings) + end + end + + def rewrite_exception(e, file) + e.set_backtrace(rewrite_backtrace(e.backtrace, file).first(10)) + end + + def format_exception(e, source_path) + rewrite_exception(e, source_path) + + reset = "\e[0;48;5;52m" + + interesting_lines = + e + .backtrace + .grep(/\A#{Regexp.escape(source_path)}:/) + .map { _1.match(/:(\d+):/)[1].to_i } + + [ + "\e[1;31;47m ERROR \e[3;31;47m #{e.class.name}: #{e.message} #{reset}", + "\e[1;34mBacktrace:#{reset}", + e.backtrace.map do |trace| + if match = trace.match(/\A(.*):(\d+):in `(.*)'\Z/) + "#{reset}\e[2mfrom #{reset}\e[1m%s:%s#{reset}\e[2m:in `#{reset}\e[1m%s#{reset}\e[2m`#{reset}" % match.captures + else + "from #{trace}#{reset}" + end + end.join("\n"), + "\e[1;34mSource:#{reset}", + self.input + .each_line + .map + .with_index(1) do |line, i| + if interesting_lines.include?(i) + format("\e[1;31m%3d: %s#{reset}", i, line.chomp) + else + format("%3d: %s", i, line.chomp) + end + end + .join("\n") + ].join("\n") + "\e[0m" + end + + private + + def rewrite_backtrace_entry(entry, file, mappings) + re = /\A#{Regexp.escape(file)}:(\d+):(.*)/ + + if match = entry.match(re) + line_no = match[1].to_i + + if mapping = find_closest_mapping(line_no, mappings) + return [file, mapping.line, match[2]].join(":") + end + end + + entry + end + + def find_closest_mapping(line_no, mappings) + mappings + .select { |k, _| k <= line_no } + .max_by(&:first) + &.last + 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..c63099a1 --- /dev/null +++ b/lib/mayu/modules/source_map.test.rb @@ -0,0 +1,90 @@ +require "minitest/autorun" + +require_relative "source_map" + +class Mayu::Modules::SourceMap::Test < Minitest::Test + SourceMap = Mayu::Modules::SourceMap + + def test_parse + skip + parsed = 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 + + actual = + parsed.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 + "/app/components/MyComponent.haml", + ) + + 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'", + ]) + + puts source_map.format_exception(e, "/app/components/MyComponent.haml") + end +end diff --git a/lib/mayu/modules/system.rb b/lib/mayu/modules/system.rb new file mode 100644 index 00000000..266d13cf --- /dev/null +++ b/lib/mayu/modules/system.rb @@ -0,0 +1,239 @@ +# 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 "assets" +require_relative "watcher" +require_relative "import" + +module Mayu + module Modules + class System + CURRENT_KEY = :modules_system + + def self.use(root, **options, &) + new(root, **options).use(&) + end + + def self.current = + Fiber[CURRENT_KEY] + + def self.import(path, source) = + current.import(path, source) + + def self.add_asset(asset, source) = + current.add_asset(asset, source) + + def self.import?(path, source) + import(path, source) + rescue + nil + end + + attr_reader :root + + def initialize(root, rules: [], extensions: ["", ".rb"]) + @root = File.expand_path(root) + @resolver = Resolver.new(@root, extensions:) + @rules = rules + @assets = Assets.new + @on_reload = Async::Notification.new + @mods = {} + end + + def wait_for_reload + @on_reload.wait + end + + def marshal_dump + [@root, @resolver, @rules, @assets, Marshal.dump(@mods)] + end + + def marshal_load(a) + @root, @resolver, @rules, @assets, mods = a + @on_reload = Async::Notification.new + + use do + @mods = Marshal.load(mods) + @mods + .each_value { _1.instance_variable_set(:@system, self) } + .each_value { _1.reload(reload_source: false) } + end + end + + def use(&) + prev = Fiber[CURRENT_KEY] + Fiber[CURRENT_KEY] = self + yield self + ensure + Fiber[CURRENT_KEY] = prev + end + + def read_source(path) + file = Loaders::LoadingFile[@root, path, nil].load_source + input = file.source + + matching_rules = @rules.select { _1.match?(path) } + + if matching_rules.empty? + raise "No rules for file: #{path}" + end + + transformed = + matching_rules + .reduce(file) do |file, rule| + rule.call(file) + end + .source + # .tap do + # puts "\e[3m#{path}\e[0m\e[35m\n#{_1}\e[0m" + # end + + source_map = SourceMap::SourceMap.parse(input, transformed) + [transformed, source_map] + end + + def relative_from_root(path) + Pathname.new(path).relative_path_from(@root).to_s.sub(/\A\/*/, "/") + end + + def register(path, mod) + Registry[path] = mod + @mods[path] = mod + end + + def unregister(path) + @mods.each do |mod| + mod.dependants.delete(path) + mod.dependencies.delete(path) + end + + Registry.delete(path) + @mods.delete(path) + end + + def start_watch(task: Async::Task.current) + task.async do + Watcher.run(self, task:) do |events| + events.each do |event| + puts event.to_s + + case event + in Watcher::Events::Created[path:] + in Watcher::Events::Updated[path:] + if mod = @mods[path] + mod.dirty! + # visit_dependencies(mod, &:dirty!) + else + puts "\e[31mModule not found: #{path}\e[0m" + end + in Watcher::Events::Deleted[path:] + if mod = @mods.delete(path) + visit_dependencies(mod, &:dirty?) + delete_mod(path) + end + end + end + + reload_dirty + end + end + end + + def import(path, source = "/") + # puts "\e[35mimport(#{path.inspect}, #{source.inspect})\e[0m" + + mod = get_or_load_mod(path, File.dirname(source)) + + if source_mod = @mods[source] + 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, _source) + @assets.store(asset) + end + + def overall_order + TSort.tsort( + ->(&b) { @mods.keys.each(&b) }, + ->(key, &b) { @mods[key]&.dependants&.each(&b) } + ) + end + + def update_order + overall_order.each_with_index do |mod, index| + mod.order = index + end + 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 + + private + + def reload_dirty + overall_order + .reverse + .map { @mods[_1] } + .compact + .select(&:dirty?) + .each(&:reload) + @on_reload.signal(true) + 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_dependencies(mod, &block) + mod.dependants.each do |dependency| + if mod = @mods[dependency] + yield mod + visit_dependencies(mod, &block) + end + end + end + end + end +end diff --git a/lib/mayu/modules/watcher.rb b/lib/mayu/modules/watcher.rb new file mode 100644 index 00000000..00d06aa3 --- /dev/null +++ b/lib/mayu/modules/watcher.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +module Mayu + module Modules + module Watcher + module Events + Created = + Data.define(:path) do + def to_s = "\e[32m#{self.class.name}[#{path.inspect}]\e[0m" + end + + Deleted = + Data.define(:path) do + def to_s = "\e[31m#{self.class.name}[#{path.inspect}]\e[0m" + end + + Updated = + Data.define(:path) do + def to_s = "\e[33m#{self.class.name}[#{path.inspect}]\e[0m" + end + + def self.from_sym(event) + case event + in :created + Created + in :updated + Updated + in :deleted + Deleted + end + end + + def self.build(event, path) + from_sym(event).new(path) + end + end + + def self.run(system, task:, &) + require "filewatcher" + + Filewatcher.class_eval do + # Filewatcher traps signals we need + def trap(_signal) = nil + end + + queue = Async::Queue.new + + watcher = task.async do + fw = Filewatcher.new([system.root]) + fw.watch do |changes| + queue.enqueue( + changes.map do |path, event| + Events.build(event, system.relative_from_root(path)) + end + ) + end + ensure + fw.stop + end + + loop do + yield queue.dequeue + end + ensure + watcher.stop + 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.in.css b/lib/mayu/resources/transformers/__test__/css/composes.in.css deleted file mode 100644 index a3095436..00000000 --- a/lib/mayu/resources/transformers/__test__/css/composes.in.css +++ /dev/null @@ -1,6 +0,0 @@ -.foo { - color: #f0f; -} -.bar { - composes: foo; -} 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.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..fc860a79 100644 --- a/lib/mayu/routes.rb +++ b/lib/mayu/routes.rb @@ -1,169 +1,237 @@ -# 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 + end - class NotFoundError < StandardError - end + Group = + Data.define(:name, :views, :children) do + def regexp = nil + def pathname(params) = nil + 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) + 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 = [] + end - PAGE_FILENAME = "page" - LAYOUT_FILENAME = "layout" - NOT_FOUND_FILENAME = "404" + Root = + Data.define(:path, :views, :children) do + def regexp = nil + def pathname(params) = "" + 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) - 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) - ) - ) + 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 do |value| + deep_symbolize_keys(value) + end + else + obj + end end + end - entries.each do |entry| - build_routes( - File.join(root), - routes:, - layouts:, - path: path + [entry], - level: level.succ - ) - end + Route = + Data.define(:regexp, :segments, :views, :layouts) do + def match(request_path) + path, query = request_path.split("?", 2) - 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 + 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 - routes - end + Router = + Data.define(:root_dir, :routes) do + def self.build(root_dir) + new(root_dir, Builder.build(root_dir)) + 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}/" - ] - ) + def match(path) + routes.each do |route| + if match = route.match(path) + 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 do |layout| + set.add(layout) + end + 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) ) - ) + + routes = [] + + build_routes(root) { |route| routes << route if route.views.page } + + routes 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 + + yield( + Route[ + Regexp.compile( + '\A/' + segments.map(&:regexp).compact.join('\/') + '\z' + ), + segments, + node.views, + segments.map(&:views).map(&:layout).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}>[^/]+)" + 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| + 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) + .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..20abf9b2 --- /dev/null +++ b/lib/mayu/routes.test.rb @@ -0,0 +1,68 @@ +#!/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 + skip "TODO: Fix this implementation" + router = setup_router + + match = router.match("/non-existant-route") + assert(match, "match should return some sort of route object") + 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..fee670bf --- /dev/null +++ b/lib/mayu/runtime.rb @@ -0,0 +1,9 @@ +module Mayu + module Runtime + autoload :Engine, File.join(__dir__, "runtime", "engine") + + def self.init(descriptor, runtime_js:) + Engine.new(descriptor, 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..fe272aaf --- /dev/null +++ b/lib/mayu/runtime.test.rb @@ -0,0 +1,152 @@ +#!/usr/env 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", key: :over] if @count > 3), + (H[:p, "Count is below 7", key: :below] 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"]] + ] + + render(descriptor) do |page| + input = find!("input", name: "hello") + + input.type_input("hello world".reverse) do + # page.step + end + + button = find!("button", text: "Increment") + + 10.times do + button.click + sleep 0.05 + 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" + + [ + (H[:head, + H[:title, "TitleThing"], + H[:meta, name: "description", value: "title thing description"], + H[:meta, name: "keywords", value: "title, thing, titlething"], + ] if @enabled), + (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", find!("title").content) + 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 diff --git a/lib/mayu/runtime/descriptors.rb b/lib/mayu/runtime/descriptors.rb new file mode 100644 index 00000000..535a5a15 --- /dev/null +++ b/lib/mayu/runtime/descriptors.rb @@ -0,0 +1,81 @@ +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 + 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 + end + + Comment = Data.define(:content) { alias to_s content } + + 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) + + module Context + Provider = + Data.define(:children, :variables) do + def self.[](*children, **variables) + new(children, variables) + end + end + end + + 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) + if descriptor in Element + 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..616761ce --- /dev/null +++ b/lib/mayu/runtime/dom.rb @@ -0,0 +1,224 @@ +# 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 = false + + 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 do |node| + return node if yield node + end + 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 + + 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}" + 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 do |node| + return node if yield node + end + end + + private + + def internal_attributes + if INJECT_MAYU_ID + { mayu_id: id } + else + {} + end + end + end + + Text = + Data.define(:id, :content) do + def text_content = content.to_s + + def type = "#text" + def to_html = CGI.escape_html(content.to_s) + 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 do |node| + return node if yield node + end + 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 do |node| + return node if yield node + end + end + end + end + end +end diff --git a/lib/mayu/runtime/engine.rb b/lib/mayu/runtime/engine.rb new file mode 100644 index 00000000..63f4ef48 --- /dev/null +++ b/lib/mayu/runtime/engine.rb @@ -0,0 +1,73 @@ +require "async/queue" + +require_relative "vnodes" + +module Mayu + module Runtime + class Engine + attr_reader :runtime_js + + def initialize(descriptor, runtime_js:) + @patches = Async::Queue.new + @runtime_js = runtime_js + @root = VNodes::VDocument.new(descriptor, parent: self) + end + + def marshal_dump + [@runtime_js, @root] + end + + def marshal_load(a) + @runtime_js, @root = a + @patches = Async::Queue.new + end + + def patch(patches) + Array(patches).flatten.each do |patch| + @patches.enqueue(patch) + end + end + + def callback(id, payload) + @root.call_listener(id, payload) + end + + def navigate(path, descriptor, push_state: true) + update(descriptor) + + if push_state + @patches.enqueue(Patches::HistoryPushState[path]) + end + end + + def ping(timestamp) + @patches.enqueue(Patches::Pong[timestamp]) + end + + def update(descriptor) + @root.update(descriptor) + end + + def render + @root.render + end + + def stop + @root.stop + end + + def run(&) + @root.start + + loop do + if patch = @patches.dequeue + yield patch + end + end + ensure + puts "\e[31mSTOPPING ROOT\e[0m" + @root.stop + end + end + end +end diff --git a/lib/mayu/runtime/h.rb b/lib/mayu/runtime/h.rb new file mode 100644 index 00000000..74081c01 --- /dev/null +++ b/lib/mayu/runtime/h.rb @@ -0,0 +1,31 @@ +require_relative "descriptors" + +module Mayu + module Runtime + module H + def self.[](type, *children, **props) + Descriptors::Element[type, *children, **props] + end + + def self.comment(content) + Descriptors::Comment[content.to_s] + end + + def self.callback(component, name) + Descriptors::Callback[component, name] + end + + def self.slot(component, name = nil) + component.__children.slots.fetch(name) do + yield if block_given? + end + end + + # H.provide(theme: "dark") do + # end + def self.set_context(**vars) + Descriptors::Context::Provider[vars, yield] + end + end + end +end diff --git a/lib/mayu/runtime/inline_style.rb b/lib/mayu/runtime/inline_style.rb new file mode 100644 index 00000000..70cc1c48 --- /dev/null +++ b/lib/mayu/runtime/inline_style.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +module Mayu + module Runtime + module InlineStyle + # CSS properties which accept numbers but are not in units of "px". + # Copied from React: + # https://github.com/facebook/react/blob/a7c57268fb71163e4abb5e386c0d0e63290baaae/packages/react-dom/src/shared/CSSProperty.js + UNITLESS_PROPERTIES = %i[ + animation_iteration_count + aspect_ratio + border_image_outset + border_image_slice + border_image_width + box_flex + box_flex_group + box_ordinal_group + column_count + columns + flex + flex_grow + flex_positive + flex_shrink + flex_negative + flex_order + grid_area + grid_row + grid_row_end + grid_row_span + grid_row_start + grid_column + grid_column_end + grid_column_span + grid_column_start + font_weight + line_clamp + line_height + opacity + order + orphans + tab_size + widows + z_index + zoom + fill_opacity + flood_opacity + stop_opacity + stroke_dasharray + stroke_dashoffset + stroke_miterlimit + stroke_opacity + stroke_width + ].freeze + + def self.stringify(properties) + return properties if properties in String + + properties + .map do |property, value| + "#{format_property(property)}:#{format_value(property, value)};" + end + .join + end + + def self.diff(dom_id, old_properties, new_properties) + old_properties + .keys + .union(new_properties.keys) + .map do |property| + old_value = old_properties[property] + new_value = new_properties[property] + + next if old_value == new_value + + unless new_value + yield( + Patches::RemoveCSSProperty[dom_id, format_property(property)] + ) + next + end + + yield( + Patches::SetCSSProperty[ + dom_id, + format_property(property), + format_value(property, new_value) + ] + ) + end + .compact + end + + def self.format_property(property) + property.to_s.tr("_", "-") + end + + def self.format_value(property, value) + should_apply_px?(property, value) ? "#{value}px" : value.to_s + end + + def self.should_apply_px?(property, value) + return false unless Integer === value + return false if UNITLESS_PROPERTIES.include?(property) + return false if property.start_with?("__") + return false if property.start_with?("--") + true + end + end + end +end diff --git a/lib/mayu/runtime/patches.rb b/lib/mayu/runtime/patches.rb new file mode 100644 index 00000000..cf0b1a0e --- /dev/null +++ b/lib/mayu/runtime/patches.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +# DO NOT EDIT! Use script/create_patches.rb to regenerate + +module Mayu + module Runtime + module Patches + Initialize = Data.define(:id_tree) + + CreateTree = Data.define(:html, :tree) + + CreateElement = Data.define(:id, :type) + CreateTextNode = Data.define(:id, :content) + CreateComment = Data.define(:id, :content) + + ReplaceChildren = Data.define(:id, :child_ids) + + RemoveNode = Data.define(:id) + + SetAttribute = Data.define(:id, :name, :value) + RemoveAttribute = Data.define(:id, :name) + + SetClassName = Data.define(:id, :class_name) + + SetListener = Data.define(:id, :name, :listener_id) + RemoveListener = Data.define(:id, :name, :listener_id) + + SetCSSProperty = Data.define(:id, :name, :value) + RemoveCSSProperty = Data.define(:id, :name) + + SetTextContent = Data.define(:id, :content) + ReplaceData = Data.define(:id, :offset, :count, :data) + InsertData = Data.define(:id, :offset, :data) + DeleteData = Data.define(:id, :offset, :count) + + AddStyleSheet = Data.define(:filename) + + Transfer = Data.define(:payload) + + Ping = Data.define(:timestamp) + Pong = Data.define(:timestamp) + + Event = Data.define(:event, :payload) + HistoryPushState = Data.define(:path) + + RenderError = + Data.define(:file, :type, :message, :backtrace, :source, :tree_path) + end + end +end diff --git a/lib/mayu/runtime/vnodes.rb b/lib/mayu/runtime/vnodes.rb new file mode 100644 index 00000000..e09d4805 --- /dev/null +++ b/lib/mayu/runtime/vnodes.rb @@ -0,0 +1,1012 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "securerandom" + +require "async" +require "async/barrier" +require "async/queue" + +require_relative "dom" +require_relative "h" +require_relative "descriptors" +require_relative "inline_style" + +module Mayu + module Runtime + module VNodes + Updater = + Data.define(:task, :queue, :vnode) do + def self.for_vnode(vnode, parent_task: Async::Task.current) + queue = Async::Queue.new + + task = + parent_task.async do + vnode.start_children + + while descriptor = queue.dequeue + vnode.update_sync(descriptor) + end + end + + Updater.new(task, queue, vnode) + end + + def async(&) + task.async { |subtask| yield subtask } + end + + def enqueue(descriptor) + queue.dequeue until queue.empty? + queue.enqueue(descriptor) + end + + def stop = task.stop + + def _dump = nil + def _load = nil + end + + class Base + State = Data.define(:state) {} + + attr_reader :id + attr_reader :descriptor + attr_reader :parent + + def initialize(descriptor, parent:) + @descriptor = descriptor + @parent = parent + @id = SecureRandom.alphanumeric + @id_counter = 0 + @state = State[:initialized] + end + + def marshal_dump + [@id, @id_counter, @descriptor, @parent, @state] + end + + def marshal_load(a) + @id, @id_counter, @descriptor, @parent, @state = a + end + + def running? + !!@updater + end + + def patch(patches) + @parent.patch(patches) + end + + def start_children + end + + def update(descriptor) + @updater ? @updater.enqueue(descriptor) : update_sync(descriptor) + end + + def closest(type) + if type === self + self + else + @parent&.closest(type) + end + end + + def closest!(type) + closest or raise "Could not find node type #{type}" + end + + def start + @updater = Updater.for_vnode(self) + end + + def start_children + end + + def stop + updater, @updater = @updater, nil + updater&.stop + end + + def update_child_ids + @parent.update_child_ids + end + + def traverse(&) + yield self + end + end + + class VAttributes < Base + Listener = + Data.define(:id, :callback) do + def self.[](callback) = new(SecureRandom.alphanumeric(32), callback) + + def to_js = "Mayu.callback(event,'#{id}')" + + def call(payload) + method = callback.component.method(callback.method_name) + + case method.parameters + in [] + method.call + in [[:req, Symbol]] + method.call(payload) + in [[:keyrest, Symbol]] + method.call(**payload) + end + end + end + + def initialize(...) + super + @attributes = {} + @attributes = update_attributes(@descriptor.props) + ensure + @initialized = true + end + + def marshal_dump + [super, @attributes, @initialized] + end + + def marshal_load(a) + a => [a, attributes, initialized] + super(a) + @attributes = attributes + @initialized = initialized + end + + def update(descriptor) + @descriptor = descriptor + @attributes = update_attributes(@descriptor.props) + end + + def render + @attributes + end + + private + + def patch(...) + super if @initialized + end + + def update_attributes(props) + @attributes + .keys + .union(props.keys) + .map do |prop| + old = @attributes[prop] + new = props[prop] || nil + + if prop == :style + update_style(prop, old, new) + elsif prop.start_with?("on") + update_callback(prop, old, new) + else + update_attribute(prop, old, new) + end + end + .compact + .to_h + end + + def update_style(prop, old, new) + unless new + patch(Patches::RemoveAttribute[@parent.id, :style]) + return + end + + InlineStyle.diff(@parent.id, old || {}, new) { patch(_1) } + + [prop, new] + end + + def update_callback(prop, old, new) + if old + return prop, old if old.callback.same?(new) + + @root.remove_listener(old) + + unless new + patch(Patches::RemoveAttribute[@parent.id, prop]) + return + end + end + + return unless new + + listener = closest(VDocument).add_listener(Listener[new]) + patch(Patches::SetAttribute[@parent.id, prop, listener.to_js]) + + [prop, listener] + end + + def update_attribute(prop, old, new) + unless new + patch(Patches::RemoveAttribute[@parent.id, new.to_s]) + return + end + + return prop, new.to_s if old.to_s == new.to_s + + if prop == :class + patch(Patches::SetClassName[@parent.id, new.to_s]) + else + patch(Patches::SetAttribute[@parent.id, prop, new.to_s]) + end + + [prop, new.to_s] + end + end + + class VElement < Base + def initialize(...) + super + @children = VChildren.new(@descriptor.children, parent: self) + @child_ids = @children.child_ids + @attributes = VAttributes.new(@descriptor, parent: self) + end + + def marshal_dump + [super, @children, @child_ids, @attributes] + end + + def marshal_load(a) + a => [a, children, child_ids, attributes] + super(a) + @children = children + @child_ids = child_ids + @attributes = attributes + end + + def traverse(&) + yield self + @children.traverse(&) + end + + def child_ids = [id] + + def start_children + @children.start + end + + def insert + patch(render.patch_insert) + end + + def remove + patch(render.patch_remove) + end + + def render + tag_name = self.tag_name + + DOM::Element[@id, tag_name, *@children.render, **@attributes.render] + end + + def update_sync(descriptor) + @descriptor = descriptor + @attributes.update(descriptor) + @children.update(descriptor.children) + end + + def tag_name = + @descriptor.type.to_s.downcase.delete_prefix("__").tr("_", "-") + + def update_child_ids + @updater&.async do + new_child_ids = @children.child_ids.flatten + + unless new_child_ids == @child_ids + @child_ids = new_child_ids + patch(Patches::ReplaceChildren[id, @child_ids]) + end + end + end + end + + class VText < Base + def update_sync(descriptor) + return if @descriptor.to_s === descriptor.to_s + @descriptor = descriptor + patch(Patches::SetTextContent[id, @descriptor.to_s]) + end + + def child_ids = [@id] + + def insert + patch(render.patch_insert) + end + + def remove + patch(render.patch_remove) + end + + def render + DOM::Text[@id, @descriptor.to_s] + end + end + + class VComment < Base + def update_sync(descriptor) + @descriptor = descriptor + end + + def child_ids = [@id] + + def insert + patch(render.patch_insert) + end + + def render + DOM::Comment[@id, @descriptor.to_s] + end + end + + class VChildren < Base + STRING_SEPARATOR = Descriptors::Comment[""] + + Updated = Data.define(:node, :descriptor) + Created = Data.define(:node) + UpdateResult = Data.define(:children, :removed) + + attr_reader :children + + def initialize(...) + super + update_children([], @descriptor) + end + + def marshal_dump + [super, @children] + end + + def marshal_load(a) + a => [a, children] + super(a) + @children = children + end + + def traverse(&block) + yield self + @children.each { |child| child.traverse(&block) } + end + + def child_ids + @children.map(&:child_ids).flatten + end + + def start_children + @children.each { |child| Async { child.start } } + end + + def update_sync(descriptor) + @descriptor = descriptor + update_children(@children, @descriptor) + end + + def insert = @children.map { _1.insert } + def remove = @children.map { _1.remove } + def render = @children.map { _1.render } + + private + + def update_children(old_children, descriptors) + diff = diff_children(old_children, normalize_descriptors(descriptors)) + + created = [] + + @children = + diff.children.map do |update| + case update + in Updated[node:, descriptor:] + node.update(descriptor) + node + in Created[node:] + created << node + node + end + end + + if running? + created.each do |node| + node.insert + node.start + end + end + + diff.removed.each do |removed| + removed.remove + removed.stop + end + + update_child_ids + + # puts "\e[31m#{diff.removed.map(&:child_ids).join(", ")}\e[0m" + # puts "\e[33m#{diff.children.select { Updated === _1 }.map(&:node).map(&:child_ids).join(", ")}\e[0m" + # puts "\e[32m#{diff.children.select { Created === _1 }.map(&:node).map(&:child_ids).join(", ")}\e[0m" + # + + @children + end + + def diff_children(old_children, descriptors) + source = old_children.dup + + new_children = + descriptors.map do |descriptor| + if index = + source.index { Descriptors.same?(descriptor, _1.descriptor) } + found = source.delete_at(index) + Updated[found, descriptor] + else + Created[VAny.new(descriptor, parent: self)] + end + end + + UpdateResult[new_children, source] + end + + private + + def normalize_descriptors(descriptors) + Array(descriptors) + .flatten + .map { Descriptors.descriptor_or_string(_1) } + .compact + .then { insert_comments_between_strings(_1) } + end + + def insert_comments_between_strings(descriptors) + [nil, *descriptors].each_cons(2) + .map do |prev, descriptor| + case [prev, descriptor] + in [String, String] + [STRING_SEPARATOR, descriptor] + else + descriptor + end + end + .flatten + end + end + + class VSlot < Base + def initialize(...) + super + @children = VChildren.new(get_children, parent: self) + end + + def marshal_dump + [super, @children] + end + + def marshal_load(a) + a => [a, children] + super(a) + @children = children + end + + def traverse(&) + yield self + @children.traverse(&) + end + + def child_ids = @children.child_ids + + def start_children = @children.start + def insert = @children.insert + def remove = @children.remove + def render = @children.render + + def update_sync(descriptor) + @descriptor = descriptor + @children.update(get_children) + end + + private + + def get_children + component = closest(VComponent) + name = @descriptor.props[:name] + component.descriptor.children.slots[name] + end + end + + class VAny < Base + def initialize(...) + super + @type = node_type_from_descriptor(@descriptor) + @child = @type.new(@descriptor, parent: self) + # puts "Creating #{@type} for #{@descriptor}" + end + + def marshal_dump + [super, @type, @child] + end + + def marshal_load(a) + a => [a, type, child] + super(a) + @type = type + @child = child + end + + def traverse(&) + yield self + @child.traverse(&) + end + + def child_ids = @child.child_ids + + def insert = @child.insert + def remove = @child.remove + def render = @child.render + + def start_children = @child.start + def update_sync(descriptor) = @child.update(descriptor) + + private + + def node_type_from_descriptor(descriptor) + case descriptor + in Descriptors::Element[type: :slot] + VSlot + in Descriptors::Element[type: :head] + VHead + in Descriptors::Element[type: :body] + VBody + in Descriptors::Element[type: Proc] + VStateless + in Descriptors::Element[type: Class] + VComponent + in Descriptors::Element + VElement + in Descriptors::Comment + VComment + else + VText + end + end + end + + class VHead < Base + def initialize(...) + super + add_to_document + end + + def traverse + yield self + end + + def insert = add_to_document + def remove = remove_from_document + def render = nil + def start_children = nil + def child_ids = [] + + def children = @descriptor.children + + def update_sync(descriptor) + unless @descriptor.children == descriptor.children + @descriptor = descriptor + add_to_document + end + end + + private + + def add_to_document + closest(VDocument).add_head(self) + end + + def remove_from_document + closest(VDocument).remove_head(self) + end + end + + class VBody < VElement + def initialize(descriptor, parent:) + super(inject_mayu_ping(descriptor), parent:) + end + + def update_sync(descriptor) + super(inject_mayu_ping(descriptor)) + end + + private + + def inject_mayu_ping(descriptor) + descriptor.with( + children: [*descriptor.children, H[:mayu_ping, ping: "N/A"]] + ) + end + end + + class VStateless < Base + def initialize(...) + super + @children = VChildren.new(rerender, parent: self) + end + + def marshal_dump + [*super, @children] + end + + def marshal_load(a) + a => [*a, children] + super(a) + @children = children + end + + def insert = @children.insert + def render = @children.render + def remove = @children.remove + def child_ids = @children.child_ids + def start_children = @children.start_children + + def update_sync(descriptor) + @descriptor = descriptor + @children.update(rerender) + end + + private + + def rerender + @descriptor.type.call(**@descriptor.props) + end + end + + class VComponent < Base + class Context + def initialize(parent: nil) + @vars = {} + @parent = parent + @notification = Async::Notification.new + end + + attr_reader :parent + + def [](var) + @vars.fetch(var) { @parent[var] if parent } + end + + def []=(var, value) + return if @vars[var] == value + @vars[var] = value + @notification.signal(var) + end + + def wait + @notification.wait + end + + def marshal_dump + [@vars, @parent] + end + + def marshal_load(a) + @vars, @parent = a + @notification = Async::Notification.new + end + end + + def initialize(...) + super + klass = @descriptor.type + + if mod = get_mod + mod.assets.each do |asset| + if asset.content_type == "text/css" + closest(VDocument).add_stylesheet(asset.filename) + end + end + end + + @context = Context.new(parent: @parent.closest(self.class)&.context) + + @instance = klass.allocate + + @instance.instance_variable_set(:@__props, @descriptor.props.freeze) + @instance.instance_variable_set(:@__context, @context) + @instance.instance_variable_set(:@__children, @descriptor.children.freeze) + + @instance.send(:initialize) + @children = VChildren.new(render_children, parent: self) + end + + attr_reader :context + + def marshal_dump + [super, @instance, @children, @context] + end + + def marshal_load(a) + a => [a, instance, children, context] + super(a) + @instance = instance + @children = children + @context = context + end + + def traverse(&) + yield self + @children.traverse(&) + end + + def insert = @children.insert + def render = @children.render + def remove = @children.remove + def child_ids = @children.child_ids + + def update_sync(descriptor) + @descriptor = descriptor + + old_props = @instance.instance_variable_get(:@__props) + old_children = @instance.instance_variable_get(:@__children) + + @instance.instance_variable_set(:@__children, @descriptor.children.freeze) + @instance.instance_variable_set(:@__props, @descriptor.props.freeze) + + @children.update(render_children) + end + + def start_children + Async do + barrier = Async::Barrier.new + queue = Async::Queue.new + + @instance.define_singleton_method(:rerender!) do + queue.enqueue(:rerender) + end + + barrier.async do + while x = queue.dequeue + @children.update(render_children) if x == :rerender + end + end + + barrier.async do + loop do + @context.wait + queue.enqueue(:rerender) + end + end + + barrier.async do + puts "\e[1mMounting #{component_type_name}\e[0m" + + handle_errors { @instance.mount } + end + + barrier.async { @children.start } + + barrier.wait + ensure + barrier.stop + puts "\e[2mUnmounting #{component_type_name}\e[0m" + @instance.unmount + end + end + + private + + def handle_errors + yield + rescue => e + mod = get_mod + raise unless mod + + puts mod.source_map.format_exception(e, mod.path) + + patch( + Patches::RenderError[ + mod.path, + e.class.name, + e.message, + e.backtrace, + mod.source_map.input, + [] + ] + ) + + nil + end + + def render_children + handle_errors { @instance.render } + end + + def get_mod + if module_path = @descriptor.type.module_path + Modules::System.current.get_mod(module_path) + end + end + + def component_type_name + klass = @instance.class + + name = (klass.module_path if klass.respond_to?(:module_path)).to_s + + name.empty? ? klass.name : name + end + end + + class VDocument < Base + class Html < Mayu::Component::Base + def render + H[:html, H[:slot]] + end + end + + class Head < Mayu::Component::Base + def render + grouped = + @__props[:descriptors] + .group_by do |descriptor| + case descriptor + in Descriptors::Element[type: :meta, props: { charset: }] + puts "\e[31m%meta(charset=#{charset.inspect}) ignored\e[0m" + nil + in Descriptors::Element[type: :meta, props: { name: }] + "meta-name-#{name}" + in Descriptors::Element[type: :meta, props: { property: }] + "meta-property-#{name}" + in Descriptors::Element[type: :title] + "title" + else + puts "\e[31mUnsupported %head node: #{descriptor.inspect}\e[0m" + nil + end + end + .except(nil) + .transform_values(&:last) + + title = grouped.delete("title") + tags = grouped.map { |key, element| element.with(key:) } + + styles = + @__props[:styles].map do |filename| + H[ + :link, + key: filename, + rel: "stylesheet", + href: "/.mayu/assets/#{filename}" + ] + end + + H[ + :__head, + H[:meta, charset: "utf-8"], + H[ + :script, + type: "module", + src: @__props[:runtime_js], + async: true, + key: "main_js" + ], + title, + *styles, + *tags + ] + end + end + + H = Mayu::Runtime::H + + def initialize(...) + super(...) + + @listeners = {} + @styles = Set.new + @head = Set.new + @html = VComponent.new(H[Html, @descriptor], parent: self) + end + + def add_head(vnode) + @head.add(vnode) + update_head + end + + def remove_head(vnode) + @head.delete(vnode) + update_head + end + + def add_stylesheet(filename) + if @styles.add?(filename) + puts "\e[3;36mAdding stylesheet: #{filename}\e[0m" + update_head + end + end + + def add_listener(listener) + @listeners.store(listener.id, listener) + end + + def remove_listener(listener) + @listeners.delete(listener.id) + end + + def call_listener(id, payload) + case @listeners.fetch(id).call(payload) + in Patches::RenderError => e + patch(e) + else + nil + end + end + + def marshal_dump + [super, @html, @listeners, @styles, @head] + end + + def marshal_load(a) + a => [a, html, listeners, styles, head] + super(a) + @html = html + @listeners = listeners + @styles = styles + @head = head + end + + def update_child_ids + end + + def start_children + @html.start + end + + def update_sync(descriptor) + @descriptor = descriptor + @html.update(init_html) + end + + def closest(type) + if type === self + self + else + nil + end + end + + def render + @html.update(init_html) + DOM::Document[*@html.render, id: @id] + end + + private + + def init_html + H[Html, init_head, @descriptor] + end + + def init_head + H[ + Head, + runtime_js: @parent.runtime_js, + styles: @styles, + descriptors: @head.map(&:children).flatten.compact + ] + end + + def update_head + @html&.traverse do |v| + if v.descriptor in Descriptors::Element + if v.descriptor.type == Head + v.update(init_head) + break + end + end + end + end + end + end + end +end diff --git a/lib/mayu/server.rb b/lib/mayu/server.rb index 8d36c499..5dd0ff3e 100644 --- a/lib/mayu/server.rb +++ b/lib/mayu/server.rb @@ -1,63 +1,80 @@ -# typed: strict +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 -require "bundler/setup" -require "sorbet-runtime" require "async" -require "async/container" -require "async/http/server" -require "async/http/endpoint" -require "protocol/http/body/file" -require "async/io/host_endpoint" -require "async/io/shared_endpoint" -require "async/io/ssl_endpoint" require "async/io/trap" -require "localhost" -require "mime/types" -require_relative "environment" -require_relative "session" -require_relative "configuration" -require_relative "colors" -require_relative "server/controller" +require "async/barrier" +require "async/queue" +require "async/http/endpoint" +require "async/http/protocol/response" +require "async/http/server" + +require_relative "server/app" module Mayu - module Server - extend T::Sig + class Server + def initialize(config) + @uri = URI.parse(config.server.listen) + @app = App.new(config) - sig { params(config: Configuration).void } - def self.start_dev(config) = start(config) + endpoint = Async::HTTP::Endpoint.new(@uri) - sig { params(config: Configuration).void } - def self.start_prod(config) = start(config) + if config.server.self_signed_cert? + endpoint = apply_local_certificate(endpoint) + end - sig { params(config: Configuration).void } - def self.start(config) - uri = config.server.uri - ssl_context = setup_self_signed_cert(config) - endpoint = Async::HTTP::Endpoint.new(uri, ssl_context:, reuse_port: true) + @server = + Async::HTTP::Server.new( + @app, + endpoint, + scheme: @uri.scheme, + protocol: Async::HTTP::Protocol::HTTP2 + ) + end - Configuration.log_config(config) - Process.setproctitle("mayu #{config.mode} file://#{config.root} #{uri}") + def run(task: Async::Task.current) + interrupt = Async::IO::Trap.new(:INT) - # Metrics.setup(config) if config.metrics.enabled + task.async do + interrupt.install! + puts "\e[3m Starting server on #{@uri} \e[0m" - Controller.new(config:, endpoint:).run - end + barrier = Async::Barrier.new + + listeners = @server.run - sig do - params(config: Configuration).returns(T.nilable(OpenSSL::SSL::SSLContext)) + interrupt.wait + Console.logger.info("Got interrupt") + + @app.stop + interrupt.default! + rescue Errno::EADDRINUSE => e + puts format("\e[3;31m %s \e[0m", e.message) + exit 1 + ensure + Console.logger.info("Stopped server") + end end - def self.setup_self_signed_cert(config) - return unless config.server.self_signed_cert - authority = Localhost::Authority.fetch(config.server.host) + private - authority.server_context.tap do |context| - context.alpn_select_cb = lambda { |_| "h2" } - lambda { |protocols| protocols.include?("h2") ? "h2" : nil } + def apply_local_certificate(endpoint) + require "localhost" + require "async/io/ssl_endpoint" - context.alpn_protocols = ["h2"] - context.session_id_context = "mayu" + authority = Localhost::Authority.fetch(endpoint.hostname) + + context = authority.server_context + context.alpn_select_cb = ->(protocols) do + protocols.include?("h2") ? "h2" : nil end + + context.alpn_protocols = ["h2"] + context.session_id_context = "mayu" + + Async::IO::SSLEndpoint.new(endpoint, ssl_context: context) end end end diff --git a/lib/mayu/server/app.rb b/lib/mayu/server/app.rb index a1c9b1f3..0dcdd636 100644 --- a/lib/mayu/server/app.rb +++ b/lib/mayu/server/app.rb @@ -1,498 +1,343 @@ -# typed: strict -# frozen_string_literal: true +require "pry" -require "async/variable" -require_relative "../event_stream" -require_relative "file_server" -require_relative "errors" +require_relative "request_refinements" +require_relative "cookies" +require_relative "session_store" +require_relative "event_stream" +require_relative "static_files" + +require_relative "../environment" +require_relative "../session" module Mayu - module Server + class Server class App - extend T::Sig - - DEV_ASSETS_TIMEOUT_SECONDS = 4 - DEV_ASSETS_RETRY_AFTER_SECONDS = 2 - PING_INTERVAL = 2 # seconds - NANOID_RE = /[\w-]{21}/ + using RequestRefinements - MIME_TYPES = - T.let( + ALLOW_HEADERS = + Ractor.make_shareable( { - eventstream: "application/vnd.mayu.eventstream", - session: "application/vnd.mayu.session" - }, - T::Hash[Symbol, String] + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": %w[ + content-type + accept + accept-encoding + ].join(", ") + } ) - sig { params(environment: Environment).void } - def initialize(environment:) - @environment = environment - @metrics = T.let(environment.metrics, AppMetrics) - @barrier = T.let(Async::Barrier.new, Async::Barrier) - @stop = T.let(Async::Variable.new, Async::Variable) - @sessions = T.let({}, T::Hash[String, Session]) - - @runtime_assets = - T.let(FileServer.new(@environment.js_runtime_path), FileServer) - @static_assets = - T.let(FileServer.new(@environment.path(:assets)), FileServer) - end - - sig { void } - def clear_expired_sessions! - old_size = @sessions.size + ASSET_CACHE_CONTROL = [ + "public", + "max-age=#{7 * 24 * 60 * 60}", + "immutable" + ].join(", ").freeze - @sessions.delete_if do |id, session| - next unless session.expired?(20) + def initialize(config) + @stopping = false + @environment = Environment.from_config(config) + @sessions = SessionStore.new + @client_files = StaticFiles.new(@environment.client_path) - Console.logger.warn(self, "Session #{session.id} timed out") - session.stop! - @metrics.session_timeout_count.increment - true - end + system = Modules::System.current - unless @sessions.size == old_size - Console.logger.warn(self, "Session count: #{@sessions.size}") + @environment.router.all_templates.each do |template| + system.import(File.join("/pages", template)) end - @metrics.session_count.set(@sessions.size) + @sessions.start_cleanup_task end - sig { void } - def stop - @stop.resolve(true) - @barrier.wait - Console.logger.info(self, "Stopped sessions") + def call(request) + puts "\e[3;33m #{request.method} #{request.path} \e[0m" + + return text_response(503, "Server is stopping") if @stopping + + case request + in path: "/favicon.ico" + handle_favicon(request) + in { path: "/.mayu", method: "OPTIONS" } + handle_options(request) + in path: %r{\A\/.mayu\/runtime\/.+\.js(\.map)?} + handle_script(request) + in { path: %r{\A/\.mayu/assets/(.+)\z}, method: "GET" } + handle_asset(request) + in { + method: "GET", + path: %r{\/.mayu\/session\/(?[[:alnum:]]+)} + } + handle_session_resume(request, $~[:session_id]) + in { + method: "POST", + path: %r{\/.mayu\/session\/(?[[:alnum:]]+)} + } + handle_session_transfer(request, $~[:session_id]) + in { + path: %r{\/.mayu\/session\/(?[[:alnum:]]+)}, + method: "PATCH" + } + handle_session_event(request, $~[:session_id]) + in method: "GET" + handle_session_start(request) + else + handle_404(request) + end + rescue SessionStore::SessionNotFoundError + error_response(403, "Session not found", **origin_header(request)) + rescue SessionStore::InvalidTokenError + error_response(403, "Invalid token", **origin_header(request)) + rescue Cookies::TokenCookieNotSetError + error_response(403, "Token cookie not set", **origin_header(request)) + rescue Errno::ENOENT => e + text_response( + 404, + "Resource not found: #{request.path}", + **origin_header(request) + ) + rescue => e + Console.logger.error(self, e) + error_response(403, "Internal server error", **origin_header(request)) end - sig { void } - def close - @barrier.wait + def stop + @stopping = true + puts "#{self.class}##{__method__}" + @sessions.transfer_all end - sig { returns(Integer) } - def time_ping_value - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond).to_i & - 0x0fffffff - end + private - sig do - params(request: Protocol::HTTP::Request).returns( - Protocol::HTTP::Response - ) - end - def call(request) - # The following line generates very noisy logs, - # but can be useful when debugging. - # Console.logger.info(self, "#{request.method} #{request.path}") + # Mayu - Errors.handle_exceptions { handle_request(request) } + def handle_options(request) + response(204, **ALLOW_HEADERS, **origin_header(request)) end - sig { void } - def rerender - @sessions.values.each(&:rerender) - end + def handle_script(request) + path = + Pathname.new(request.path).relative_path_from("/.mayu/runtime").to_s - sig do - params( - id: String, - request: Protocol::HTTP::Request, - resume: T::Boolean - ).returns(Session) - end - def get_session(id, request, resume: false) - session = load_session(id, resume ? request.read.to_s : "") - cookie_value = get_token_cookie_value(request) + file = @client_files.get(path) - if session.authorized?(cookie_value) - session - else - raise Errors::UnauthorizedSessionCookie, - "session with id #{id} had wrong value #{cookie_value.inspect}" + unless file + return( + response( + 404, + "Resource not found: #{request.path}", + "content-type": "text/plain; charset=utf-8", + **origin_header(request) + ) + ) end - end - sig do - params(request: Protocol::HTTP::Request).returns( - Protocol::HTTP::Response + response( + 200, + file.encoded_content.content, + **file.headers, + **origin_header(request) ) end - def handle_request(request) - # FIXME: raise_if_shutting_down! should only prevent the following: - # * starting new sessions - # * updating sessions that have been transferred - # * updating sessions that have been paused for transferring - raise_if_shutting_down! - - case request.path.delete_prefix("/").split("/") - in ["__mayu", "session", NANOID_RE => session_id, *rest] - handle_session_post(request, session_id, rest) - in ["index.js"] - body = File.read(File.join(__dir__, "client", "dist", "live.js")) - Protocol::HTTP::Response[ - 200, - { "content-type": "application/javascript" }, - [body] - ] - in ["robots.txt"] - Protocol::HTTP::Response[ - 200, - { "content-type" => "text/plain; charset=utf-8" }, - File.read(File.join(@environment.root, "app", "robots.txt")) - ] - in ["favicon.ico"] - # Idea: Maybe it would be possible to create - # an asset from the favicon and redirect to the asset? - Protocol::HTTP::Response[ - 200, - { "content-type" => "image/png" }, - Protocol::HTTP::Body::File.open( - File.join(@environment.root, "app", "favicon.png") - ) - ] - in ["__mayu", "status"] - Protocol::HTTP::Response[200, {}, "ok"] - in ["__mayu", "runtime", *path] - accept_encodings = request.headers["accept-encoding"].to_s.split(", ") - - filename = File.join(*path) - - if filename == "entries.json" - return Protocol::HTTP::Response[403, {}, ["forbidden"]] - end - - @runtime_assets.serve(filename, accept_encodings:) - in ["__mayu", "static", filename] - if @environment.config.server.generate_assets - begin - @environment.resources.wait_for_asset( - filename, - timeout: DEV_ASSETS_TIMEOUT_SECONDS - ) - rescue Async::TimeoutError => e - Console.logger.warn( - self, - "Asset #{filename} could not be generated in time" - ) - return( - Protocol::HTTP::Response[ - 503, - { "retry-after" => DEV_ASSETS_RETRY_AFTER_SECONDS }, - ["asset could not be generated in time"] - ] - ) - end - end - accept_encodings = request.headers["accept-encoding"].to_s.split(", ") + def handle_asset(request) + asset = Modules::System.current.get_asset(File.basename(request.path)) - @static_assets.serve(filename, accept_encodings:) - in ["__mayu", *] - raise Errors::FileNotFound, - "Resource not found at: #{request.method} #{request.path}" - in [*] if request.method == "GET" - raise_if_shutting_down! + return text_response(404, "file not found") unless asset - handle_session_init(request) - else - Protocol::HTTP::Response[404, {}, ["not found"]] - end + response( + 200, + asset.encoded_content.content, + **asset.headers, + **origin_header(request) + ) end - sig { void } - def raise_if_shutting_down! - raise Errors::ServerIsShuttingDown if @stop.resolved? + def handle_favicon(request) + send_file( + File.read(File.join(@environment.app_dir, "favicon.png")), + "image/png", + origin_header(request) + ) end - sig do - params( - request: Protocol::HTTP::Request, - session_id: String, - path: T::Array[String] - ).returns(Protocol::HTTP::Response) + def handle_404(request) + text_response(404, "file not found") end - def handle_session_post(request, session_id, path) - raise Errors::InvalidMethod unless request.method == "POST" - if ["resume"] === path - body = Async::HTTP::Body::Writable.new - session = get_session(session_id, request, resume: true) - run_event_stream(session, body:) + # Session - return( - Protocol::HTTP::Response[ - 200, - { "content-type": MIME_TYPES[:eventstream] }, - body - ] - ) - end + def handle_session_start(request) + session = Session.new( + request_info: Session::RequestInfo.from_request(request), + environment: @environment + ) - session = get_session(session_id, request, resume: false) - session.activity! - - case path - in ["init"] - body = Async::HTTP::Body::Writable.new - run_event_stream(session, body:) - Protocol::HTTP::Response[ - 200, - { "content-type": MIME_TYPES[:eventstream] }, - body - ] - in ["ping"] - body = JSON.parse(request.read.to_s) - pong = body["pong"].to_f - ping = body["ping"] - time = time_ping_value - server_pong = time_ping_value - body["pong"].to_f - headers = { - "content-type": "application/json", - "set-cookie": set_token_cookie_value(session) - } - session.log.push( - :pong, - pong: ping, - server: server_pong, - region: @environment.config.instance.region, - instance: @environment.config.instance.alloc_id.split("-", 2).first - ) - Protocol::HTTP::Response[200, headers, [JSON.generate(ping)]] - in ["navigate"] - @environment.metrics.session_navigate_count.increment() - path = request.read.force_encoding("utf-8") - session.handle_callback("navigate", { path: }) - headers = { - "content-type": "text/plain", - "set-cookie": set_token_cookie_value(session), - "x-request-time": request.headers["x-request-time"] - } - Protocol::HTTP::Response[200, headers, ["ok"]] - in ["callback", String => callback_id] - session.handle_callback( - callback_id, - JSON.parse(request.read, symbolize_names: true) - ) - headers = { - "content-type": "text/plain", - "set-cookie": set_token_cookie_value(session), - "x-request-time": request.headers["x-request-time"] - } - Protocol::HTTP::Response[200, headers, ["ok"]] - end - end + @sessions.store(session) + + body = session.render.to_html - sig do - params(request: Protocol::HTTP::Request).returns( - Protocol::HTTP::Response + response( + 200, + body, + "content-type": "text/html; charset=utf-8", + "x-mayu-session-id": session.id, + **Cookies.set_token_cookie_header(session) ) end - def handle_session_init(request) - Console.logger.info(self) { "Init session: #{request.path}" } - - validate_header!( - request.headers, - "sec-fetch-mode", - "navigate" - ) do |value| - raise Errors::InvalidSecFetchHeader, - "Expected sec-fetch-mode to equal navigate but got #{value.inspect}" - end - validate_header!( - request.headers, - "sec-fetch-dest", - "document" - ) do |value| - raise Errors::InvalidSecFetchHeader, - "Expected sec-fetch-dest to equal document but got #{value.inspect}" + def handle_session_transfer(request, session_id) + encrypted_session = request.read.to_s + session = Session.resume_transferred(@environment, encrypted_session) + + unless session.id == session_id + return error_response(403, "invalid session id") end + # TODO: Validate token + + @sessions.store(session) + + run_session_stream(request, session) + rescue Mayu::EncryptedMarshal::ExpiredError + error_response(403, "expired") + rescue Mayu::EncryptedMarshal::DecryptError => e + Console.logger.error(self, e) + error_response(403, "cipher error") + end + + def handle_session_resume(request, session_id) session = - Session.new( - environment: @environment, - path: request.path, - headers: request.headers.to_h.freeze + @sessions.authenticate( + session_id, + Cookies.get_token_cookie_value(request) ) - body = Async::HTTP::Body::Writable.new - headers = { - "content-type" => "text/html; charset=utf-8", - "cache" => "no-cache" - } + return session_not_found_response unless session - accept_encodings = request.headers["accept-encoding"].to_s.split(", ") + run_session_stream(request, session) + end - writer = - if accept_encodings.include?("br") - headers["content-encoding"] = "br" - Brotli::Writer.new(body) - else - body - end + def run_session_stream(request, session) + headers = { + "content-type": EventStream::CONTENT_TYPE, + "content-encoding": EventStream::CONTENT_ENCODING, + "set-cookie": Cookies.set_token_cookie_value(session), + **origin_header(request) + } - session.initial_render(writer) => { stylesheets: } + body = EventStream::Writer.new - headers["link"] = [ - "; rel=preload; as=script; crossorigin=same-origin; fetchpriority=high", - *stylesheets.map { "<#{_1}>; rel=preload; as=style" } - ].join(", ") + body.write( + Runtime::Patches::Initialize[session.render.id_node.serialize] + ) - headers["set-cookie"] = set_token_cookie_value(session) + Async do |task| + session.run do |patch| + body.write(patch) - @sessions.store(session.id, session) + if patch in Runtime::Patches::Transfer + body.close + task.stop + end + end - @environment.metrics.session_init_count.increment() + body.wait + ensure + task.stop + end Protocol::HTTP::Response[200, headers, body] end - sig do - params(session: Session, body: Async::HTTP::Body::Writable).returns( - Async::Task + def session_not_found_response(request) + response( + 404, + "Session not found/invalid token", + **origin_header(request), + "content-type": "text/plain" ) end - def run_event_stream(session, body:) - @barrier.async do |task| - session.activity! - stream = EventStream::Writable.new(body) + def handle_session_event(request, session_id) + session = + @sessions.authenticate( + session_id, + Cookies.get_token_cookie_value(request) + ) - Console.logger.info(self, "Streaming events to session #{session.id}") + return session_not_found_response unless session - barrier = Async::Barrier.new - stop_notification = Async::Notification.new + Async do + session.wait + ensure + request.body.close + end - task.async do - @stop.wait - stop_notification.signal + EventStream.each_incoming_message(request) do |message| + case message + in { type: "callback", payload: { id:, event: }, ping: } + session.handle_ping(ping) + session.handle_callback(id, event) + in { + type: "navigate", + payload: { href:, pushState: push_state }, + ping: + } + session.handle_ping(ping) + session.handle_navigate(href, push_state:) + in { type: "ping", ping: } + session.handle_ping(ping) end - - session_task = - barrier.async do - session - .run do |message| - case message - in [event, payload] - session.log.push(:"session.#{event}", payload) - end - end - .wait - ensure - stop_notification.signal - end - - ping_task = - barrier.async do - loop do - sleep PING_INTERVAL - session.log.push(:ping, time_ping_value) - end - end - - message_task = - barrier.async do |subtask| - loop { stream.write(session.log.pop.to_a) } - ensure - barrier.stop - end - - stop_notification.wait - - barrier.stop - perform_transfer(session, stream) - task.stop end - end - private - - sig do - params( - headers: Protocol::HTTP::Headers, - name: String, - expected_value: String, - block: T.proc.params(arg0: String).void - ).void - end - def validate_header!(headers, name, expected_value, &block) - if actual_value = headers[name] - yield actual_value.to_s unless actual_value.to_s == expected_value - end + json_response( + 204, + "ok", + "set-cookie": Cookies.set_token_cookie_value(session), + **origin_header(request) + ) end - sig { params(session_id: String, body: String).returns(Session) } - def load_session(session_id, body) - if body.empty? - return( - @sessions.fetch(session_id) do - raise Errors::SessionNotFound, "Session not found: #{session_id}" - end - ) - end + # Helpers - @environment.encrypted_marshal.load(body) => String => dumped - session = Session.restore(environment: @environment, dumped:) - @sessions.store(session.id, session) + def text_response(status, *bodies, **headers) + response( + status, + *bodies, + "content-type": "text/plain; charset-utf-8", + **headers + ) end - sig do - params( - session: Session, - stream: EventStream::Writable, - task: Async::Task - ).void + def error_response(status, error, **headers) + json_response(status, { error: }, **headers) end - def perform_transfer(session, stream, task: Async::Task.current) - return if stream.closed? - - Console.logger.info(self, "Session #{session.id}: Transferring") - - stream.write( - EventStream::Message.new( - :"session.transfer", - EventStream::Blob.new( - @environment.encrypted_marshal.dump( - Session::SerializedSession.dump_session(session) - ) - ) - ).to_a - ) - # Sleep a little bit so that the message - # gets sent before the body closes... - # This is not ideal though, it would be better - # maybe if the client would acknowledge that they - # have received it? - sleep 0.1 - stream.close + def json_response(status, json, **headers) + response( + status, + JSON.generate(json), + "content-type": "application/json", + **headers + ) 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 - end + def response(status, *bodies, **headers) + Protocol::HTTP::Response[status, headers, bodies] + end - raise Errors::CookieNotSet + def origin_header(request) + { "access-control-allow-origin": request.headers["origin"] } 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}", - "path=/__mayu/session/#{session.id}/", - "expires=#{expires.httpdate}", - "secure", - "HttpOnly", - "SameSite=Strict" - ].join("; ") + def send_file(content, content_type, headers = {}) + Protocol::HTTP::Response[ + 200, + { + "content-type": content_type, + "content-length": content.bytesize, + **headers + }, + [content] + ] end end end diff --git a/lib/mayu/server/controller.rb b/lib/mayu/server/controller.rb deleted file mode 100644 index 92d31709..00000000 --- a/lib/mayu/server/controller.rb +++ /dev/null @@ -1,152 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require "async/container/controller" -require "async/io/shared_endpoint" -require "async/io/trap" -require_relative "app" -require_relative "../metrics" -require_relative "../app_metrics" - -module Mayu - module Server - class Controller < Async::Container::Controller - extend T::Sig - - sig { returns(Async::Container::Generic) } - def create_container - Async::Container::Hybrid.new - end - - sig do - params( - config: Configuration, - endpoint: Async::HTTP::Endpoint, - options: T.untyped - ).void - end - def initialize(config:, endpoint:, **options) - super(**options) - @config = config - @endpoint = endpoint - @interrupt_trap = T.let(Async::IO::Trap.new(:INT), Async::IO::Trap) - @bound_endpoint = T.let(nil, T.nilable(Async::IO::SharedEndpoint)) - end - - sig { void } - def start - Console.logger.info(self, "Binding to #{@endpoint.url}") - - @bound_endpoint = - Async { Async::IO::SharedEndpoint.bound(@endpoint) }.wait - - super - end - - sig { params(timeout: T::Boolean).void } - def stop(timeout = true) - super(timeout && 5) - end - - sig { params(container: Async::Container::Generic).void } - def setup(container) - collector_endpoint = Async::IO::Endpoint.unix("metrics.ipc") - exporter_endpoint = Async::HTTP::Endpoint.parse("http://[::]:9092") - - Metrics.start_collect_and_export( - container, - collector_endpoint:, - exporter_endpoint: - ) { |registry| AppMetrics.setup(registry, instance_id: "Collector") } - - # TODO: We're waiting for the collector to start. - # Better make start_collect_and_export block until started. - sleep 0.2 - - container.run( - name: "mayu-live server", - count: @config.server.count, - threads: @config.server.threads, - forks: @config.server.forks - ) do |instance, asd| - Async do |task| - interrupt = Async::Notification.new - - metrics = - Metrics::Reporter.run(collector_endpoint) do |registry| - AppMetrics.setup(registry) - end - - task.async do - @interrupt_trap.install! - - @interrupt_trap.trap { interrupt.signal } - end - - environment = Environment.new(@config, metrics) - app = App.new(environment:) - - server = - Async::HTTP::Server.new( - app, - @bound_endpoint, - protocol: Async::HTTP::Protocol::HTTP2, - scheme: @endpoint.scheme - ) - - start_hot_swap(environment, app) if @config.server.hot_swap - - if @config.server.generate_assets - environment.resources.generate_assets( - environment.path(:assets), - concurrency: Async::Container.processor_count, - forever: true - ) - end - - server_task = server.run - - task.async do - loop do - sleep 1 - app.clear_expired_sessions! - end - end - - instance.ready! - - interrupt.wait - app.stop - raise Interrupt - end - rescue => e - Console.logger.error(self, e) - end - end - - sig { params(environment: Environment, app: App, task: Async::Task).void } - def start_hot_swap(environment, app, task: Async::Task.current) - task.async do - if environment.config.use_bundle - Console.logger.error( - self, - "Disabling hot swap because bundle is used" - ) - return - end - - require_relative "../resources/hot_swap" - - Resources::HotSwap.start(environment.resources) do - Console.logger.info( - self, - Colors.rainbow("Detected code changes, rerendering.") - ) - - app.rerender - end - end - end - end - end -end diff --git a/lib/mayu/server/cookies.rb b/lib/mayu/server/cookies.rb new file mode 100644 index 00000000..51183be0 --- /dev/null +++ b/lib/mayu/server/cookies.rb @@ -0,0 +1,38 @@ + +module Mayu + class Server + module Cookies + class TokenCookieNotSetError < StandardError + end + + def self.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::Token.validate!(_1) } + end + end + + raise TokenCookieNotSetError + end + + def self.set_token_cookie_header(session, ttl_seconds: 60) + { + "set-cookie": set_token_cookie_value(session, ttl_seconds:) + } + end + + def self.set_token_cookie_value(session, ttl_seconds: 60) + expires = Time.now.utc + ttl_seconds + + [ + "mayu-token=#{session.token}", + "path=/.mayu/session/#{session.id}", + "expires=#{expires.httpdate}", + "secure", + "HttpOnly", + "SameSite=Strict" + ].join("; ") + end + end + end +end diff --git a/lib/mayu/server/errors.rb b/lib/mayu/server/errors.rb deleted file mode 100644 index b39f17c4..00000000 --- a/lib/mayu/server/errors.rb +++ /dev/null @@ -1,110 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module Mayu - module Server - module Errors - extend T::Sig - - class ServerError < StandardError - end - - class FileNotFound < ServerError - end - class CookieNotSet < ServerError - end - class InvalidToken < ServerError - end - class InvalidMethod < ServerError - end - class UnauthorizedSessionCookie < ServerError - end - class SessionAlreadyResumed < ServerError - end - class SessionNotFound < ServerError - end - class ServerIsShuttingDown < ServerError - end - class InvalidSecFetchHeader < ServerError - end - - sig do - params(block: T.proc.returns(Protocol::HTTP::Response)).returns( - Protocol::HTTP::Response - ) - end - def self.handle_exceptions(&block) - respond_to_exceptions { log_exceptions { yield } } - end - - sig do - params(block: T.proc.returns(Protocol::HTTP::Response)).returns( - Protocol::HTTP::Response - ) - end - def self.log_exceptions(&block) - yield - rescue => e - Console.logger.error(self, "#{e.class.name}: #{e.message}") - raise - end - - sig do - params(block: T.proc.returns(Protocol::HTTP::Response)).returns( - Protocol::HTTP::Response - ) - end - def self.respond_to_exceptions(&block) - yield - rescue Errno::ENOENT => e - text_response(404, "file not found") - rescue FileNotFound => e - text_response(404, e.message.to_s) - rescue CookieNotSet => e - text_response(403, "session cookie not set") - rescue SessionNotFound => e - text_response(404, "session not found") - rescue Mayu::EncryptedMarshal::DecryptError => e - text_response(403, "decrypt error") - rescue Mayu::EncryptedMarshal::ExpiredError => e - text_response(403, "session expired") - rescue InvalidToken => e - text_response(403, "invalid token") - rescue SessionAlreadyResumed => e - text_response(409, "already resumed") - rescue Session::AlreadyRunningError => e - text_response(409, "already running") - rescue ServerIsShuttingDown => e - # https://fly.io/docs/reference/fly-replay/#fly-replay - text_response( - 405, - "invalid method", - { "fly-replay" => "elsewhere=true" } - ) - rescue InvalidMethod => e - text_response(405, "invalid method") - rescue UnauthorizedSessionCookie => e - text_response(403, "session cookie is invalid") - rescue InvalidSecFetchHeader => e - text_response(415, e.message) - rescue StandardError - text_response(500, "error") - end - - sig do - params( - code: Integer, - text: String, - headers: T::Hash[String, String] - ).returns(Protocol::HTTP::Response) - end - def self.text_response(code, text, headers = {}) - Protocol::HTTP::Response[ - code, - { "content-type" => "text/plain", **headers }, - [text] - ] - end - end - end -end diff --git a/lib/mayu/server/event_stream.rb b/lib/mayu/server/event_stream.rb new file mode 100644 index 00000000..78754e3e --- /dev/null +++ b/lib/mayu/server/event_stream.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "msgpack" +require "zlib" +require "async/notification" + +module Mayu + class Server + module EventStream + CONTENT_TYPE = "application/vnd.mayu.event-stream" + CONTENT_ENCODING = "deflate-raw" + + class MsgPackWrapper < MessagePack::Factory + def initialize + super() + + self.register_type(0x01, Blob) + end + end + + class Writer < Async::HTTP::Body::Writable + def initialize(...) + super + @deflate = + Zlib::Deflate.new( + Zlib::BEST_COMPRESSION, + -Zlib::MAX_WBITS, + Zlib::MAX_MEM_LEVEL, + Zlib::HUFFMAN_ONLY + ) + @wrapper = MsgPackWrapper.new + @on_close = Async::Notification.new + end + + def wait + @on_close.wait + end + + def write(buf) + if @closed + puts "Attempting to write #{buf.inspect} to closed #{self.class.name}" + return + end + + buf + .then { PatchSet[_1].to_a } + .then { @wrapper.pack(_1) } + .then { @deflate.deflate(_1, Zlib::SYNC_FLUSH) } + .then { super(_1) } + end + + def close(reason = nil) + @on_close.signal(reason) + + begin + @queue.enqueue(@deflate.flush(Zlib::FINISH)) + rescue StandardError + nil + end + begin + @deflate.close + rescue StandardError + nil + end + super + end + end + + Blob = + Data.define(:data) do + def self.from_msgpack_ext(data) = new(data) + def to_msgpack_ext = data + end + + PatchSet = + Data.define(:id, :patches) do + def self.[](patches) = new(SecureRandom.alphanumeric, [patches].flatten) + + def to_a + patches.map do |patch| + [patch.class.name[/[^:]+\z/], *patch.deconstruct] + end + end + end + + def self.each_incoming_message(request) + buf = String.new + + request.body.each do |chunk| + buf += chunk + + if idx = buf.index("\n") + yield JSON.parse(buf[0..idx], symbolize_names: true) + buf = buf[idx.succ..-1].to_s + end + end + end + end + end +end diff --git a/lib/mayu/server/file_server.rb b/lib/mayu/server/file_server.rb deleted file mode 100644 index 5ef43dde..00000000 --- a/lib/mayu/server/file_server.rb +++ /dev/null @@ -1,140 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "errors" - -module Mayu - module Server - class FileServer - class FoundFile < T::Struct - const :absolute_path, String - const :content_type, String - const :size, Integer - end - - # TODO: Make configurable. A higher value means less - # filsystem IO, but obviously consumes more memory. - DEFAULT_MEMORY_CACHE_MAX_SIZE = T.let(1024, Integer) - - CACHE_MAX_AGE = T.let(60 * 60 * 24 * 7, Integer) - - CACHE_CONTROL = - T.let( - { - "cache-control" => "public, max-age=#{CACHE_MAX_AGE}, immutable" - }.freeze, - T::Hash[String, String] - ) - BROTLI_CONTENT_ENCODING = - T.let({ "content-encoding" => "br" }.freeze, T::Hash[String, String]) - - extend T::Sig - - sig { params(root_dir: String, memory_cache_max_size: Integer).void } - def initialize( - root_dir, - memory_cache_max_size: DEFAULT_MEMORY_CACHE_MAX_SIZE - ) - @root_dir = root_dir - @found_files = - T.let( - T::Hash[String, FoundFile].new do |h, filename| - if found_file = find_file(filename) - h[filename] = found_file - end - end, - T::Hash[String, FoundFile] - ) - @memory_cache_max_size = memory_cache_max_size - @memory_cache = T.let({}, T::Hash[String, String]) - end - - sig do - params(filename: String, accept_encodings: T::Array[String]).returns( - Protocol::HTTP::Response - ) - end - def serve(filename, accept_encodings: []) - found_file = @found_files[filename] - - unless found_file - raise Errors::FileNotFound, "Could not find file #{filename}" - end - - headers = { - **CACHE_CONTROL, - "content-type" => add_charset(found_file.content_type) - } - - if accept_encodings.include?("br") - if brotlied = @found_files["#{filename}.br"] - return( - Protocol::HTTP::Response[ - 200, - { **headers, **BROTLI_CONTENT_ENCODING }, - read_file(brotlied) - ] - ) - end - end - - contents = read_file(found_file) - Protocol::HTTP::Response[200, headers, read_file(found_file)] - end - - private - - sig { params(filename: String).returns(T.nilable(FoundFile)) } - def find_file(filename) - absolute_path = File.join(@root_dir, filename) - - return unless File.exist?(absolute_path) - - size = File.size(absolute_path) - - content_type = - MIME::Types.type_for(absolute_path.delete_suffix(".br")).first.to_s - - FoundFile.new(absolute_path:, content_type:, size:) - end - - sig do - params(found_file: FoundFile).returns( - T.any([String], Protocol::HTTP::Body::File) - ) - end - def read_file(found_file) - if found_file.size > @memory_cache_max_size - return Protocol::HTTP::Body::File.open(found_file.absolute_path) - end - - [ - @memory_cache[found_file.absolute_path] ||= begin - File.read(found_file.absolute_path) - end - ] - end - - sig { params(filename: String).returns(String) } - def get_absolute_path(filename) - File.join(@root_dir, File.expand_path(filename, "/")) - end - - sig { params(content_type: String).returns(String) } - def add_charset(content_type) - if add_charset?(content_type) - "#{content_type}; charset=utf-8" - else - content_type - end - end - - sig { params(content_type: String).returns(T::Boolean) } - def add_charset?(content_type) - content_type in - "application/javascript" | "application/json" | "image/svg+xml" | - "text/css" | "text/html" - end - end - end -end diff --git a/lib/mayu/server/request_refinements.rb b/lib/mayu/server/request_refinements.rb new file mode 100644 index 00000000..602859d6 --- /dev/null +++ b/lib/mayu/server/request_refinements.rb @@ -0,0 +1,17 @@ +module Mayu + class Server + module RequestRefinements + refine Async::HTTP::Protocol::HTTP2::Request do + def deconstruct_keys(keys) + keys.each_with_object({}) do |key, obj| + var = "@#{key}" + + if instance_variable_defined?(var) + obj[key] = instance_variable_get(var) + end + end + end + end + end + end +end diff --git a/lib/mayu/server/session_store.rb b/lib/mayu/server/session_store.rb new file mode 100644 index 00000000..fe6aa626 --- /dev/null +++ b/lib/mayu/server/session_store.rb @@ -0,0 +1,52 @@ +module Mayu + class Server + class SessionStore + class SessionNotFoundError < StandardError + end + class InvalidTokenError < StandardError + end + + def initialize + @sessions = {} + end + + def store(session) + @sessions[session.id] = session + end + + def authenticate(id, token) + session = @sessions.fetch(id) { raise SessionNotFoundError } + + unless Session::Token.equal?(session.token, token) + raise InvalidTokenError + end + + session + end + + def transfer_all + @sessions.each_value(&:transfer!) + end + + def delete(session_id) + @sessions.delete(session_id) + end + + def start_cleanup_task + @cleanup_task ||= Async do + loop do + sleep 1 + + @sessions.delete_if do |session_id, session| + if session.timed_out? + puts "\e[31mDeleting timed out session #{session_id}\e[0m" + session.stop + true + end + end + end + end + end + end + end +end diff --git a/lib/mayu/server/static_files.rb b/lib/mayu/server/static_files.rb new file mode 100644 index 00000000..c34cd850 --- /dev/null +++ b/lib/mayu/server/static_files.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "mime/types" +require "brotli" +require "digest/sha2" +require "base64" + +MIME::Types["application/json"].first.add_extensions(%w[map]) + +module Mayu + class Server + class StaticFiles + StaticFile = + Data.define( + :content_type, + :content_hash, + :encoded_content, + :filename + ) do + def self.build(filename, content) + MIME::Types.type_for(filename).first => MIME::Type => mime_type + + encoded_content = + EncodedContent.for_mime_type_and_content(mime_type, content) + content_hash = Digest::SHA256.digest(encoded_content.content) + content_type = mime_type.to_s + + filename = + format( + "%s.%s?%s", + File.join( + File.dirname(filename), + File.basename(filename, ".*") + ), + mime_type.preferred_extension, + Base64.urlsafe_encode64(content_hash, padding: false) + ) + + new(content_type:, content_hash:, encoded_content:, filename:) + end + + def headers + { + "content-type": content_type, + "content-length": content_length, + **encoded_content.headers + } + end + + def content_length + encoded_content.content.bytesize + end + end + + 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 + if encoding + { "content-encoding": encoding.to_s } + else + {} + end + end + end + + def initialize(root) + @root = File.expand_path(root) + @files = {} + end + + def get(path) + clean_path = File.expand_path(path, "/") + @files[clean_path] ||= read_file(clean_path) + end + + private + + def read_file(path) + full_path = File.join(@root, path) + StaticFile.build(path, File.read(full_path)) + rescue Errno::ENOENT + nil + end + end + end +end diff --git a/lib/mayu/server/static_files.test.rb b/lib/mayu/server/static_files.test.rb new file mode 100755 index 00000000..b4408867 --- /dev/null +++ b/lib/mayu/server/static_files.test.rb @@ -0,0 +1,62 @@ +#!/usr/bin/env ruby -rbundler/setup +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "minitest/autorun" + +require_relative "static_files" + +class Mayu::Server::StaticFiles::Test < Minitest::Test + def test_static_files + root = File.join(__dir__, "..", "..", "..", "app") + static_files = Mayu::Server::StaticFiles.new(root) + + robots = static_files.get("robots.txt") + + assert_equal("text/plain", robots.content_type) + end + + def text_nonexistant + root = File.join(__dir__, "..", "..", "..", "app") + static_files = Mayu::Server::StaticFiles.new(root) + + assert_nil static_files.get('nonexistant') + end + + def test_getting_out_of_root + root = File.join(__dir__, "..", "..", "..", "app") + static_files = Mayu::Server::StaticFiles.new(root) + + assert_nil static_files.get('../Gemfile.lock') + end +end + +class Mayu::Server::StaticFiles::StaticFile::Test < Minitest::Test + def test_static_file1 + asset = Mayu::Server::StaticFiles::StaticFile.build("/path/to/foo.txt", "content") + + assert_equal("text/plain", asset.content_type) + assert_equal(:br, asset.encoded_content.encoding) + assert_equal("content", Brotli.inflate(asset.encoded_content.content)) + assert_equal( + "/path/to/foo.txt?yqy-cFH3rN6EyrcsWbBdog-NE1tHRkLWWO1sdhQVMNk", + asset.filename + ) + assert_equal(Digest::SHA256.digest(Brotli.deflate("content")), asset.content_hash) + end + + def test_static_file2 + asset = Mayu::Server::StaticFiles::StaticFile.build("/path/to/foo.png", "content") + + assert_equal("image/png", asset.content_type) + assert_nil(asset.encoded_content.encoding) + assert_equal("content", asset.encoded_content.content) + assert_equal( + "/path/to/foo.png?7XACtDnprIRfIjV9giusFERzD722AW0-yUMil7nsn3M", + asset.filename + ) + assert_equal(Digest::SHA256.digest("content"), asset.content_hash) + end +end diff --git a/lib/mayu/session.rb b/lib/mayu/session.rb index 6e7a8e2a..c728efa0 100644 --- a/lib/mayu/session.rb +++ b/lib/mayu/session.rb @@ -1,355 +1,139 @@ -# typed: strict - -require "time" -require "nanoid" -require "rbnacl" -require_relative "environment" -require_relative "vdom/vtree" -require_relative "vdom/marshalling" -require_relative "event_stream" +require_relative "runtime" +require_relative "session/token" module Mayu class Session - extend T::Sig - - class InvalidTokenError < StandardError - end - class InvalidIdError < StandardError - end - class AlreadyRunningError < StandardError - end - - sig do - params(environment: Environment, path: String).returns(T.attached_class) - end - def self.init(environment:, path:) - new(environment:, path:) - end - - sig do - params(environment: Environment, dumped: String).returns(T.attached_class) - end - def self.restore(environment:, dumped:) - Marshal.restore( - dumped, - ->(obj) do - case obj - when self - obj.instance_variable_set(:@environment, environment) - obj - when SerializedSession - obj.to_session(environment) - else - obj - end - end - ) - end - - ID_FORMAT = /\A[A-Za-z0-9_-]{21}\z/ - - TOKEN_LENGTH = 64 - TOKEN_FORMAT = /\A\w{#{TOKEN_LENGTH}}\z/ - - sig { returns(String) } - def self.generate_token - SecureRandom.alphanumeric(TOKEN_LENGTH) - end - - sig { params(token: String).returns(T::Boolean) } - def self.valid_token?(token) - token.match?(TOKEN_FORMAT) - end - - sig { params(token: String).void } - def self.validate_token!(token) - raise InvalidTokenError unless valid_token?(token) - end - - sig { params(id: String).returns(T::Boolean) } - def self.valid_id?(id) - id.match?(ID_FORMAT) - end - - sig { params(id: String).void } - def self.validate_id!(id) - raise InvalidIdError unless valid_id?(id) - end - - sig { params(token: String).returns(T::Boolean) } - def authorized?(token) - RbNaCl::Util.verify64(self.token, token) + RequestInfo = Data.define(:path, :headers) do + def self.from_request(request) + new( + path: request.path, + headers: request.headers.to_h.freeze, + ) + end end - Marshaled = T.type_alias { [String, String, String, String, String] } + TIMEOUT_SECONDS = 5 - sig { returns(String) } attr_reader :id - sig { returns(String) } attr_reader :token - sig { returns(String) } - attr_reader :path - sig { returns(T::Hash[String, String]) } - attr_reader :headers - sig { returns(Environment) } - attr_reader :environment - sig { returns(Float) } - attr_reader :last_ping_at - sig { returns(EventStream::Log) } - attr_reader :log - sig { params(timeout_seconds: T.any(Float, Integer)).returns(T::Boolean) } - def expired?(timeout_seconds = 30) - seconds_since_last_ping > timeout_seconds - end - - sig { returns(Float) } - def seconds_since_last_ping - Time.now.to_f - last_ping_at - end - - sig do - params( - environment: Environment, - path: String, - headers: T::Hash[String, 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:, request_info:) + @id = SecureRandom.alphanumeric(32) + @token = Token.generate @environment = environment - @id = T.let(Nanoid.generate, String) - @token = T.let(self.class.generate_token, String) - @path = path - @headers = headers - @vtree = T.let(vtree || VDOM::VTree.new(session: self), VDOM::VTree) - @log = T.let(EventStream::Log.new, EventStream::Log) - @store = - T.let( - store || environment.create_store(initial_state: {}), - State::Store + @request_info = request_info + + @engine = + Runtime.init( + resolve_route(@request_info.path), + runtime_js: environment.runtime_js_for_session_id(@id) ) - @app = T.let(environment.load_root(path, headers:), VDOM::Descriptor) - @last_ping_at = T.let(Time.now.to_f, Float) - @barrier = T.let(Async::Barrier.new, Async::Barrier) - end - sig { void } - def stop! - @barrier.stop + @last_ping = Async::Clock.now end - Writable = - T.type_alias { T.any(Async::HTTP::Body::Writable, Brotli::Writer) } - - sig do - params(body: Writable, task: Async::Task).returns( - { stylesheets: T::Array[String] } - ) + def self.resume_transferred(environment, encrypted_state) + session = environment.marshaller.load(encrypted_state) + session.instance_variable_set(:@environment, environment) + session end - def initial_render(body, task: Async::Task.current) - @vtree.render(@app, lifecycles: false) - root = @vtree.root or raise "There is no root" - - html = root.to_html - stylesheets = - @vtree - .assets - .select { _1.end_with?(".css") } - .map { "/__mayu/static/#{_1}" } + def marshal_dump + [@id, @token, @engine, @last_ping, @request_info] + end - # freeze + def marshal_load(a) + @id, @token, @engine, @last_ping, @request_info = a + end - # encrypted_session = - # @environment.encrypted_marshal.dump(SerializedSession.dump_session(self)) + def timed_out? + diff = Async::Clock.now - @last_ping + diff > TIMEOUT_SECONDS + end - links = [ - %{}, - *stylesheets.map do |stylesheet| - %{} + def run(&block) + @task = Async do |task| + task.async do + while Modules::System.current.wait_for_reload + @engine.update(resolve_route(@request_info.path)) + end end - ].join - - # scripts = %{} - scripts = "" - body.write("\n") - task.async do - @vtree.root&.write_html(body, links:, scripts:) - body.close - rescue => e - p e + @engine.run(&block) + ensure + @task = nil end - - { stylesheets: } end - class SerializedSession - extend T::Sig - - sig { returns(T::Array[T.untyped]) } - attr_reader :data - - sig { params(data: T::Array[T.untyped]).void } - def initialize(data) - @data = data - end - - sig { params(session: Session).returns(String) } - def self.dump_session(session) - Marshal.dump(self.new(session.marshal_dump)) - end - - sig { params(environment: Environment).returns(Session) } - def to_session(environment) - session = Session.allocate - session.instance_variable_set(:@environment, environment) - session.marshal_load(@data) - session - end - - sig { returns(T::Array[T.untyped]) } - def marshal_dump - @data - end - - sig { params(a: T::Array[T.untyped]).void } - def marshal_load(a) - @data = a - end + def wait + @task&.wait end - sig { returns(T::Array[T.untyped]) } - def marshal_dump - [ - @id, - @token, - @path, - @headers, - VDOM::Marshalling.dump(@vtree), - Marshal.dump(@store.state) - ] + def stop + @task&.stop end - sig { params(a: T::Array[T.untyped]).void } - def marshal_load(a) - @id, @token, @path, @headers, 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:) - @barrier = Async::Barrier.new - @log = EventStream::Log.new + def render + @engine.render end - sig do - params( - url: String, - method: Symbol, - headers: T::Hash[String, String], - body: T.nilable(String) - ).returns(Fetch::Response) - end - def fetch(url, method: :GET, headers: {}, body: nil) - @environment.fetch.fetch(url, method:, headers:, body:) + def handle_callback(id, payload) + update_last_ping + @engine.callback(id, payload) end - sig { void } - def activity! - @last_ping_at = Time.now.to_f + def handle_navigate(path, push_state: true) + update_last_ping + @request_info = @request_info.with(path:) + descriptor = resolve_route(path) + @engine.navigate(path, descriptor, push_state:) end - sig do - params(callback_id: String, payload: T::Hash[Symbol, T.untyped]).void - end - def handle_callback(callback_id, payload = {}) - activity! - @vtree.handle_callback(callback_id, payload) + def handle_ping(timestamp) + update_last_ping + @engine.ping(timestamp) end - sig { void } - def rerender - @app = @environment.load_root(path, headers:) - @vtree.replace_root(@app) + def transfer! + @engine.stop + @engine.patch( + Runtime::Patches::Transfer[ + Mayu::Server::EventStream::Blob[@environment.marshaller.dump(self)] + ] + ) end - sig { params(path: String).void } - def navigate(path) - Console.logger.info(self, "navigate: #{path.inspect}") - @app = @environment.load_root(path, headers:) - @path = path - @vtree.replace_root(@app) - end + private - sig do - params( - task: Async::Task, - block: T.proc.params(msg: [Symbol, T.untyped]).void - ).returns(Async::Barrier) + def update_last_ping + @last_ping = Async::Clock.now end - def run(task: Async::Task.current, &block) - root = @vtree.root - raise "No root!" unless root + def resolve_route(path) + system = Modules::System.current - barrier = Async::Barrier.new(parent: @barrier) + match = @environment.router.match(path) - barrier.async do |subtask| - yield [:init, { ids: root.id_tree }] + layouts = + match.route.layouts.map { system.import(File.join("/pages", _1)) } - root.traverse do |vnode| - if c = vnode.component - # TODO: Make sure the component isn't already mounted.. - # maybe can check that in the component wrapper? - # Also, shouldn't this be done once when resuming rather - # than when starting the event stream? - c.mount - end - end + page = + Mayu::Runtime::H[ + system.import(File.join("/pages", match.route.views.page)), + params: match.params, + query: match.query + ] - @vtree.render(@app, lifecycles: true) - - updater = VDOM::VTree::Updater.new(@vtree) - - updater - .run(metrics: environment.metrics, task: subtask) do |msg| - case msg - in [:patch, patches] - yield [:patch, patches] - in [:exception, error] - yield [:exception, error] - in [:pong, timestamp] - yield [:pong, timestamp] - in [:navigate, href] - navigate(href) - yield [:navigate, path: href.force_encoding("utf-8")] - in [:action, payload] - yield [:action, payload] - in [:update_finished, *] - # noop - else - Console.logger.error(self, "Unknown event: #{msg.inspect}") - end - end - .wait - - barrier.stop - end - - barrier.async do - loop do - # puts "keep alive task" - sleep 1 - yield [:keep_alive, nil] + layouts + .reverse + .reduce(page) do |page, layout| + Mayu::Runtime::H[ + layout, + page, + params: match.params, + query: match.query + ] end - ensure - # puts "Stopped this task" - barrier.stop - end - - barrier end end end diff --git a/lib/mayu/session/token.rb b/lib/mayu/session/token.rb new file mode 100644 index 00000000..a96f0367 --- /dev/null +++ b/lib/mayu/session/token.rb @@ -0,0 +1,28 @@ +require "rbnacl" + +module Mayu + class Session + module Token + class InvalidTokenError < StandardError + end + + TOKEN_LENGTH = 64 + + def self.validate!(token) + raise InvalidTokenError unless valid_format?(token) + end + + def self.valid_format?(token) + token.match?(/\A[[:alnum:]]{#{TOKEN_LENGTH}}\z/) + end + + def self.generate + SecureRandom.alphanumeric(TOKEN_LENGTH) + end + + def self.equal?(a, b) + RbNaCl::Util.verify64(a, b) + end + end + end +end diff --git a/lib/mayu/state.rb b/lib/mayu/state.rb deleted file mode 100644 index f7e36327..00000000 --- a/lib/mayu/state.rb +++ /dev/null @@ -1,8 +0,0 @@ -# typed: strict - -require_relative "state/store" - -module Mayu - module State - end -end diff --git a/lib/mayu/state.test.rb b/lib/mayu/state.test.rb deleted file mode 100644 index 3c2821e5..00000000 --- a/lib/mayu/state.test.rb +++ /dev/null @@ -1,97 +0,0 @@ -# typed: true - -require "minitest/autorun" -require "test_helper" - -require_relative "vdom/descriptor" -require_relative "state" - -class TestState < Minitest::Test - class MyComponent < Mayu::Component::Base - def render - end - end - - State = Mayu::State - - INCREMENT = State::ActionCreator.create(:increment) - DECREMENT = State::ActionCreator.create(:decrement) - ADD_ITEM = State::ActionCreator.create(:add_item) - REMOVE_ITEM = State::ActionCreator.create(:remove_item) - THUNK = - State::ActionCreator.async(:hello) do |store| - store.dispatch(INCREMENT) - sleep 1 - store.dispatch(INCREMENT) - sleep 1 - store.dispatch(DECREMENT) - end - - def test_initialize_descriptor - Sync do - count_reducer = ->(state, action) do - state ||= { count: 0 } - - case action - when INCREMENT - state.merge(count: state[:count].succ) - when DECREMENT - state.merge(count: state[:count].pred) - else - state - end - end - - totals_reducer = ->(state, action) do - state ||= 0 - - case action - in ADD_ITEM - state += 1 - in REMOVE_ITEM - state -= 1 - else - state - end - end - - items_reducer = ->(state, action) do - state ||= [] - p action - - case action - in ADD_ITEM - state + [action[:item]] - in REMOVE_ITEM - state - [action[:item]] - else - state - end - end - - @store = - State::Store.new( - { total: 0, items: [] }, - reducers: { - count1: count_reducer, - count2: count_reducer, - items: items_reducer, - totals: totals_reducer - } - ) - - @store.dispatch(ADD_ITEM, item: "Apple") - @store.dispatch(ADD_ITEM, item: "Banana") - @store.dispatch(REMOVE_ITEM, item: "Apple") - @store.dispatch(ADD_ITEM, item: "Papaya") - @store.dispatch(INCREMENT) - - puts @store.state - @store.dispatch(THUNK) - sleep 0.1 - @store.dispatch(THUNK) - puts "hello" - puts @store.state - end - end -end diff --git a/lib/mayu/state/README.md b/lib/mayu/state/README.md deleted file mode 100644 index 2b72fff3..00000000 --- a/lib/mayu/state/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# `state/` - -This directory contains classes related to dealing with state. - -It's inspired by [Redux Toolkit](https://redux-toolkit.js.org/), -but simplified. diff --git a/lib/mayu/state/action_creator.rb b/lib/mayu/state/action_creator.rb deleted file mode 100644 index 28ee3c63..00000000 --- a/lib/mayu/state/action_creator.rb +++ /dev/null @@ -1,191 +0,0 @@ -# typed: strict - -require "nanoid" -require_relative "../fetch" - -module Mayu - module State - module ActionCreator - extend T::Sig - - class Base - extend T::Sig - - sig { returns(Symbol) } - attr_reader :type - - sig { params(type: Symbol).void } - def initialize(type) - @type = type - end - - sig do - params(payload: T.untyped).returns( - T.any(Store::ActionHash, Store::Thunk) - ) - end - def call(payload = nil) - { type:, payload: } - end - - sig { params(other: T.untyped).returns(T::Boolean) } - def ===(other) - case other - when Hash - @type == other[:type] - when ActionWrapper - @type == other.type - else - super - end - end - end - - class StaticActionCreator < Base - end - - class PreparedActionCreator < Base - ProcType = T.type_alias { T.proc.returns(Store::ActionHash) } - - sig do - params(type: Symbol, block: T.proc.returns(Store::ActionHash)).void - end - def initialize(type, &block) - super(type) - @prepare = block - end - - sig do - params(args: T.untyped, kwargs: T.untyped).returns(Store::ActionHash) - end - def call(*args, **kwargs) - { type: }.merge(T.unsafe(@prepare).call(*args, **kwargs)) - end - end - - class AsyncActionCreator < Base - ProcType = - T.type_alias do - T.proc.params(arg0: Store, args: T.untyped, kwargs: T.untyped).void - end - - sig { returns(StaticActionCreator) } - attr_reader :pending - sig { returns(StaticActionCreator) } - attr_reader :fulfilled - sig { returns(StaticActionCreator) } - attr_reader :rejected - - sig { params(type: Symbol, block: ProcType).void } - def initialize(type, &block) - super(type) - @block = block - @pending = - T.let( - StaticActionCreator.new(:"#{type}/pending"), - StaticActionCreator - ) - @fulfilled = - T.let( - StaticActionCreator.new(:"#{type}/fulfilled"), - StaticActionCreator - ) - @rejected = - T.let( - StaticActionCreator.new(:"#{type}/rejected"), - StaticActionCreator - ) - end - - sig do - override - .params(args: T.untyped, parent: Async::Task, kwargs: T.untyped) - .returns(Store::Thunk) - end - def call(*args, parent: Async::Task.current, **kwargs) - ->(store) do - parent.async do |task| - request_id = Nanoid.generate() - store.dispatch(pending, *args, **kwargs.merge(request_id:)) - result = - T.unsafe(@block).call( - store, - *args, - **kwargs.merge(task:, request_id:) - ) - store.dispatch(fulfilled, result) - rescue => error - store.dispatch(rejected, { error:, request_id: }) - end - end - end - end - - module ActionContext - extend T::Sig - - FetchResponse = T.type_alias { String } - Header = T.type_alias { [String, String] } - - sig do - params( - resource: T.any(String, URI), - method: Symbol, - headers: T::Array[Header], - body: T.nilable(String), - redirect: Symbol, - referrer: String, - keepalive: T::Boolean - ).returns(FetchResponse) - end - def fetch( - resource, - method: :GET, - headers: [], - body: nil, - redirect: :follow, - referrer: "mayu-live-fetch/#{Mayu::VERSION}", - keepalive: false - ) - internet = Async::HTTP::Internet.new - - case method - when :GET - internet.get(resource, headers) - when :POST - internet.post(resource, headers, body) - when :PUT - internet.put(resource, headers, body) - when :PATCH - internet.patch(resource, headers, body) - end - ensure - internet&.close - end - end - - sig do - params( - type: Symbol, - block: T.nilable(PreparedActionCreator::ProcType) - ).returns(Base) - end - def self.create(type, &block) - if block_given? - PreparedActionCreator.new(type, &block) - else - StaticActionCreator.new(type) - end - end - - sig do - params(type: Symbol, block: AsyncActionCreator::ProcType).returns( - AsyncActionCreator - ) - end - def self.async(type, &block) - AsyncActionCreator.new(type, &block) - end - end - end -end diff --git a/lib/mayu/state/action_wrapper.rb b/lib/mayu/state/action_wrapper.rb deleted file mode 100644 index bf11432a..00000000 --- a/lib/mayu/state/action_wrapper.rb +++ /dev/null @@ -1,30 +0,0 @@ -# typed: strict - -module Mayu - module State - class ActionWrapper - extend T::Sig - - sig { returns(Symbol) } - attr_reader :type - sig { returns(T.untyped) } - attr_reader :payload - - sig { params(type: Symbol, payload: T.untyped).void } - def initialize(type:, payload:) - @type = type - @payload = payload - end - - sig { params(key: T.untyped).returns(T.untyped) } - def [](key) = @payload[key] - sig { params(key: T.untyped, block: T.untyped).returns(T.untyped) } - def fetch(key, &block) = @payload.fetch(key, &block) - - sig { returns(String) } - def inspect - "#" - end - end - end -end diff --git a/lib/mayu/state/loader.rb b/lib/mayu/state/loader.rb deleted file mode 100644 index 8c74d29c..00000000 --- a/lib/mayu/state/loader.rb +++ /dev/null @@ -1,220 +0,0 @@ -# typed: true - -require_relative "store" - -module Mayu - module State - class Selector - module SelectorModule - extend T::Sig - - sig do - params( - block: T.proc.params(state: T.untyped).returns(T.untyped) - ).returns(Selector) - end - def selector(&block) - Selector.new - end - - sig do - params( - block: T.proc.params(state: T.untyped).returns(T.untyped) - ).returns(Module) - end - def self.build(&block) - mod = Module.new - mod.send(:extend, SelectorModule) - mod.class_eval(&block) - mod - end - end - - extend T::Sig - end - - module Actions - module ActionModule - extend T::Sig - - sig do - params( - type: Symbol, - block: T.nilable(ActionCreator::PreparedActionCreator::ProcType) - ).returns(ActionCreator::Base) - end - def action(type, &block) - ActionCreator.create(type, &block) - end - - sig do - params( - type: Symbol, - block: ActionCreator::AsyncActionCreator::ProcType - ).returns(ActionCreator::AsyncActionCreator) - end - def async(type, &block) - ActionCreator.async(type, &block) - end - - sig do - params( - block: T.proc.params(state: T.untyped).returns(T.untyped) - ).returns(Module) - end - def self.build(&block) - mod = Module.new - mod.send(:extend, ActionModule) - mod.class_eval(&block) - mod - end - end - - extend T::Sig - end - - module ReducerDSL - extend T::Sig - extend T::Helpers - interface! - - sig { abstract.params(kwargs: T.untyped).void } - def initial_state(**kwargs) - end - - sig do - abstract - .params(action: ActionCreator::Base, reducer: Store::Reducer) - .void - end - def reducer(action, &reducer) - end - - sig { abstract.params(url: String, options: T.untyped).void } - def fetch(url, **options) - end - - sig do - abstract - .params( - block: - T.proc.bind(Selector::SelectorModule).params(arg0: T.untyped).void - ) - .returns(Module) - end - def selectors(&block) - end - - sig do - abstract - .params( - block: - T.proc.bind(Actions::ActionModule).params(arg0: T.untyped).void - ) - .returns(Module) - end - def actions(&block) - end - end - - class Loader - extend T::Sig - - class ReducerBuilder - extend ::T::Sig - include ::Mayu::State::ReducerDSL - - sig do - params(source: ::String, path: ::String).returns( - T::Hash[Symbol, T.untyped] - ) - end - def self.build(source, path) - { initial_state: nil, reducers: [], actions: [] }.tap do - new(File.basename(path, ".*"), _1).instance_eval(source, path, 0) - end - end - - sig { params(prefix: String, data: T::Hash[::Symbol, T.untyped]).void } - def initialize(prefix, data) - @prefix = prefix - @data = data - end - - sig { override.params(kwargs: T.untyped).void } - def initial_state(**kwargs) - @data[:initial_state] = kwargs - end - - sig { override.params(url: String, options: T.untyped).void } - def fetch(url, **options) - end - - sig do - override - .params( - block: - T - .proc - .bind(Selector::SelectorModule) - .params(arg0: T.untyped) - .void - ) - .returns(Module) - end - def selectors(&block) - @data[:selectors] = Selector::SelectorModule.build(&block) - end - - sig do - override - .params( - block: - T.proc.bind(Actions::ActionModule).params(arg0: T.untyped).void - ) - .returns(Module) - end - def actions(&block) - @data[:actions] = Actions::ActionModule.build(&block) - end - - sig do - override - .params(action: ActionCreator::Base, reducer: Store::Reducer) - .void - end - def reducer(action, &reducer) - @data[:reducers].push([action, reducer]) - end - end - - sig { params(directory: String).void } - def initialize(directory) - @directory = directory - end - - sig { returns(Store::Reducers) } - def load - Dir[File.join(@directory, "*.rb")] - .map do |path| - data = ReducerBuilder.build(File.read(path), path) - name = File.basename(path, ".*").capitalize.to_sym - - [ - name, - ->(state, action) do - state ||= data[:initial_state] - - data[:reducers] - .filter { _1 === action } - .reduce( - state || data[:initial_state] - ) { |state, (_, reducer)| reducer.call(state, action) } - end - ] - end - .to_h - end - end - end -end diff --git a/lib/mayu/state/store.rb b/lib/mayu/state/store.rb deleted file mode 100644 index 21e95f9a..00000000 --- a/lib/mayu/state/store.rb +++ /dev/null @@ -1,82 +0,0 @@ -# typed: strict - -require "async" -require "async/semaphore" -require_relative "action_wrapper" -require_relative "action_creator" - -module Mayu - module State - class Store - extend T::Sig - - sig { returns(T.untyped) } - def marshal_dump - [@state] - end - - sig { params(a: T.untyped).void } - def marshal_load(a) - @state = a.first - end - - State = T.type_alias { T.untyped } - Reducer = - T.type_alias do - T.proc.params(arg0: State, arg1: ActionWrapper).returns(State) - end - Thunk = T.type_alias { T.proc.params(arg0: Store).void } - Reducers = T.type_alias { T::Hash[Symbol, Store::Reducer] } - - sig { returns(State) } - attr_reader :state - - sig { params(initial_state: State, reducers: Reducers).void } - def initialize(initial_state, reducers:) - @state = T.let(initial_state, State) - @reducer = T.let(combine_reducers(reducers), Reducer) - @semaphore = T.let(Async::Semaphore.new, Async::Semaphore) - - dispatch({ type: :__INIT__, payload: nil }) - end - - ActionHash = T.type_alias { T::Hash[Symbol, T.untyped] } - - sig do - params( - action: T.any(ActionHash, ActionCreator::Base, Thunk), - args: T.untyped, - kwargs: T.untyped - ).void - end - def dispatch(action, *args, **kwargs) - if action.is_a?(ActionCreator::Base) - return dispatch(T.unsafe(action).call(*args, **kwargs)) - end - - return action.call(self) if action.is_a?(Proc) - - @semaphore.async do - new_state = - @reducer.call(@state.dup, T.unsafe(ActionWrapper).new(**action)) - @state = new_state - rescue => e - Console.logger.error(self) { "Reducer crashed" } - end - - nil - end - - private - - sig { params(reducers: T::Hash[Symbol, Reducer]).returns(Reducer) } - def combine_reducers(reducers) - ->(state, action) do - reducers.reduce(state) do |new_state, (name, reducer)| - new_state.merge(name => reducer.call(state[name], action)) - end - end - end - end - end -end diff --git a/lib/mayu/style_sheet.rb b/lib/mayu/style_sheet.rb new file mode 100644 index 00000000..7ce02d0b --- /dev/null +++ b/lib/mayu/style_sheet.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +module Mayu + NullStyleSheet = + Data.define(:source_filename) do + def [](*class_names) + unless class_names.compact.all? { + _1.start_with?("__") || String === _1 + } + Console.logger.error( + source_filename, + "\e[31mNo stylesheet defined\e[0m" + ) + end + + class_names.filter { String === _1 } + end + + def merge(other) + other || self + end + + def each(&) + end + end + + MergedStyleSheet = Data.define(:stylseheets) do + def [](*class_names) + stylesheets.map { _1[*class_names] }.flatten.compact.uniq + end + + def each(&) + stylesheets.each(&) + end + end + + StyleSheet = + Data.define(:source_filename, :content_hash, :classes, :content) do + def self.encode_url(url) + url + end + + def filename + source_filename + ".css" + end + + def each(&) + yield self + end + + def [](*class_names) + class_names + .compact + .flatten + .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 + unless class_name.start_with?("__") + available_class_names = + classes + .keys + .reject { _1.start_with?("__") } + .map { _1.to_s.prepend(" ") } + .join("\n") + + Console.logger.error( + source_filename, + format(<<~MSG, class_name, available_class_names) + Could not find class: \e[1;31m.%s\e[0m + Available class names: + \e[1;33m%s\e[0m + MSG + ) + nil + end + end + else + nil + end + end + .compact + .uniq + end + + def merge(other) + if other + MergedStyleSheet[stylesheets: [self, other]] + else + self + end + end + end +end diff --git a/lib/mayu/test.rb b/lib/mayu/test.rb new file mode 100644 index 00000000..f496eaca --- /dev/null +++ b/lib/mayu/test.rb @@ -0,0 +1,336 @@ +#!/usr/bin/env ruby -rbundler/setup +# frozen_string_literal: true + +# Copyright Andreas Alin +# License: AGPL-3.0 + +require "async" +require "async/notification" +require "nokogiri" +require "syntax_tree/xml" +require "pry" + +require "rouge" + +require_relative "runtime" +require_relative "runtime/h" +require_relative "runtime/dom" +require_relative "component" + +module Mayu + module Test + module Helpers + def render(descriptor) + Sync do + page = + Mayu::Test::Page.new( + Mayu::Runtime.init(descriptor, runtime_js: "test.js") + ) + + Fiber[:current_test_page] = page + + page.start + + sleep 0.01 + + yield page + rescue => e + puts e.message + puts e.backtrace + raise + ensure + page.stop + end + end + + def find(*, **) + find!(*, **) + rescue Page::NodeNotFoundError + nil + end + + def find!(*, **) + filter = Mayu::Test::Filters::Tag[*, **] + current_page.find!(&filter) + end + + def current_page + Fiber[:current_test_page] or raise "There is no current page" + end + + def enable_step! + Fiber[:test_enable_step] = true + end + + def capture_patches(&block) + patches = [] + + task = Async do + current_page.on_patch do + patches.push(patch) + end + end + + begin + yield + ensure + task.stop + end + + patches + end + end + + module Filters + Tag = Data.define(:name, :text, :attributes) do + def self.[](tag, text: nil, **attributes) + tag = tag.to_s if tag in Symbol + new(tag.to_s, text, attributes) + end + + def match?(node) + case name + in "#text" + if node in Nokogiri::XML::Text + return true unless text + text === node.content + end + in "#comment" + if node in Nokogiri::XML::Comment + return true unless text + text === node.content + end + in String => tag_name + return false unless node in Nokogiri::XML::Element + return false unless tag_name === node.name + + attributes.each do |attr, value| + unless value === node.attributes["name"]&.value + return false + end + end + + return true unless text + text === node.content + end + end + + def to_proc + ->(node) { match?(node) } + end + end + end + + class Page + class NodeNotFoundError < StandardError + end + class NoListenerError < StandardError + end + + Node = Data.define(:page, :node) do + def name + node.name + end + + def [](attr) + node.attributes.fetch(attr.to_s) do + raise "Could not find attribute #{attr.to_s} in #{node.to_html}" + end.value + end + + def attributes + node.attributes.transform_values(&:value) + end + + def content + node.content + end + + def traverse(&) + node.traverse(&) + end + + def find(&) + traverse do |node| + return self.class.new(page, node) if yield node + end + + nil + end + + def find!(&) + find(&) or raise NodeNotFoundError + end + + def click + attrs = attributes + + target = { + name: attrs["name"], + value: attrs["value"], + } + + page.callback(callback_id(:onclick), { + target:, + currentTarget: target, + }) + end + + def input(value) + page.callback(callback_id(:oninput), { currentTarget: { value: } }) + end + + def type_input(value) + page.callback(callback_id(:oninput), { currentTarget: { value: } }) + + value.each_char.reduce("") do |str, char| + (str + char).tap do + yield if block_given? + self.input(_1) + sleep 0.05 + end + end + end + + private + + def callback_id(attribute) + if value = node.attributes[attribute.to_s]&.value + value[/\AMayu\.callback\(event,'(?[^\)]+)'\)\z/, :id] + end + end + end + + attr_reader :on_patch + + def initialize(engine) + @engine = engine + rendered = @engine.render + @nodes = {} + @doc = Nokogiri::HTML5(rendered.to_html) + @patches = [] + @on_patch = Async::Notification.new + setup_tree(@doc, rendered.id_node) + end + + def start + @task ||= Async do + @engine.run do |patch| + puts format( + "\e[33m%s\e[0m %s", + patch.class.name.split("::").last, + patch.to_h.map { |k, v| + format( + "\e[34m%s\e[0m: \e[94m%s\e[0m", + k, + v.inspect + ) + }.join(", ") + ) + + @patches.push(patch) + + case patch + in Mayu::Runtime::Patches::SetTextContent[id:, content:] + @nodes.fetch(id).content = content + in Mayu::Runtime::Patches::CreateTree[html:, tree:] + Nokogiri::HTML5.fragment(html).children => [node] + setup_tree(node, tree) + in Mayu::Runtime::Patches::SetAttribute[id:, name:, value:] + node = @nodes.fetch(id) + node[name.to_s] = value + in Mayu::Runtime::Patches::ReplaceChildren[id:, child_ids:] + node = @nodes.fetch(id) + children = child_ids.map { @nodes.fetch(_1) } + node_set = Nokogiri::XML::NodeSet.new(@doc, children) + node.children = node_set + in Mayu::Runtime::Patches::RemoveNode[id:] + @nodes.delete(id) + else + puts "\e[33mUnhandled #{patch.inspect}\e[0m" + end + end + ensure + @task = nil + end + end + + def stop + @task.stop + end + + def step + interactive = Fiber[:test_enable_step] && $stdout.tty? + clear = interactive ? "\e[H\e[2J" : "#".*(40).+("\n") + + puts format( + "%s%s\n\e[3m %s \e[0m\n", + clear, + self.class.format_xhtml(to_xhtml).strip, + "Press return to step" + ) + + if interactive + gets + else + sleep 0.05 + end + end + + def to_xhtml + SyntaxTree::XML.format(@doc.to_xhtml) + end + + def traverse(&) = @doc.traverse(&) + + def find(...) + Node.new(self, @doc).find(...) + end + + def find!(...) + Node.new(self, @doc).find!(...) + end + + def callback(id, payload = {}) + puts "Callback #{id} #{payload.inspect}" + @engine.callback(id, payload) + end + + def emit(event) + event.call(@engine) + end + + private + + def setup_tree(dom_node, id_node) + return unless dom_node + return unless id_node + + unless dom_node.name == id_node.name.delete_prefix("#").downcase + raise "\e[31m#{id_node.id} should be #{id_node.name.inspect}, but found #{dom_node.name.inspect}\e[0m" + end + + @nodes.store(id_node.id, dom_node) + + dom_node + .children.to_a + .reject do + _1.node_type == 14 + end + .zip(id_node.children) + .each do |dom_child, id_child| + setup_tree(dom_child, id_child) + end + end + + def self.format_xhtml(source) + theme = Rouge::Themes::Gruvbox.dark! + formatter = Rouge::Formatters::Terminal256.new(theme) + lexer = Rouge::Lexers::XML.new + source + .then { lexer.lex(_1) } + .then { formatter.format(_1) } + end + end + end +end diff --git a/lib/mayu/utils.rb b/lib/mayu/utils.rb deleted file mode 100644 index c0fe2b29..00000000 --- a/lib/mayu/utils.rb +++ /dev/null @@ -1,114 +0,0 @@ -# typed: strict - -module Mayu - module Utils - extend T::Sig - - sig { params(unit: Symbol).returns(Float) } - def self.monotonic_now(unit = :float_millisecond) - Process.clock_gettime(Process::CLOCK_MONOTONIC, unit).to_f - end - - sig { params(unit: Symbol, block: T.proc.void).returns(Float) } - def self.measure_time(unit = :float_millisecond, &block) - start = monotonic_now - yield - monotonic_now - start - end - - sig do - params( - hash: T::Hash[T.untyped, T.untyped], - path: T::Array[String] - ).returns(T::Hash[Symbol, T.untyped]) - end - def self.flatten_props(hash, path = []) - hash.reduce({}) do |obj, (k, v)| - next obj.merge(style: v) if k == :style && path.empty? - - current_path = [*path, k] - - obj.merge( - case v - when Hash - flatten_props(v, current_path) - else - { current_path.join("_").to_sym => v } - end - ) - end - end - - class DeepFreezer - extend T::Sig - extend T::Generic - Elem = type_member { { upper: Object } } - - sig { params(obj: Elem).void } - def initialize(obj) = @obj = obj - - sig { returns(Elem) } - def deep_freeze - case @obj - when Hash - @obj.freeze - T.cast( - @obj - .transform_keys { Utils.deep_freeze(_1) } - .transform_values { Utils.deep_freeze(_1) }, - Elem - ) - when Array - T.cast(@obj.map(&:freeze), Elem) - else - @obj.freeze - end - end - end - - class DeepDuper - extend T::Sig - extend T::Generic - Elem = type_member { { upper: Object } } - - sig { params(obj: Elem).void } - def initialize(obj) = @obj = obj - - sig { returns(Elem) } - def deep_dup - case @obj - when Hash - @obj.dup - T.cast( - @obj - .transform_keys { Utils.deep_dup(_1) } - .transform_values { Utils.deep_dup(_1) }, - Elem - ) - when Array - T.cast(@obj.map(&:dup), Elem) - else - @obj.dup - end - end - end - - sig do - type_parameters(:O) - .params(obj: T.type_parameter(:O)) - .returns(T.type_parameter(:O)) - end - def self.deep_freeze(obj) - DeepFreezer.new(obj).deep_freeze - end - - sig do - type_parameters(:O) - .params(obj: T.type_parameter(:O)) - .returns(T.type_parameter(:O)) - end - def self.deep_dup(obj) - DeepDuper.new(obj).deep_dup - end - end -end diff --git a/lib/mayu/vdom.rb b/lib/mayu/vdom.rb deleted file mode 100644 index b39ea8ca..00000000 --- a/lib/mayu/vdom.rb +++ /dev/null @@ -1,8 +0,0 @@ -# typed: strict - -require_relative "vdom/h" - -module Mayu - module VDOM - end -end diff --git a/lib/mayu/vdom.test.rb b/lib/mayu/vdom.test.rb deleted file mode 100644 index 40716e2a..00000000 --- a/lib/mayu/vdom.test.rb +++ /dev/null @@ -1,73 +0,0 @@ -# typed: true - -require "minitest/autorun" -require "test_helper" -require "rouge" -require "nokogiri" -require_relative "resources/transformers/haml" -require_relative "session" -require_relative "vdom" -require_relative "vdom/vtree" - -class Mayu::VDOM::Test < Minitest::Test - MyComponent = Mayu::TestHelper.haml_to_component(__FILE__, __LINE__, <<~HAML) - :ruby - def self.get_initial_state(initial_count: 3, **) - { count: initial_count } - end - - def handle_click(e) - value = e.dig("target", "value").to_i - - update do |state| - { count: state[:count] + value } - end - end - - :css - .foo { color: peru; } - .btn { background: peachpuff; } - .increment { } - .decrement { } - .foo - %button.increment(name="increment" value="1" onclick=handle_click) - Decrement - %button.decrement(name="decrement" value="-1" onclick=handle_click) - Increment - %output= state[:count] - HAML - - def test_vdom - Mayu::TestHelper.test_component(MyComponent) do |page| - button = page.find_by_css("[name=increment]") - page.fire_event(button, :click) - assert_equal(page.find_by_css("output")&.inner_text, "3") - # page.debug! - page.wait_for_update - assert_equal(page.find_by_css("output")&.inner_text, "4") - # page.debug! - - assert_equal(page.to_html, <<~HTML) -
- - - 4 -
- HTML - end - end -end diff --git a/lib/mayu/vdom/children.rb b/lib/mayu/vdom/children.rb deleted file mode 100644 index dfc40cbe..00000000 --- a/lib/mayu/vdom/children.rb +++ /dev/null @@ -1,117 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "children" -require_relative "../component" -require_relative "component_marshaler" - -module Mayu - module VDOM - Slots = - T.type_alias do - T::Hash[T.nilable(String), T::Array[Interfaces::Descriptor]] - end - - class Children - extend T::Sig - extend T::Generic - include Enumerable - - include Interfaces::Children - - sig do - params( - descriptors: T::Array[Interfaces::Descriptor], - parent_type: T.untyped - ).void - end - def self.check_duplicate_keys(descriptors, parent_type: "??unknown??") - keys = descriptors.map(&:key).compact - duplicates = keys.reject { keys.rindex(_1) == keys.index(_1) }.uniq - duplicates.each do |key| - Console.logger.warn( - self, - "Duplicate keys detected: #{key.inspect}", - "This may cause an update error!", - "Parent type: #{parent_type.inspect}" - ) - end - end - - Elem = type_member { { fixed: Interfaces::Descriptor } } - - sig do - params( - descriptors: T::Array[Interfaces::Descriptor], - parent_type: T.untyped - ).void - end - def initialize(descriptors, parent_type: nil) - @descriptors = - T.let( - Descriptor::Factory.clean(descriptors, parent_type:), - T::Array[Interfaces::Descriptor] - ) - @slots = T.let(nil, T.nilable(Slots)) - end - - sig { returns(T::Array[Interfaces::Descriptor]) } - def to_a = @descriptors - - sig { params(other: T.untyped).returns(T::Boolean) } - def ==(other) - case other - when Children - @descriptors == other.to_a - when Array - @descriptors == other - else - false - end - end - - sig { returns(T::Boolean) } - def empty? = @descriptors.empty? - - sig do - override - .params( - name: T.nilable(String), - fallback: T.nilable(T.proc.returns(Interfaces::Descriptor)) - ) - .returns( - T.nilable( - T.any(Interfaces::Descriptor, T::Array[Interfaces::Descriptor]) - ) - ) - end - def slot(name = nil, &fallback) - case slots.fetch(name, []) - in [] - yield if block_given? - in [one] - one - in [*many] unless name - many - in [*many] - raise "Got #{many.size} slots one slot with name #{name.inspect}, #{many.map(&:type).inspect}" - end - end - - sig { returns(String) } - def join = @descriptors.join - - sig { returns(Slots) } - def slots = @slots ||= @descriptors.group_by(&:slot) - - sig do - override - .params(block: T.proc.params(arg0: T.untyped).returns(BasicObject)) - .returns(T.untyped) - end - def each(&block) - @descriptors.each(&block) - end - end - end -end diff --git a/lib/mayu/vdom/component_marshaler.rb b/lib/mayu/vdom/component_marshaler.rb deleted file mode 100644 index d7d594e8..00000000 --- a/lib/mayu/vdom/component_marshaler.rb +++ /dev/null @@ -1,53 +0,0 @@ -# typed: strict - -require_relative "../component" - -module Mayu - module VDOM - class ComponentMarshaler - extend T::Sig - - sig { returns(T.untyped) } - attr_reader :type - - sig { params(type: T.untyped).void } - def initialize(type) - @type = - T.let( - if Component === type - klass = T.cast(type, T.class_of(Component::Base)) - - # TODO: Would be better to do something like - # resources.resouce_for_type(klass) - if resource = - ( - begin - klass.__mayu_resource - rescue StandardError - nil - end - ) - component = resource.path - { component: } - else - { klass: klass } - end - else - type - end, - T.untyped - ) - end - - sig { returns(T.untyped) } - def marshal_dump - @type - end - - sig { params(a: T.untyped).void } - def marshal_load(a) - @type = a - end - end - end -end diff --git a/lib/mayu/vdom/css_attributes.rb b/lib/mayu/vdom/css_attributes.rb deleted file mode 100644 index 793416be..00000000 --- a/lib/mayu/vdom/css_attributes.rb +++ /dev/null @@ -1,131 +0,0 @@ -# typed: strict - -require_relative "update_context" -require_relative "vnode" - -module Mayu - module VDOM - class CSSAttributes - extend T::Sig - - # CSS properties which accept numbers but are not in units of "px". - # Copied from React: - # https://github.com/facebook/react/blob/a7c57268fb71163e4abb5e386c0d0e63290baaae/packages/react-dom/src/shared/CSSProperty.js - UNITLESS_PROPERTIES = - T.let( - [ - :animation_iteration_count, - :aspect_ratio, - :border_image_outset, - :border_image_slice, - :border_image_width, - :box_flex, - :box_flex_group, - :box_ordinal_group, - :column_count, - :columns, - :flex, - :flex_grow, - :flex_positive, - :flex_shrink, - :flex_negative, - :flex_order, - :grid_area, - :grid_row, - :grid_row_end, - :grid_row_span, - :grid_row_start, - :grid_column, - :grid_column_end, - :grid_column_span, - :grid_column_start, - :font_weight, - :line_clamp, - :line_height, - :opacity, - :order, - :orphans, - :tab_size, - :widows, - :z_index, - :zoom, - # SVG-related properties - :fill_opacity, - :flood_opacity, - :stop_opacity, - :stroke_dasharray, - :stroke_dashoffset, - :stroke_miterlimit, - :stroke_opacity, - :stroke_width - ].freeze, - T::Array[Symbol] - ) - - sig { returns(T::Hash[Symbol, T.untyped]) } - attr_reader :properties - - sig { params(properties: T.untyped).void } - def initialize(**properties) - @properties = properties - end - - sig { returns(String) } - def to_s - @properties - .map do |property, value| - format( - "%s:%s;", - transform_property(property), - transform_value(property, value) - ) - end - .join - end - - sig do - params(ctx: UpdateContext, vnode: VNode, other: CSSAttributes).void - end - def patch(ctx, vnode, other) - (properties.keys | other.properties.keys).sort.each do |property| - old_value = properties[property] - new_value = other.properties[property] - - next if old_value == new_value - - unless new_value - ctx.css(vnode, transform_property(property)) - next - end - - ctx.css( - vnode, - transform_property(property), - transform_value(property, new_value) - ) - end - end - - private - - sig { params(property: Symbol).returns(String) } - def transform_property(property) - property.to_s.tr("_", "-") - end - - sig { params(property: Symbol, value: T.untyped).returns(String) } - def transform_value(property, value) - should_apply_px?(property, value) ? "#{value}px" : value.to_s - end - - sig { params(property: Symbol, value: T.untyped).returns(T::Boolean) } - def should_apply_px?(property, value) - return false unless Integer === value - return false if UNITLESS_PROPERTIES.include?(property) - return false if property.start_with?("__") - return false if property.start_with?("--") - true - end - end - end -end diff --git a/lib/mayu/vdom/descriptor.rb b/lib/mayu/vdom/descriptor.rb deleted file mode 100644 index 29561314..00000000 --- a/lib/mayu/vdom/descriptor.rb +++ /dev/null @@ -1,151 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "interfaces" -require_relative "../component" -require_relative "component_marshaler" -require_relative "children" -require_relative "./special_elements" - -module Mayu - module VDOM - class Descriptor < T::Struct - class FactoryImpl - extend T::Sig - include Interfaces::Descriptor::Factory - - sig { override.returns(Descriptor) } - def comment - Descriptor[Interfaces::Descriptor::COMMENT] - end - - sig { override.params(text_content: T.untyped).returns(Descriptor) } - def text(text_content) - Descriptor[ - Interfaces::Descriptor::TEXT, - text_content: text_content.to_s - ] - end - - sig { override.params(obj: T.untyped).returns(Descriptor) } - def or_text(obj) - Descriptor === obj ? obj : text(obj.to_s) - end - - sig do - override - .params(children: Component::Children, parent_type: T.untyped) - .returns(T::Array[Descriptor]) - end - def clean(children, parent_type: nil) - cleaned = Array(children).flatten.select(&:itself) # Remove anything falsy - - if parent_type == :title - # can only have text children - cleaned.map { text(_1) } - else - cleaned.map { or_text(_1) } - end - end - - sig do - override - .params(descriptors: T::Array[Interfaces::Descriptor]) - .returns(T::Array[Interfaces::Descriptor]) - end - def add_comments_between_texts(descriptors) - comment = self.comment - - [*descriptors, nil].each_cons(2) - .flat_map do |curr, succ| - if curr&.text? && succ&.text? - [curr, comment] - else - curr - end - end - .compact - end - end - - Factory = T.let(FactoryImpl.new, FactoryImpl) - - extend T::Sig - include Interfaces::Descriptor - - const :type, Component::ElementType - const :props, Component::Props - const :key, T.untyped - const :slot, T.nilable(String) - - sig do - params( - type: Component::ElementType, - children: T.untyped, - props: T.untyped - ).returns(Descriptor) - end - def self.[](type, *children, **props) - type = T.let(SpecialElements.for_type(type), Component::ElementType) - - children = Children.new(children, parent_type: type) - props = props.merge(children:) - key = props.delete(:key) - slot = props.delete(:slot)&.to_s - - new(type:, key:, slot:, props:) - end - - sig { returns(T::Array[T.untyped]) } - def marshal_dump - [ComponentMarshaler.new(type), Marshalling.dump_props(props), key, slot] - end - - sig { params(a: T::Array[T.untyped]).void } - def marshal_load(a) - @type, @props, @key, @slot = a - freeze - end - - ## - # This is used for hash comparisons, - # https://ruby-doc.org/3.2.0/Hash.html#class-Hash-label-User-Defined+Hash+Keys - sig { override.params(other: T.untyped).returns(T::Boolean) } - def eql?(other) = self.class === other && same?(other) - - sig { override.returns(T::Boolean) } - def component? = Component.component_class?(@type) - - sig { override.returns(T.class_of(Component::Base)) } - def component_class - if Component.component_class?(@type) - T.cast(@type, T.class_of(Component::Base)) - else - raise "#{@type.inspect} is not a component class" - end - end - - sig { returns(String) } - def to_s - return text if text? - return "" if comment? - "#<Descriptor type=#{type.inspect}>" - end - - sig { override.params(other: Interfaces::Descriptor).returns(T::Boolean) } - 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 - end - end -end diff --git a/lib/mayu/vdom/descriptor.test.rb b/lib/mayu/vdom/descriptor.test.rb deleted file mode 100644 index 99d0e915..00000000 --- a/lib/mayu/vdom/descriptor.test.rb +++ /dev/null @@ -1,26 +0,0 @@ -# typed: true - -require "minitest/autorun" -require "test_helper" -require_relative "descriptor" - -class TestDescriptor < Minitest::Test - class MyComponent < Mayu::Component::Base - def render - end - end - - def test_element - descriptor = Mayu::VDOM::Descriptor[:foo, key: "test-key"] - assert_equal(descriptor.type, :foo) - assert_equal(descriptor.props, { children: [] }) - assert_equal(descriptor.key, "test-key") - end - - def test_component - descriptor = Mayu::VDOM::Descriptor[MyComponent, key: "test-key"] - assert_equal(descriptor.type, MyComponent) - assert_equal(descriptor.props, { children: [] }) - assert_equal(descriptor.key, "test-key") - end -end diff --git a/lib/mayu/vdom/dom.rb b/lib/mayu/vdom/dom.rb deleted file mode 100644 index 6940489f..00000000 --- a/lib/mayu/vdom/dom.rb +++ /dev/null @@ -1,239 +0,0 @@ -# typed: strict - -require "cgi" - -module Mayu - module VDOM - class DOM - extend T::Sig - - class DOMException < StandardError - end - - class CSSStyleDeclaration - extend T::Sig - - sig { void } - def initialize - @properties = T.let({}, T::Hash[String, String]) - end - - sig { params(name: String).returns(String) } - def get_property_value(name) - @properties[name].to_s - end - - sig do - params(name: String, value: T.any(String, Integer, Float)).returns( - self - ) - end - def set_property(name, value) - @properties[name] = value.to_s - self - end - - sig { params(name: String).returns(self) } - def remove_property(name) - @properties.delete(name) - self - end - - sig { returns(String) } - def to_s - @properties.map { "#{_1}: #{_2};" }.join(" ") - end - end - - class Node - extend T::Sig - - sig { returns(Integer) } - attr_reader :id - - sig { returns(T.nilable(Element)) } - attr_reader :parent - - sig { returns(T.nilable(Node)) } - def previous_sibling = parent&.find_previous_sibling(self) - - sig { returns(T.nilable(Node)) } - def next_sibling = parent&.find_next_sibling(self) - - sig { params(document: DOM).void } - def initialize(document) - @document = T.let(document, DOM) - @id = T.let(@document.next_id!, Integer) - @parent = T.let(nil, T.nilable(Element)) - end - - sig { returns(T::Boolean) } - def text? = false - sig { returns(T::Boolean) } - def element? = false - - sig { returns(String) } - def to_s = "" - - sig do - params(new_parent: T.nilable(Element)).returns(T.nilable(Element)) - end - def parent=(new_parent) - @parent&.remove_child(self) - @parent = new_parent - end - - sig { params(node: Node).returns(self) } - def append_child(node) - raise DOMException, "Can't append children to #{self.class.name}" - end - - sig do - params(new_node: Node, reference_node: T.nilable(Node)).returns(self) - end - def insert_before(new_node, reference_node = nil) - raise DOMException, "Can't insert children to #{self.class.name}" - end - - sig { params(node: Node).returns(self) } - def remove_child(node) - raise DOMException, "Can't remove children from #{self.class.name}" - end - end - - class Element < Node - sig { returns(T::Array[Node]) } - attr_reader :children - - sig { returns(CSSStyleDeclaration) } - attr_reader :style - - sig { params(document: DOM, tag_name: Symbol).void } - def initialize(document, tag_name) - super(document) - @tag_name = T.let(tag_name, Symbol) - @children = T.let([], T::Array[Node]) - @attributes = T.let({}, T::Hash[String, String]) - @style = T.let(CSSStyleDeclaration.new, CSSStyleDeclaration) - end - - sig { returns(T::Boolean) } - def element? = true - - sig { returns(String) } - def to_s - attrs = [ - %{data-mayu-id="#{id}"}, - @attributes.map { %{#{_1}="#{CGI.escape(_2)}"} } - ].flatten.join(" ") - - if children.empty? - "<#{@tag_name} #{attrs} />" - else - "<#{@tag_name} #{attrs}>#{children.join}</#{@tag_name}>" - end - end - - sig { params(name: String).returns(T.nilable(String)) } - def get_attribute(name) - @attributes[name] - end - - sig { params(name: String, value: String).void } - def set_attribute(name, value) - @attributes[name] = value - end - - sig { params(name: String).void } - def remove_attribute(name) - @attributes.delete(name) - end - - sig { params(node: Node).returns(self) } - def append_child(node) - node.parent = self - @children.push(node) - self - end - - sig do - params(new_node: Node, reference_node: T.nilable(Node)).returns(self) - end - def insert_before(new_node, reference_node = nil) - new_node.parent = self - index = @children.index(reference_node) || -1 - @children.insert(index, new_node) - self - end - - sig { params(node: Node).returns(self) } - def remove_child(node) - @children.delete(node) - self - end - - sig { params(node: Node).returns(T.nilable(Node)) } - def find_previous_sibling(node) - index = @children.index(node).to_i - @children[index.pred] if index > 1 - end - - sig { params(node: Node).returns(T.nilable(Node)) } - def find_next_sibling(node) - index = @children.index(node).to_i - @children[index.succ] if index - end - end - - class Text < Node - sig { params(document: DOM, data: String).void } - def initialize(document, data) - super(document) - @data = T.let(data, String) - end - - sig { returns(T::Boolean) } - def text? = true - - sig { returns(String) } - def to_s = @data - end - - class Comment < Node - sig { params(document: DOM, data: String).void } - def initialize(document, data) - super(document) - @data = T.let(data, String) - end - - sig { returns(T::Boolean) } - def comment? = true - - sig { returns(String) } - def to_s = "<!-- mayu-id: #{id}. #{@data} -->" - end - - sig { returns(Element) } - attr_reader :root - - sig { void } - def initialize - @id_counter = T.let(0, Integer) - @root = T.let(create_element(:html), Element) - end - - sig { params(tag_name: Symbol).returns(Element) } - def create_element(tag_name) - Element.new(self, tag_name) - end - - sig { params(data: String).returns(Text) } - def create_text_node(data) - Text.new(self, data) - end - - sig { returns(Integer) } - def next_id! = @id_counter += 1 - end - end -end diff --git a/lib/mayu/vdom/h.rb b/lib/mayu/vdom/h.rb deleted file mode 100644 index 02040a84..00000000 --- a/lib/mayu/vdom/h.rb +++ /dev/null @@ -1,22 +0,0 @@ -# typed: strict - -require "sorbet-runtime" - -module Mayu - module VDOM - module H - extend T::Sig - - sig do - params( - type: Component::ElementType, - children: T.any(Component::Children, Component::ChildType), - props: T.untyped - ).returns(Descriptor) - end - def self.[](type, *children, **props) - T.unsafe(Descriptor)[type, *children, **props] - end - end - end -end diff --git a/lib/mayu/vdom/id_generator.rb b/lib/mayu/vdom/id_generator.rb deleted file mode 100644 index 11f0e8a6..00000000 --- a/lib/mayu/vdom/id_generator.rb +++ /dev/null @@ -1,55 +0,0 @@ -# typed: strict - -module Mayu - module VDOM - class IdGenerator - extend T::Sig - - # ALPHABET = "🌱🌴🌵🌸🌺🌻🌼🌿🍀🍃" - # JOINER = "" - ALPHABET = "0123456789" - JOINER = "" - - DIGITS = T.let(ALPHABET.chars.freeze, T::Array[String]) - - Type = T.type_alias { String } - - sig { void } - def initialize - @counter = T.let(0, Integer) - end - - sig { returns(Type) } - def next! - id = @counter.tap { @counter = @counter.succ } - number_to_base(id, DIGITS.length).map { DIGITS[_1] }.join(JOINER) - end - - sig { returns(Integer) } - def marshal_dump - @counter - end - - sig { params(counter: Integer).void } - def marshal_load(counter) - @counter = counter - end - - private - - sig { params(number: Integer, base: Integer).returns(T::Array[Integer]) } - def number_to_base(number, base) - return [0] if number.zero? - - digits = [] - - until number.zero? - digits.unshift(number % base) - number /= base - end - - digits - end - end - end -end diff --git a/lib/mayu/vdom/interfaces.rb b/lib/mayu/vdom/interfaces.rb deleted file mode 100644 index f6002f85..00000000 --- a/lib/mayu/vdom/interfaces.rb +++ /dev/null @@ -1,186 +0,0 @@ -# typed: strict - -module Mayu - module VDOM - module Interfaces - module VTree - extend T::Sig - extend T::Helpers - abstract! - - sig { abstract.returns(String) } - def next_id! - end - - sig { abstract.returns(Session) } - def session - end - - sig { abstract.params(path: String).void } - def navigate(path) - end - - sig { abstract.params(type: Symbol, payload: T.untyped).void } - def action(type, payload) - end - - sig { overridable.params(vnode: T.untyped).void } - def enqueue_update!(vnode) - end - end - - module VNode - extend T::Sig - extend T::Helpers - abstract! - - sig { abstract.returns(String) } - def id - end - - sig { abstract.returns(Descriptor) } - def descriptor - end - end - - module Descriptor - module Factory - extend T::Sig - extend T::Helpers - abstract! - - sig { abstract.params(obj: T.untyped).returns(Descriptor) } - def or_text(obj) - end - - sig { abstract.params(text_content: T.untyped).returns(Descriptor) } - def text(text_content) - end - - sig { abstract.returns(Descriptor) } - def comment - end - - sig do - abstract - .params(children: Component::Children, parent_type: T.untyped) - .returns(T::Array[Descriptor]) - end - def clean(children, parent_type: nil) - end - - sig do - abstract - .params(descriptors: T::Array[Descriptor]) - .returns(T::Array[Descriptor]) - end - def add_comments_between_texts(descriptors) - end - end - - extend T::Sig - extend T::Helpers - abstract! - - TEXT = :TEXT - COMMENT = :COMMENT - - sig { overridable.returns(T::Boolean) } - def text? = type == TEXT - - sig { overridable.returns(T::Boolean) } - def comment? = type == COMMENT - - sig { overridable.returns(T::Boolean) } - def element? = type.is_a?(Symbol) - - sig { abstract.returns(T::Boolean) } - def component? - end - - sig { abstract.returns(Component::ElementType) } - def type - end - - sig { abstract.returns(Component::Props) } - def props - end - - sig { abstract.returns(T.untyped) } - def key - end - - sig { abstract.returns(String) } - def slot - end - - sig { overridable.returns(Children[Descriptor]) } - def children = props[:children] - - sig { overridable.returns(T::Boolean) } - def has_children? = children.any? - - sig { overridable.returns(String) } - def text = props[:text_content].to_s - - sig { abstract.returns(T.class_of(Component::Base)) } - def component_class - end - - sig { returns(Descriptor) } - def itself = self - - sig do - type_parameters(:R) - .params( - block: - T.proc.params(arg0: Descriptor).returns(T.type_parameter(:R)) - ) - .returns(T.type_parameter(:R)) - end - def then(&block) = yield self - - ## - # This is used for hash comparisons, - # https://ruby-doc.org/3.2.0/Hash.html#class-Hash-label-User-Defined+Hash+Keys - sig { overridable.returns(Integer) } - def hash - [type, slot, key, type == :input && props[:type]].hash - end - - ## - # This is used for hash comparisons, - # https://ruby-doc.org/3.2.0/Hash.html#class-Hash-label-User-Defined+Hash+Keys - sig { abstract.params(other: T.untyped).returns(T::Boolean) } - def eql?(other) - end - - sig do - abstract.params(other: Interfaces::Descriptor).returns(T::Boolean) - end - def same?(other) - end - end - - module Children - extend T::Sig - extend T::Helpers - extend T::Generic - include Enumerable - Elem = type_member { { upper: Descriptor } } - abstract! - - sig do - abstract - .params( - name: T.nilable(String), - fallback: T.nilable(T.proc.returns(Descriptor)) - ) - .returns(T.nilable(T.any(Descriptor, T::Array[Descriptor]))) - end - def slot(name = nil, &fallback) - end - end - end - end -end diff --git a/lib/mayu/vdom/marshalling.rb b/lib/mayu/vdom/marshalling.rb deleted file mode 100644 index 73fbdcd9..00000000 --- a/lib/mayu/vdom/marshalling.rb +++ /dev/null @@ -1,78 +0,0 @@ -# typed: strict - -module Mayu - module VDOM - module Marshalling - extend T::Sig - - sig { params(vtree: VTree).returns(String) } - def self.dump(vtree) - Marshal.dump(vtree) - end - - sig do - params(dumped: String, session: Session, task: Async::Task).returns( - VTree - ) - end - def self.restore(dumped, session:, task: Async::Task.current) - vtree = - Marshal.restore( - dumped, - ->(obj) do - case obj - when VDOM::VTree - obj.instance_variable_set(:@session, session) - obj.instance_variable_set(:@task, task) - obj - when VDOM::ComponentMarshaler - case obj.type - in klass: - klass - in component: - T.cast( - session.environment.resources.load_resource(component).type, - Resources::Types::Component - ).component - else - obj.type - end - else - obj - end - end - ) - - vtree.root&.traverse do |vnode| - vnode.instance_variable_set(:@vtree, vtree) - end - - vtree - end - - sig { params(props: Component::Props).returns(Component::Props) } - def self.dump_props(props) - props.transform_values { |value| dump_value(value) } - end - - sig { params(state: Component::State).returns(Component::State) } - def self.dump_state(state) - state.transform_values { |value| dump_value(value) } - end - - sig { params(value: T.untyped).returns(T.untyped) } - def self.dump_value(value) - case value - when Hash - value.transform_values { dump_value(_1) } - when Array - value.map { dump_value(_1) } - when Component - ComponentMarshaler.new(value) - else - value - end - end - end - end -end diff --git a/lib/mayu/vdom/reconciliation.rb b/lib/mayu/vdom/reconciliation.rb deleted file mode 100644 index 50c1dc6f..00000000 --- a/lib/mayu/vdom/reconciliation.rb +++ /dev/null @@ -1,205 +0,0 @@ -# typed: true -# frozen_string_literal: true - -require_relative "interfaces" - -module Mayu - module VDOM - module Reconciliation - class RangeIterator - def initialize(elements) - @head = 0 - @tail = elements.length.pred - @elements = elements - end - - def done? = @head > @tail - - def head = @elements[@head] - def tail = @elements[@tail] - - def tail_idx = @tail - - def next_head! = @head += 1 - def next_tail! = @tail -= 1 - - def to_a = @elements[to_range] || [] - def to_range = @head..@tail - - def [](idx) - @elements[idx] - end - - def []=(idx, value) - @elements[idx] = value - end - end - - module Patches - extend T::Sig - - class Init < T::Struct - const :descriptor, Interfaces::Descriptor - def inspect = "#{self.class.name}(#{descriptor.type.to_s})" - end - - class InsertBefore < T::Struct - const :vnode, Interfaces::VNode - const :ref, T.nilable(Interfaces::VNode) - - def inspect = - "#{self.class.name}(#{vnode.id.inspect}, #{ref&.id.inspect})" - end - - class InsertAfter < T::Struct - const :vnode, Interfaces::VNode - const :ref, T.nilable(Interfaces::VNode) - - def inspect = - "#{self.class.name}(#{vnode.id.inspect}, #{ref&.id.inspect})" - end - - class Patch < T::Struct - const :vnode, Interfaces::VNode - const :descriptor, Interfaces::Descriptor - def inspect = - "#{self.class.name}(#{vnode.id.inspect}, #{descriptor.type.to_s})" - end - - class Remove < T::Struct - const :vnode, Interfaces::VNode - def inspect = "#{self.class.name}(#{vnode.id.inspect})" - end - - Any = - T.type_alias { T.any(Init, InsertBefore, InsertAfter, Patch, Remove) } - end - - class Result < T::Struct - const :vnodes, T::Array[Interfaces::VNode] - const :patches, T::Array[Patches::Any] - end - - extend T::Sig - - sig do - params( - old_children: T::Array[Interfaces::VNode], - descriptors: T::Array[Interfaces::Descriptor], - block: - T - .proc - .params(arg0: Patches::Any) - .returns(T.nilable(Interfaces::VNode)) - ).returns(Result) - end - def self.reconcile(old_children, descriptors, &block) - # TODO: Make it possible to disable the following check in production: - Children.check_duplicate_keys(descriptors) - - grouped = old_children.group_by { _1.descriptor } - - new_children = - descriptors - .map do |descriptor| - if vnode = grouped[descriptor]&.shift - yield Patches::Patch.new(vnode:, descriptor:) - else - yield Patches::Init.new(descriptor:) - end - end - .compact - - patches = T.let([], T::Array[Patches::Any]) - - delta_time_ms = - Mayu::Utils.measure_time do - patches.concat(diff(old_children, new_children)) - - grouped.values.flatten.each do |removed| - patches << Patches::Remove.new(vnode: removed) - end - end - - if delta_time_ms > 10 - Console.logger.warn(self, "Diffing took %.3fms" % delta_time_ms) - end - - Result.new(vnodes: new_children, patches:) - end - - def self.diff(old, new) - old = old.dup - new = new.dup - - old_ids = old.map(&:id).sort - - iold = RangeIterator.new(old) - inew = RangeIterator.new(new) - - ops = [] - - until iold.done? || inew.done? - iold.next_head! and next unless iold.head - iold.next_tail! and next unless iold.tail - inew.next_head! and next unless inew.head - inew.next_tail! and next unless inew.tail - - if iold.tail.eql?(inew.tail) - iold.next_tail! - inew.next_tail! - next - end - - if iold.head.eql?(inew.head) - iold.next_head! - inew.next_head! - next - end - - if iold.head.eql?(inew.tail) - # Right move - ops << Patches::InsertAfter.new(vnode: iold.head, ref: iold.tail) - iold.next_head! - inew.next_tail! - next - end - - if iold.tail.eql?(inew.head) - # Left move - ops << Patches::InsertBefore.new(vnode: iold.tail, ref: iold.head) - inew.next_head! - iold.next_tail! - next - end - - if old_index = old.find_index { _1.eql?(inew.head) } - old[old_index] = nil - ops << Patches::InsertBefore.new(vnode: inew.head, ref: iold.head) - inew.next_head! - next - end - - ops << Patches::InsertBefore.new(vnode: inew.head, ref: iold.head) - - inew.next_head! - end - - if iold.done? - before = new[inew.tail_idx.succ] - - until inew.done? - ops << Patches::InsertBefore.new(vnode: inew.head, ref: before) - inew.next_head! - end - elsif inew.done? - iold.to_a.compact.each do |vnode| - # ops << Patches::Remove.new(vnode:) - end - end - - ops - end - end - end -end diff --git a/lib/mayu/vdom/reconciliation.test.rb b/lib/mayu/vdom/reconciliation.test.rb deleted file mode 100644 index cbbb950f..00000000 --- a/lib/mayu/vdom/reconciliation.test.rb +++ /dev/null @@ -1,56 +0,0 @@ -# typed: true - -require "minitest/autorun" -require "test_helper" -require_relative "../utils" -require_relative "reconciliation" -require_relative "descriptor" -require_relative "h" - -class Mayu::VDOM::Reconciliation::Test < Minitest::Test - class VNode < T::Struct - extend T::Sig - include Mayu::VDOM::Interfaces::VNode - - const :id, String, factory: -> { SecureRandom.alphanumeric(8) } - prop :descriptor, Mayu::VDOM::Descriptor - end - - def test_reconciliation - descriptors = 200.times.map { |i| Mayu::VDOM::H[:li, i.to_s, key: i] } - - patches = [] - - vnodes = T.let([], T::Array[VNode]) - - p Mayu::Utils.measure_time { vnodes = update(vnodes, descriptors) } - - assert_equal(vnodes.map(&:descriptor), descriptors) - - descriptors = descriptors.shuffle - - p Mayu::Utils.measure_time { vnodes = update(vnodes, descriptors) } - - descriptors = descriptors.shuffle.slice(0..100).to_a - - p Mayu::Utils.measure_time { vnodes = update(vnodes, descriptors) } - - assert_equal(vnodes.map(&:descriptor).map(&:key), descriptors.map(&:key)) - end - - def update(vnodes, descriptors) - result = - Mayu::VDOM::Reconciliation.reconcile(vnodes, descriptors) do - case _1 - in Mayu::VDOM::Reconciliation::Patches::Init => init - VNode.new(descriptor: init.descriptor) - in Mayu::VDOM::Reconciliation::Patches::Patch => patch - vnode = patch.vnode - vnode.descriptor = patch.descriptor - vnode - end - end - p(count: result.patches.length) - result.vnodes.then { T.cast(_1, T::Array[VNode]) } - end -end diff --git a/lib/mayu/vdom/special_elements.rb b/lib/mayu/vdom/special_elements.rb deleted file mode 100644 index 41292f37..00000000 --- a/lib/mayu/vdom/special_elements.rb +++ /dev/null @@ -1,108 +0,0 @@ -# typed: strict - -require_relative "./descriptor" -require_relative "../component" - -module Mayu - module VDOM - module SpecialElements - extend T::Sig - - class Head < Component::Base - sig { override.returns(T.nilable(VDOM::Descriptor)) } - def render - T.unsafe(VDOM::H)[ - :__mayu_head, - *children, - VDOM::H[:__mayu_links], - **props - ] - end - end - - class Body < Component::Base - sig { override.returns(T.nilable(VDOM::Descriptor)) } - def render - T.unsafe(VDOM::H)[ - :__mayu_body, - *children, - VDOM::H[:__mayu_scripts], - **props - ] - end - end - - class A < Component::Base - EXTERNAL_LINK_RE = T.let(%r{\A[a-z0-9]+://}, Regexp) - - sig { override.returns(T.nilable(VDOM::Descriptor)) } - def render - T.unsafe(VDOM::H)[:__mayu_a, *children, **overridden_props] - end - - private - - sig { returns(T::Hash[Symbol, T.untyped]) } - def overridden_props - if EXTERNAL_LINK_RE.match?(props[:href] || nil) - { rel: "noreferrer", external: true, **props } - elsif !props[:href] || props[:href].to_s.empty? - props - else - { **props, on_click: "Mayu.navigate(event)" } - end - end - end - - class Select < Component::Base - class InvalidNestingError < StandardError - end - - sig { override.returns(T.nilable(VDOM::Descriptor)) } - def render - value = props[:value] - - options = - Array(children).flatten.compact.map do |descriptor| - unless descriptor.type == :option - raise InvalidNestingError, - "Only option are valid children for select, you passed #{descriptor.type}" - end - - T.unsafe(VDOM::H)[ - descriptor.type, - *descriptor.children, - **descriptor.props, - key: descriptor.key, - selected: !value.nil? && value == descriptor.props[:value] - ] - end - - T.unsafe(VDOM::H)[:__mayu_select, *options, **props.except(:value)] - end - end - - MAPPINGS = - T.let( - { - head: Head, - __mayu_head: :head, - body: Body, - __mayu_body: :body, - a: A, - __mayu_a: :a, - select: Select, - __mayu_select: :select - }.freeze, - T::Hash[Symbol, Component::ElementType] - ) - - sig do - params(type: Component::ElementType).returns(Component::ElementType) - end - def self.for_type(type) - MAPPINGS.fetch(T.unsafe(type), type) - end - end - end -end diff --git a/lib/mayu/vdom/update_context.rb b/lib/mayu/vdom/update_context.rb deleted file mode 100644 index e1150898..00000000 --- a/lib/mayu/vdom/update_context.rb +++ /dev/null @@ -1,180 +0,0 @@ -# typed: strict - -module Mayu - module VDOM - class UpdateContext - extend T::Sig - - sig { returns(T::Array[T.untyped]) } - attr_reader :patches - - sig { void } - def initialize - @patches = T.let([], T::Array[T.untyped]) - @parents = T.let([], T::Array[VNode]) - @dom_parent_ids = T.let([], T::Array[VNode::Id]) - @stylesheets = T.let(Set.new, T::Set[String]) - end - - sig { returns(T.untyped) } - def stylesheet_patch - return [] if @stylesheets.empty? - - paths = @stylesheets.to_a.map { "/__mayu/static/#{_1}" } - - [{ type: :stylesheet, paths: }] - end - - sig { returns(T.nilable(VNode)) } - def parent = @parents.last - - sig { returns(VNode::Id) } - def dom_parent_id = @dom_parent_ids.last || "probably root" - - sig { params(vnode: VNode, blk: T.proc.void).void } - def enter(vnode, &blk) - # Sleep so that the fiber yields, - # so that other things can run.. - sleep(0) - - dom_parent_id = - (vnode.descriptor.element? ? vnode.id : vnode.dom_parent_id) - - @parents.push(vnode) - @dom_parent_ids.push(dom_parent_id) if dom_parent_id - yield - ensure - @dom_parent_ids.pop if dom_parent_id - @parents.pop - end - - sig do - params( - vnode: VNode, - before: T.nilable(VNode), - after: T.nilable(VNode) - ).void - end - def insert(vnode, before: nil, after: nil) - html = vnode.to_html - ids = vnode.id_tree - - if before - add_patch( - :insert, - id: vnode.dom_id, - parent: dom_parent_id, - before: before.dom_id, - html:, - ids: - ) - elsif after - add_patch( - :insert, - id: vnode.dom_id, - parent: dom_parent_id, - after: after.dom_id, - html:, - ids: - ) - else - add_patch( - :insert, - id: vnode.dom_id, - parent: dom_parent_id, - html:, - ids: - ) - end - end - - sig do - params( - vnode: VNode, - before: T.nilable(VNode), - after: T.nilable(VNode) - ).void - end - def move(vnode, before: nil, after: nil) - if before - add_patch( - :move, - id: vnode.dom_id, - parent: vnode.dom_parent_id, - before: before.dom_id - ) - elsif after - add_patch( - :move, - id: vnode.dom_id, - parent: vnode.dom_parent_id, - after: after.dom_id - ) - else - add_patch(:move, id: vnode.dom_id, parent: vnode.dom_parent_id) - end - end - - sig { params(vnode: VNode, attr: String, value: T.nilable(String)).void } - def css(vnode, attr, value = nil) - if value - add_patch(:css, id: vnode.dom_id, attr:, value:) - else - add_patch(:css, id: vnode.dom_id, attr:) - end - end - - sig { params(hash: String).void } - def stylesheet(hash) - @stylesheets.add(hash) - end - - sig { params(vnode: VNode, text: String, append: T::Boolean).void } - def text(vnode, text, append: false) - if append - add_patch(:text, id: vnode.dom_id, append: text) - else - add_patch(:text, id: vnode.dom_id, text:) - end - end - - sig { params(vnode: VNode).void } - def remove(vnode) - if vnode.component - if child = vnode.children.first - return remove(child) - end - end - # puts "\e[31mremove\e[0m #{vnode.key}" - add_patch(:remove, id: vnode.dom_id, parent: vnode.dom_parent_id) - end - - sig { params(vnode: VNode, name: String, value: String).void } - def set_attribute(vnode, name, value) - add_patch(:attr, id: vnode.dom_id, name: attr_name(name), value:) - end - - sig { params(vnode: VNode, name: String).void } - def remove_attribute(vnode, name) - add_patch(:attr, id: vnode.dom_id, name: attr_name(name)) - end - - private - - sig { params(type: Symbol, args: T.untyped).void } - def add_patch(type, **args) - # puts "\e[35;5mXXXXXX \e[33m#{type}:\e[0m #{args.inspect}" - @patches.push(args.merge(type:)) - end - - sig { params(attr: T.any(String, Symbol)).returns(String) } - def attr_name(attr) - attr - .to_s - .sub(/^on_/, "on") - .sub(/\Ainitial_value\Z/, "value") - .tr("_", "-") - end - end - end -end diff --git a/lib/mayu/vdom/vdom.perf.test.rb b/lib/mayu/vdom/vdom.perf.test.rb deleted file mode 100644 index cf9fb2ba..00000000 --- a/lib/mayu/vdom/vdom.perf.test.rb +++ /dev/null @@ -1,149 +0,0 @@ -# typed: false - -require "minitest/autorun" -require "test_helper" -require "async" -require "rexml/document" -require "stringio" -require "ruby-prof" -require_relative "vtree" -require_relative "h" -require_relative "../session" -require_relative "../commands" -require_relative "../app_metrics" - -class Mayu::VDOM::PerformanceTest < Minitest::Test - H = Mayu::VDOM::H - - class Item < Mayu::Component::Base - def render - H[:li, H[:a, props[:children].to_a, href: props[:path]]] - end - end - - class MyComponent < Mayu::Component::Base - def self.get_initial_state(**props) - { page: 0 } - end - - def handle_next_page(e) - update { |state| { page: state[:page].succ } } - end - - def render - per_page = 50 - items = props[:items].slice(state[:page] * per_page, per_page) - - H[ - :div, - (H[:button, on_click: handler(:handle_next_page)] unless items.empty?), - H[:ul, items.map { H[Item, _1, key: _1, path: "/#{_1}"] }] - ] - end - end - - def test_perf - items = 2000.times.map { SecureRandom.alphanumeric(5 + rand(10)) } - - Async do - vtree = setup_vtree - app = H[MyComponent, items:] - vtree.render(app) - vtree.to_html.tap { |html| print_xml(html) } - - # https://ruby-prof.github.io/#measurements - - profile = - RubyProf::Profile.new( - track_allocations: true, - measure_mode: RubyProf::WALL_TIME - # measure_mode: RubyProf::PROCESS_TIME, - # measure_mode: RubyProf::ALLOCATIONS, - # measure_mode: RubyProf::MEMORY, - ) - profile.exclude_methods!(T::Types::Union, :recursively_valid?) - profile.exclude_methods!(T::Types::FixedArray, :initialize) - profile.exclude_methods!(T::Props::WeakConstructor, :initialize) - # profile.exclude_methods!(T::Props::Constructor::DecoratorMethods, :construct_props_without_defaults) - profile.exclude_methods!(T::Types::TypedEnumerable, :recursively_valid?) - # profile.exclude_methods!(T::Private::Methods::Signature, :initialize) - - result = - profile.profile do - while handler_ref = - vtree.instance_variable_get(:@handlers).values.first - handler_ref.call({}) - update_vtree(vtree) - end - end - - printer = RubyProf::MultiPrinter.new(result) - path = File.join(Mayu::TestHelper::ROOT, "profile") - FileUtils.mkdir_p(path) - printer.print(path:, profile: File.basename(__FILE__, ".*")) - - vtree.render(app) - vtree.to_html.tap { |html| print_xml(html) } - end - end - - private - - def update_vtree(vtree) - ctx = Mayu::VDOM::UpdateContext.new - - vtree.update_queue.size.times do - case vtree.update_queue.dequeue - in Mayu::VDOM::VNode => vnode - next if vnode.removed? - vtree.patch(ctx, vnode, vnode.descriptor, lifecycles: false) - else - # ok - end - end - - ctx - end - - def setup_vtree - $metrics ||= Mayu::AppMetrics.setup(Prometheus::Client.registry) - config = - Mayu::Configuration.from_hash!( - { "mode" => :test, "root" => "/laiehbaleihf", "secret_key" => "test" } - ) - environment = Mayu::Environment.new(config, $metrics) - - environment.instance_eval do - def load_root(path, headers: {}) - H[:div] - end - def match_route(path) - end - end - - session = Mayu::Session.new(environment:, path: "/") - Mayu::VDOM::VTree.new(session:) - end - - def print_xml(source) - io = StringIO.new - doc = REXML::Document.new(source) - formatter = REXML::Formatters::Pretty.new - formatter.compact = true - formatter.write(doc, io) - io.rewind - - puts( - io - .read - .to_s - .gsub(/(mayu-id='?)(\d+)/) { "#{$~[1]}\e[1;34m#{$~[2]}\e[0m" } - .gsub(/(mayu-key='?)(\d+)/) { "#{$~[1]}\e[1;35m#{$~[2]}\e[0m" } - .gsub(/>(.*?)</) { ">\e[33m#{$~[1]}\e[0m<" } - ) - end - - def extract_numbers(source) - REXML::Document.new(source).get_elements("//li").map(&:text).map(&:to_i) - end -end diff --git a/lib/mayu/vdom/vnode.rb b/lib/mayu/vdom/vnode.rb deleted file mode 100644 index e302d40e..00000000 --- a/lib/mayu/vdom/vnode.rb +++ /dev/null @@ -1,266 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "../component" -require_relative "descriptor" -require_relative "dom" -require_relative "interfaces" -require_relative "id_generator" -require_relative "../html" - -module Mayu - module VDOM - class VNode < T::Struct - extend T::Sig - include Interfaces::VNode - - Children = T.type_alias { T::Array[VNode] } - Id = T.type_alias { IdGenerator::Type } - - sig do - params( - vtree: VTree, - dom_parent_id: Id, - descriptor: Interfaces::Descriptor, - task: Async::Task - ).returns(VNode) - end - def self.build( - vtree, - dom_parent_id, - descriptor, - task: Async::Task.current - ) - new(id: vtree.next_id!, vtree:, dom_parent_id:, descriptor:, task:) - end - - const :id, Id - const :vtree, Interfaces::VTree - const :dom_parent_id, Id - prop :descriptor, Interfaces::Descriptor - const :task, Async::Task, factory: -> { Async::Task.current } - prop :children, Children, default: [] - prop :removed, T::Boolean, default: false - prop :wrapper, T.nilable(Component::Wrapper) - alias component wrapper - - sig { returns(Component::ElementType) } - def type = descriptor.type - sig { returns(Component::Props) } - def props = descriptor.props - sig { returns(T.untyped) } - def key = descriptor.key - - sig { returns(Id) } - def dom_id = (wrapper ? children.first&.dom_id || "root" : id) - sig { returns(T::Boolean) } - def dom? = type.is_a?(Symbol) - - sig { returns(T::Boolean) } - def removed? = @removed - sig { void } - def remove! = @removed = true - sig { returns(T::Boolean) } - - def assert_not_removed! - return true unless removed? - raise "VNode is marked as removed and should not be used!" - end - - sig { returns(T.untyped) } - def marshal_dump - assert_not_removed! - [@id, @dom_parent_id, @wrapper, @children, @descriptor] - end - - sig { params(a: T.untyped).void } - def marshal_load(a) - @id, @dom_parent_id, @wrapper, @children, @descriptor = a - @removed = false - - if @wrapper - @wrapper.instance_variable_set(:@vnode, self) - instance = descriptor.component_class.new(@wrapper) - @wrapper.instance_variable_set(:@instance, instance) - instance.instance_variable_set(:@wrapper, @wrapper) - @wrapper.instance_variable_set( - :@helpers, - Component::Helpers.new(@wrapper) - ) - end - end - - sig { params(block: T.proc.params(vnode: VNode).void).void } - def traverse(&block) - yield self - - children.each { |child| child.traverse(&block) } - end - - sig do - params( - url: String, - method: Symbol, - headers: T::Hash[String, String], - body: T.nilable(String) - ).returns(Fetch::Response) - end - def fetch(url, method: :GET, headers: {}, body: nil) - @vtree.session.fetch(url, method:, headers:, body:) - end - - # sig { returns(Mayu::State::Store) } - # def store = @vtree.session.store - - sig { returns(T.nilable(Component::Wrapper)) } - def init_component - @wrapper ||= Component.wrap(self, type, props) - end - - sig { params(path: String).void } - def navigate(path) - @vtree.navigate(path) - end - - sig { params(type: Symbol, payload: T.untyped).void } - def action(type, payload) - @vtree.action(type, payload) - end - - sig { void } - def enqueue_update! - @vtree.enqueue_update!(self) - end - - sig { params(descriptor: Interfaces::Descriptor).returns(T::Boolean) } - def same?(descriptor) - self.descriptor.eql?(descriptor) - end - - sig { params(other: T.untyped).returns(T::Boolean) } - def eql?(other) - self.class === other && other.id == id - end - - sig { returns(T.untyped) } - def id_tree - children = Array(self.children).flatten.compact - - return children.first&.id_tree if component - - if children.empty? - { id:, type: type.to_s } - else - { id:, ch: children.map(&:id_tree), type: type.to_s } - end - end - - sig { returns(String) } - def inspect - "<#VNode:#{id} type=#{descriptor.type} key=#{descriptor.key}>" - end - - sig { returns(String) } - def to_html - T.must(StringIO.new.tap { write_html(_1) }.tap(&:rewind).read) - end - - Writable = - T.type_alias do - T.any(Async::HTTP::Body::Writable, Brotli::Writer, StringIO) - end - - sig { params(io: Writable, opts: T.untyped).void } - def write_html(io, **opts) - type = descriptor.type - - case type - when Mayu::VDOM::Interfaces::Descriptor::TEXT - text = descriptor.text - if text.empty? - # A zero-width-space will generate a text node in the DOM. - io.write("​") - else - io.write(CGI.escape_html(descriptor.text)) - end - return - when Mayu::VDOM::Interfaces::Descriptor::COMMENT - io.write("<!--mayu-id=#{@id}-->") - return - when :__mayu_links - io.write(opts[:links].to_s) - return - when :__mayu_scripts - io.write(opts[:scripts].to_s) - return - end - - cleaned_children = children - - if descriptor.component? - cleaned_children.each { _1.write_html(io, **opts) } - return - end - - io.write("<#{type}") - - io.write(%< data-mayu-id="#{@id.to_s}">) - - format_props { |formatted_prop| io.write(formatted_prop) } - - io.write(">") - - return if type.is_a?(Symbol) && Mayu::HTML.void_tag?(type) - - cleaned_children.each { _1.write_html(io, **opts) } - - io.write("</#{type}>") - end - - sig { params(block: T.proc.params(arg0: String).void).void } - def format_props(&block) - props.each do |prop, value| - next unless value - next if prop == :children - next if prop == :slot - next if value == "" - - if value.is_a?(Hash) - if prop == :style - yield format_attr(prop, CSSAttributes.new(**value).to_s) - else - Utils - .flatten_props(value, [prop.to_s]) - .each { yield format_prop(_1, _2) } - end - next - end - - yield format_prop(prop, value) - end - end - - sig do - params(attr: T.any(String, Symbol), value: T.untyped).returns(String) - end - def format_attr(attr, value) - format( - %{ %<attr>s="%<value>s"}, - attr: attr.to_s, - value: CGI.escape_html(value.to_s) - ) - end - - sig { params(prop: Symbol, value: T.untyped).returns(T.untyped) } - def format_prop(prop, value) - value = prop.to_s if value == true - - prop = :value if prop == :initial_value - - attr = prop.to_s.sub(/^on_/, "on").tr("_", "-") - - format_attr(attr, value) - end - end - end -end diff --git a/lib/mayu/vdom/vtree.rb b/lib/mayu/vdom/vtree.rb deleted file mode 100644 index 8e14058d..00000000 --- a/lib/mayu/vdom/vtree.rb +++ /dev/null @@ -1,672 +0,0 @@ -# typed: strict - -require "async/queue" -require "nanoid" -require "benchmark" -require_relative "../component" -require_relative "interfaces" -require_relative "descriptor" -require_relative "dom" -require_relative "vnode" -require_relative "css_attributes" -require_relative "update_context" -require_relative "id_generator" -require_relative "../session" -require_relative "../ref_counter" -require_relative "../utils" -require_relative "./reconciliation" - -module Mayu - module VDOM - class VTree - extend T::Sig - include Interfaces::VTree - - class Updater - extend T::Sig - - # This value limits how many updates per second we can make. - DEFAULT_UPDATES_PER_SECOND = 20 - - sig { params(vtree: VTree, updates_per_second: Integer).void } - def initialize(vtree, updates_per_second: DEFAULT_UPDATES_PER_SECOND) - @vtree = vtree - @updates_per_second = updates_per_second - end - - sig do - params( - metrics: T.nilable(AppMetrics), - task: Async::Task, - block: T.proc.params(arg0: [Symbol, T.untyped]).void - ).returns(Async::Task) - end - def run(metrics: nil, task: Async::Task.current, &block) - task.async(annotation: "VTree updater") do |task| - assets = T::Set[String].new - - loop do - @vtree.update_queue.wait while @vtree.update_queue.empty? - - start_at = Time.now - - update(assets:, metrics:) do |event, payload| - yield [event, payload] - end - - delta_time_ms = (Time.now - start_at) * 1000 - - # TODO: Make this configurable.. - # should also be in the prometheus output - if delta_time_ms > 50 - Console.logger.warn( - self, - "Rendering took %.3fms" % delta_time_ms - ) - end - - yield [:update_finished, delta_time_ms:] - - sleep 1.0 / @updates_per_second - end - rescue => e - puts e.message - puts e.backtrace - error = { - type: e.class.name, - message: e.message, - backtrace: e.backtrace - } - - yield [:exception, error] - end - end - - sig do - params(stylesheets: T::Array[String]).returns(T::Array[T.untyped]) - end - def stylesheet_patch(stylesheets) - return [] if stylesheets.empty? - - paths = stylesheets.map { "/__mayu/static/#{_1}" } - - [{ type: :stylesheet, paths: }] - end - - sig do - params( - assets: T::Set[String], - metrics: T.nilable(AppMetrics), - block: T.proc.params(arg0: [Symbol, T.untyped]).void - ).void - end - def update(assets: T::Set[String].new, metrics: nil, &block) - ctx = UpdateContext.new - - @vtree.update_queue.size.times do - case @vtree.update_queue.dequeue - in [:replace_root, descriptor] - @vtree.render(descriptor, ctx:) - in [:navigate, path] - yield [:navigate, path] - in [:action, payload] - yield [:action, payload] - in [:exception, error] - yield [:exception, error] - in [:pong, timestamp] - yield [:pong, timestamp] - in VNode => vnode - next if vnode.removed? - next unless vnode.component&.dirty? - - if metrics - type = vnode.descriptor.type - vnode_type = - if type.respond_to?(:__mayu_resource) && - resource = type.__mayu_resource - resource.path - else - type.inspect[0..10].to_s - end - - metrics.vnode_patch_times.observe( - Benchmark.realtime do - @vtree.patch(ctx, vnode, vnode.descriptor, lifecycles: true) - end, - labels: { - vnode_type: - } - ) - else - @vtree.patch(ctx, vnode, vnode.descriptor, lifecycles: true) - end - end - end - - stylesheets = [] - - @vtree.assets.each do |asset| - next if assets.include?(asset) - next unless asset.end_with?(".css") - stylesheets.push(asset) - assets.add(asset) - end - - patches = [*stylesheet_patch(stylesheets), *ctx.patches] - yield [:patch, patches] unless patches.empty? - - @vtree.cleanup_unused_handlers! - end - end - - sig { override.returns(Session) } - attr_reader :session - - sig { returns(Async::Queue) } - attr_reader :update_queue - sig { returns(T.nilable(VNode)) } - attr_reader :root - - sig { returns(T::Set[String]) } - attr_reader :assets - - sig { params(session: Session, task: Async::Task).void } - def initialize(session:, task: Async::Task.current) - @root = T.let(nil, T.nilable(VNode)) - @id_generator = T.let(IdGenerator.new, IdGenerator) - @session = T.let(session, Session) - - @handlers = T.let({}, T::Hash[String, Component::HandlerRef]) - @handler_counts = T.let(RefCounter.new, RefCounter[String]) - - @update_queue = T.let(Async::Queue.new, Async::Queue) - - @update_semaphore = - T.let(Async::Semaphore.new(parent: task), Async::Semaphore) - - @assets = T.let(Set.new, T::Set[String]) - end - - sig { returns(T::Array[T.untyped]) } - def marshal_dump - [@root, @id_generator, @assets] - end - - sig { params(a: T::Array[T.untyped]).void } - def marshal_load(a) - @root, @id_generator, @assets = a - @handlers = {} - @handler_counts = RefCounter.new - @update_queue = Async::Queue.new - @update_semaphore = Async::Semaphore.new - @assets = Set.new - @root.instance_variable_set(:@vtree, self) - end - - sig do - params( - descriptor: Interfaces::Descriptor, - ctx: UpdateContext, - lifecycles: T::Boolean - ).returns(UpdateContext) - end - def render(descriptor, ctx: UpdateContext.new, lifecycles: true) - start_at = Time.now - @root = patch(ctx, @root, descriptor, lifecycles:) - ctx - end - - sig { params(descriptor: Interfaces::Descriptor).void } - def replace_root(descriptor) - @update_queue.enqueue([:replace_root, descriptor]) - end - - sig { params(handler_id: String, payload: T.untyped).void } - def handle_callback(handler_id, payload = {}) - case handler_id - when "ping" - @update_queue.enqueue([:pong, payload[:timestamp]]) - return - when "navigate" - navigate(payload[:path]) - return - end - - @handlers - .fetch(handler_id) do - raise KeyError, "Handler not found: #{handler_id}" - end - .call(payload) - rescue => e - puts e.message - puts e.backtrace - error = { - type: e.class.name, - message: e.message, - backtrace: e.backtrace - } - @update_queue.enqueue([:exception, error]) - end - - sig { returns(String) } - def to_html - @root&.to_html.to_s - end - - sig { returns(T.untyped) } - def id_tree - @root&.id_tree - end - - sig { override.params(vnode: VNode).void } - def enqueue_update!(vnode) - component = vnode.component - return unless component - return if component.dirty? - - component.dirty! - @update_queue.enqueue(vnode) - end - - sig { override.returns(IdGenerator::Type) } - def next_id! = @id_generator.next! - - sig { override.params(path: String).void } - def navigate(path) - @update_queue.enqueue([:navigate, path]) - end - - sig { override.params(type: Symbol, payload: T.untyped).void } - def action(type, payload) - @update_queue.enqueue([:action, { type:, payload: }]) - end - - sig do - params( - ctx: UpdateContext, - vnode: T.nilable(VNode), - descriptor: T.nilable(Interfaces::Descriptor), - lifecycles: T::Boolean - ).returns(T.nilable(VNode)) - end - def patch(ctx, vnode, descriptor, lifecycles:) - unless vnode - return nil unless descriptor - - vnode = init_vnode(ctx, descriptor, lifecycles:) - ctx.insert(vnode) - return vnode - end - - return remove_vnode(ctx, vnode, lifecycles:) unless descriptor - - if vnode.descriptor.same?(descriptor) - patch_vnode(ctx, vnode, descriptor, lifecycles:) - else - remove_vnode(ctx, vnode, lifecycles:) - vnode = init_vnode(ctx, descriptor, lifecycles:) - ctx.insert(vnode) - return vnode - end - end - - sig { void } - def cleanup_unused_handlers! - @handlers.delete_if do |id, handler| - if @handler_counts.count(id).zero? - Console.logger.warn(self, "Removing handler #{id}") - true - end - end - end - - private - - sig do - params( - ctx: UpdateContext, - vnode: VNode, - descriptor: Interfaces::Descriptor, - lifecycles: T::Boolean - ).returns(VNode) - end - def patch_vnode(ctx, vnode, descriptor, lifecycles:) - unless vnode.descriptor.same?(descriptor) - raise "Can not patch different types!" - end - - if component = vnode.component - if component.should_update?(descriptor.props, component.next_state) - vnode.descriptor = descriptor - prev_props, prev_state = component.props, component.state - component.props = descriptor.props - component.state = component.next_state.clone - - descriptors = clean_children(component.render, parent: descriptor) - - ctx.enter(vnode) do - vnode.children = - update_children( - ctx, - vnode.children.compact, - descriptors, - lifecycles: - ) - end - - update_stylesheet(ctx, component) - - component.did_update(prev_props, prev_state) if lifecycles - end - - return vnode - end - - type = descriptor.type - - if type.is_a?(Proc) - vnode.descriptor = descriptor - descriptors = Array(type.call(**descriptor.props)).compact - - ctx.enter(vnode) do - vnode.children = - update_children( - ctx, - vnode.children.compact, - descriptors, - lifecycles: - ) - end - - return vnode - end - - return vnode if vnode.descriptor == descriptor - - if descriptor.text? - unless vnode.descriptor.text == descriptor.text - if append = append_part(vnode.descriptor.text, descriptor.text) - ctx.text(vnode, append, append: true) - else - ctx.text(vnode, descriptor.text) - end - vnode.descriptor = descriptor - return vnode - end - else - if vnode.descriptor.has_children? && descriptor.has_children? - if vnode.descriptor.children != descriptor.children - ctx.enter(vnode) do - vnode.children = - update_children( - ctx, - vnode.children, - descriptor.children.to_a, - lifecycles: - ) - end - end - elsif descriptor.has_children? - ctx.enter(vnode) do - vnode.children = - clean_children( - descriptor.children.to_a, - parent: descriptor - ).map do - init_vnode(ctx, _1, lifecycles:).tap do |child| - ctx.insert(child) - end - end - end - elsif vnode.children.length > 0 - ctx.enter(vnode) do - vnode.children.each { remove_vnode(ctx, _1, lifecycles:) } - end - vnode.children = [] - elsif vnode.descriptor.text? - ctx.text(vnode, "") - else - # Everything seems to be exactly the same - end - end - - update_handlers(vnode.props, descriptor.props) - - update_attributes( - ctx, - vnode, - Utils.flatten_props(vnode.props), - Utils.flatten_props(descriptor.props) - ) - - vnode.descriptor = descriptor - - vnode - end - - sig do - params( - ctx: UpdateContext, - vnodes: T::Array[VNode], - lifecycles: T::Boolean - ).returns(NilClass) - end - def remove_vnodes(ctx, vnodes, lifecycles:) - vnodes.each { |vnode| remove_vnode(ctx, vnode, lifecycles:) } - nil - end - - sig { params(ctx: UpdateContext, component: Component::Wrapper).void } - def update_stylesheet(ctx, component) - # TODO: Make this more generic.. - # This only works with CSS right now. - # Images could also be preloaded. - # https://web.dev/preload-responsive-images/ - component.assets.each { |asset| @assets.add(asset) } - end - - sig do - params( - ctx: UpdateContext, - descriptor: Interfaces::Descriptor, - lifecycles: T::Boolean, - nested: T::Boolean - ).returns(VNode) - end - def init_vnode(ctx, descriptor, lifecycles:, nested: false) - vnode = VNode.build(self, ctx.dom_parent_id, descriptor) - - component = vnode.init_component - - children = - if component - Array(component.render).compact - else - descriptor.props[:children].to_a - end - - update_stylesheet(ctx, component) if component - # puts "\e[32mInitializing vnode #{vnode.id} #{vnode.descriptor.type} with #{children.length} children\e[0m" - - unless children.empty? - ctx.enter(vnode) do - vnode.children = - clean_children(children, parent: descriptor).map do - init_vnode(ctx, _1, lifecycles:, nested: true) - end - end - end - - vnode.component&.mount if lifecycles - - update_handlers({}, vnode.props) - - vnode - end - - EMPTY_HASH = T.let({}.freeze, T::Hash[T.untyped, T.untyped]) - - sig do - params( - ctx: UpdateContext, - vnode: VNode, - lifecycles: T::Boolean, - patch: T::Boolean - ).returns(NilClass) - end - def remove_vnode(ctx, vnode, lifecycles:, patch: true) - # puts "\e[31mRemoving vnode #{vnode.id} #{vnode.descriptor.type}\e[0m" - - vnode.component&.unmount if lifecycles - vnode.remove! - ctx.remove(vnode) if patch - vnode.children.map { remove_vnode(ctx, _1, lifecycles:, patch: false) } - update_handlers(vnode.props, EMPTY_HASH) - nil - end - - sig do - params(vnode: VNode, descriptor: Interfaces::Descriptor).returns( - T::Boolean - ) - end - def same?(vnode, descriptor) - vnode.descriptor.same?(descriptor) - end - - sig do - params( - ctx: UpdateContext, - vnodes: T::Array[VNode], - descriptors: T::Array[Interfaces::Descriptor], - lifecycles: T::Boolean - ).returns(T::Array[VNode]) - end - def update_children(ctx, vnodes, descriptors, lifecycles:) - initialized = T.let([], T::Array[VNode::Id]) - - result = - Reconciliation.reconcile(vnodes, descriptors) do - case _1 - in Reconciliation::Patches::Init => init - vnode = init_vnode(ctx, init.descriptor, lifecycles:) - initialized.push(vnode.id) - vnode - in Reconciliation::Patches::Patch => patch - patch_vnode(ctx, patch.vnode, patch.descriptor, lifecycles:) - end - end - - result.patches.each do |patch| - case patch - in Reconciliation::Patches::InsertBefore => insert - if initialized.delete(insert.vnode.id) - ctx.insert(insert.vnode, before: insert.ref) - else - ctx.move(insert.vnode, before: insert.ref) - end - nil - in Reconciliation::Patches::InsertAfter => insert - if initialized.delete(insert.vnode.id) - ctx.insert(insert.vnode, after: insert.ref) - else - ctx.move(insert.vnode, after: insert.ref) - end - nil - in Reconciliation::Patches::Remove => remove - remove_vnode(ctx, remove.vnode, lifecycles:) - nil - end - end - - T.cast(result.vnodes, T::Array[VNode]) - end - - sig do - params(old_props: Component::Props, new_props: Component::Props).void - end - def update_handlers(old_props, new_props) - old_handlers = old_props.keys.select { _1.start_with?("on") } - new_handlers = new_props.keys.select { _1.start_with?("on") } - - # FIXME: If the same handler id is used somewhere else, - # it will be cleared too. The id needs to include the attribute - # to be unique. Then we can also remove @handler_counts. - removed_handlers = old_handlers - new_handlers - - old_props - .values_at(*T.unsafe(removed_handlers)) - .select { _1.is_a?(Component::HandlerRef) } - .each { |handler| @handler_counts.release(handler.id) } - - new_props - .values_at(*T.unsafe(new_handlers)) - .select { _1.is_a?(Component::HandlerRef) } - .each do |handler| - @handlers[handler.id] = handler - @handler_counts.acquire!(handler.id) - end - end - - sig do - params( - ctx: UpdateContext, - vnode: VNode, - old_props: Component::Props, - new_props: Component::Props - ).void - end - def update_attributes(ctx, vnode, old_props, new_props) - removed = old_props.keys - new_props.keys - [:children] - - new_props.each do |attr, value| - next if attr == :children - next if attr == :slot - - old_value = old_props[attr] - - next if value == old_props[attr] - - removed.push(attr) and next unless value - removed.push(attr) and next if value == "" - - if attr == :style && old_value.is_a?(Hash) && value.is_a?(Hash) - CSSAttributes.new(**old_value).patch( - ctx, - vnode, - CSSAttributes.new(**value) - ) - next - end - - if value == true - ctx.set_attribute(vnode, attr.to_s, attr.to_s) - else - ctx.set_attribute(vnode, attr.to_s, value.to_s) - end - end - - removed.uniq.each { |attr| ctx.remove_attribute(vnode, attr.to_s) } - end - - sig { params(str1: String, str2: String).returns(T.nilable(String)) } - def append_part(str1, str2) - return nil if str1.strip.empty? || str1.length >= str2.length - return nil unless str2.slice(0...str1.length) == str1 - str2.slice(str1.length..-1) - end - - sig do - params( - children: Component::Children, - parent: Interfaces::Descriptor - ).returns(T::Array[Interfaces::Descriptor]) - end - def clean_children(children, parent:) - children - .then { Descriptor::Factory.clean(_1, parent_type: parent) } - .then { Descriptor::Factory.add_comments_between_texts(_1) } - end - end - end -end diff --git a/lib/mayu/vdom/vtree.test.rb b/lib/mayu/vdom/vtree.test.rb deleted file mode 100644 index 8b07b6cc..00000000 --- a/lib/mayu/vdom/vtree.test.rb +++ /dev/null @@ -1,68 +0,0 @@ -# typed: true - -require "minitest/autorun" -require "test_helper" -require "async" -require "rexml/document" -require "stringio" -require_relative "vtree" -require_relative "../session" -require_relative "../app_metrics" - -class TestVTree < Minitest::Test - H = Mayu::VDOM::Descriptor - - MyComponent = Mayu::TestHelper.haml_to_component(__FILE__, __LINE__, <<~HAML) - %div - %h1 Hola mundo #{@lol ||= rand} - %pre= props[:count] - HAML - - def test_component_reuse - Mayu::TestHelper.test_component(MyComponent, count: 0) do |page| - # page.debug! - original_text = page.find_by_css("h1")&.inner_text - - page.render(H[MyComponent, count: 1]) - page.wait_for_update - # page.debug! - assert_equal(page.find_by_css("h1")&.inner_text, original_text) - - page.render(H[MyComponent, count: 2]) - page.wait_for_update - - # page.debug! - assert_equal(page.find_by_css("h1")&.inner_text, original_text) - end - end - - def test_list_ordering - component = Mayu::TestHelper.haml_to_component(__FILE__, __LINE__, <<~HAML) - %div - %h1 Hello world - %ul - = props[:numbers].map do |num| - %li(key=num)= num - HAML - - number_lists = [ - [0, 2, 1, 6, 7, 8, 4, 3, 5], - [1, 7, 6, 5, 3, 0, 2, 4], - [1, 3, 123, 0, 4, 2, 9, 32, 455] - ] - - Mayu::TestHelper.test_component(component, numbers: []) do |page| - number_lists.each do |numbers| - page.render(H[component, numbers:]) - assert_equal(numbers, extract_numbers(page.to_html)) - # page.debug! - end - end - end - - private - - def extract_numbers(source) - REXML::Document.new(source).get_elements("//li").map(&:text).map(&:to_i) - end -end diff --git a/mayu-live.gemspec b/mayu-live.gemspec index cbe52d24..2ab13ed4 100644 --- a/mayu-live.gemspec +++ b/mayu-live.gemspec @@ -42,37 +42,34 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Core dependencies - spec.add_dependency "async", "~> 2.8.0" - spec.add_dependency "async-container", "~> 0.16.12" - spec.add_dependency "async-http", "~> 0.61.0" + # Core + spec.add_dependency "async", "~> 2.8" + spec.add_dependency "async-http", "~> 0.62.0" + spec.add_dependency "async-io", "~> 1.41" + spec.add_dependency "base64", "~> 0.2.0" + spec.add_dependency "toml", "~> 0.3.0" + + # Server spec.add_dependency "brotli", "~> 0.4.0" - spec.add_dependency "mime-types", "~> 3.4.1" - spec.add_dependency "msgpack", "~> 1.6.0" - spec.add_dependency "nanoid", "~> 2.0.0" - spec.add_dependency "prometheus-client", "~> 4.0.0" - spec.add_dependency "protocol-http", "~> 0.25.0" - spec.add_dependency "pry", "~> 0.14.2" + spec.add_dependency "msgpack", "~> 1.7" spec.add_dependency "rack", ">= 3.0.4.1", "< 3.0.10.0" - spec.add_dependency "rake", "~> 13.0.6" - spec.add_dependency "rbnacl", "~> 7.1.1" - spec.add_dependency "sorbet-runtime", "~> 0.5.10634" - spec.add_dependency "terminal-table", "~> 3.0.2" - spec.add_dependency "toml-rb", "~> 2.2.0" + spec.add_dependency "rbnacl", "~> 7.1" # Development - spec.add_dependency "listen", "~> 3.7.1" - spec.add_dependency "localhost", "~> 1.1.9" + spec.add_dependency "filewatcher", "~> 2.1" + spec.add_dependency "localhost", "~> 1.1" + spec.add_dependency "minitest", "~> 5.21" + spec.add_dependency "nokogiri", "~> 1.16" + spec.add_dependency "pry", "~> 0.14.2" + spec.add_dependency "rouge", "~> 4.2" - # Build - spec.add_dependency "image_size", "~> 3.2.0" - spec.add_dependency "kramdown", "~> 2.4.0" - spec.add_dependency "rouge", "~> 4.0.0" + # Modules + spec.add_dependency "image_size", "~> 3.4" spec.add_dependency "mayu-css", "~> 0.1.2" - spec.add_dependency "rmagick", "~> 5.3.0" - spec.add_dependency "source_map", "~> 3.0.1" - spec.add_dependency "syntax_tree", "~> 5.3.0" - spec.add_dependency "syntax_tree-haml", "~> 3.0.0" + spec.add_dependency "mime-types", "~> 3.5" + spec.add_dependency "rake", "~> 13.1" + spec.add_dependency "syntax_tree", "~> 6.2" + spec.add_dependency "syntax_tree-haml", "~> 4.0" spec.add_dependency "syntax_tree-xml", "~> 0.1.0" - spec.add_dependency "base64", "~> 0.2.0" + spec.add_dependency "tsort", "~> 0.2.0" end