From ca0aaa2cd9cd5c22eb653af820c0e437fa4d9f2b Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Sat, 16 Oct 2021 12:34:19 +1100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20plugins=20system=20(#?= =?UTF-8?q?62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): ✨ added fundamentals of plugins system This also adds plugin examples to the `basic` example, which will be removed before merging. * feat(plugins): ✨ added nearly all functional actions and improved plugins framework * feat(plugins): ✨ added control plugin opportunities * fix(plugins): 🔥 removed plugin actions for setting html shell and static dir paths They were hell for the deployment process, and didn't add any value. * chore: ♻️ cleaned up old todos and renamed a new checkpoint * docs(plugins): 📝 added introductory plugin docs * feat(plugins): ✨ added settings actions for plugins We can't modify `MutableStore` or `TranslationsManager` yet though. BREAKING CHANGE: `build_app`/`export_app`now take a `&TemplateMap` (`get_templates_vec` abolished) * feat(plugins): ✨ added `tinker` action and command * feat(examples): ✨ added `plugins` example and removed plugins code from other examples This includes tests. * fix(plugins): 🐛 fixed plugin data system Note that `PluginData` is now replaced by `Any`. * docs(book): ✏️ fixed missing link to lighthouse in book intro * refactor(plugins): ♻️ removed plugin type system Any plugin can now take functional or control actions. Docs still need updating. * refactor(plugins): 🔥 removed old `get_immutable_store` actions These are replaced by the `set_immutable_store` settings action * fix(exporting): 🐛 fixed engine crate name change bug in exporting * docs(book): 📝 added docs for plugins --- .vscode/settings.json | 3 +- docs/0.1.x/en-US/arch.md | 16 +- docs/next/en-US/SUMMARY.md | 7 + docs/next/en-US/ejecting.md | 2 + docs/next/en-US/plugins/control.md | 22 +++ docs/next/en-US/plugins/functional.md | 32 ++++ docs/next/en-US/plugins/intro.md | 9 ++ docs/next/en-US/plugins/security.md | 14 ++ docs/next/en-US/plugins/tinker.md | 11 ++ docs/next/en-US/plugins/using.md | 11 ++ docs/next/en-US/plugins/writing.md | 41 +++++ docs/next/en-US/what-is-perseus.md | 2 +- examples/README.md | 1 + examples/basic/.perseus/Cargo.toml | 7 +- examples/basic/.perseus/server/Cargo.toml | 3 + examples/basic/.perseus/server/src/main.rs | 50 ++++-- examples/basic/.perseus/src/app.rs | 145 +++++++++++++++++ examples/basic/.perseus/src/bin/build.rs | 37 ++++- examples/basic/.perseus/src/bin/export.rs | 89 ++++++++--- examples/basic/.perseus/src/bin/tinker.rs | 19 +++ examples/basic/.perseus/src/lib.rs | 40 +++-- examples/plugins/.gitignore | 1 + examples/plugins/Cargo.toml | 16 ++ examples/plugins/index.html | 10 ++ examples/plugins/src/error_pages.rs | 17 ++ examples/plugins/src/lib.rs | 18 +++ examples/plugins/src/plugin.rs | 45 ++++++ examples/plugins/src/templates/about.rs | 24 +++ examples/plugins/src/templates/index.rs | 24 +++ examples/plugins/src/templates/mod.rs | 2 + examples/plugins/tests/main.rs | 43 +++++ packages/perseus-cli/src/bin/main.rs | 14 +- packages/perseus-cli/src/errors.rs | 2 + packages/perseus-cli/src/export.rs | 4 +- packages/perseus-cli/src/lib.rs | 2 + packages/perseus-cli/src/parse.rs | 11 ++ packages/perseus-cli/src/prepare.rs | 13 +- packages/perseus-cli/src/tinker.rs | 79 ++++++++++ packages/perseus/Cargo.toml | 1 + packages/perseus/src/build.rs | 12 +- packages/perseus/src/error_pages.rs | 5 + packages/perseus/src/export.rs | 2 +- packages/perseus/src/lib.rs | 2 + packages/perseus/src/locale_detector.rs | 4 +- packages/perseus/src/macros.rs | 103 ++++++------ packages/perseus/src/path_prefix.rs | 3 +- packages/perseus/src/plugins/action.rs | 20 +++ packages/perseus/src/plugins/control.rs | 92 +++++++++++ packages/perseus/src/plugins/functional.rs | 148 ++++++++++++++++++ packages/perseus/src/plugins/mod.rs | 16 ++ packages/perseus/src/plugins/plugin.rs | 39 +++++ packages/perseus/src/plugins/plugins_list.rs | 53 +++++++ .../website/src/templates/docs/generation.rs | 1 - .../src/templates/docs/get_file_at_version.rs | 1 - 54 files changed, 1253 insertions(+), 135 deletions(-) create mode 100644 docs/next/en-US/plugins/control.md create mode 100644 docs/next/en-US/plugins/functional.md create mode 100644 docs/next/en-US/plugins/intro.md create mode 100644 docs/next/en-US/plugins/security.md create mode 100644 docs/next/en-US/plugins/tinker.md create mode 100644 docs/next/en-US/plugins/using.md create mode 100644 docs/next/en-US/plugins/writing.md create mode 100644 examples/basic/.perseus/src/app.rs create mode 100644 examples/basic/.perseus/src/bin/tinker.rs create mode 100644 examples/plugins/.gitignore create mode 100644 examples/plugins/Cargo.toml create mode 100644 examples/plugins/index.html create mode 100644 examples/plugins/src/error_pages.rs create mode 100644 examples/plugins/src/lib.rs create mode 100644 examples/plugins/src/plugin.rs create mode 100644 examples/plugins/src/templates/about.rs create mode 100644 examples/plugins/src/templates/index.rs create mode 100644 examples/plugins/src/templates/mod.rs create mode 100644 examples/plugins/tests/main.rs create mode 100644 packages/perseus-cli/src/tinker.rs create mode 100644 packages/perseus/src/plugins/action.rs create mode 100644 packages/perseus/src/plugins/control.rs create mode 100644 packages/perseus/src/plugins/functional.rs create mode 100644 packages/perseus/src/plugins/mod.rs create mode 100644 packages/perseus/src/plugins/plugin.rs create mode 100644 packages/perseus/src/plugins/plugins_list.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 56d59396d8..d9933e45cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "testing", "templates", "exporting", - "website" + "website", + "plugins" ] } diff --git a/docs/0.1.x/en-US/arch.md b/docs/0.1.x/en-US/arch.md index 0627f273d1..e3d9cf2cc6 100644 --- a/docs/0.1.x/en-US/arch.md +++ b/docs/0.1.x/en-US/arch.md @@ -1,22 +1,22 @@ # Architecture -Perseus is a complex system, and this page will aim to explain the basics in a beginner-friendly way. If you've already used similar frameworks from the JS world like NextJS, then some of this may be familiar to you. If you're having trouble following along, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose) and ask us to clarify some sections, because this page in particular should be accessible to everyone. If you'd like more specific help, [ask on Gitter](TODO)! +Perseus is a complex system, and this page will aim to explain the basics in a beginner-friendly way. If you've already used similar frameworks from the JS world like NextJS, then some of this may be familiar to you. If you're having trouble following along, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose) and ask us to clarify some sections, because this page in particular should be accessible to everyone. If you'd like more specific help, [ask on Gitter]()! ## Templates and Pages -The core of Perseus is the idea of templates. When you create a Perseus app, what you're basically doing is telling Perseus how to compile your code into a series of *pages*. **Each page has a unique URL on your final website.** If you have a blog, and every post is stored as something like `post/title`, then each post would be a unique page. +The core of Perseus is the idea of templates. When you create a Perseus app, what you're basically doing is telling Perseus how to compile your code into a series of _pages_. **Each page has a unique URL on your final website.** If you have a blog, and every post is stored as something like `post/title`, then each post would be a unique page. -But this doesn't mean you have to write the code for every page individually! Perseus does this for you, and only asks you to write *templates*. A template can generate one page or many, and a great example of one would be a `post` template. Each template has a *root path*, which is essentially like the space on your website that that template controls. For example, a post template might control `/post`, meaning it can render pages at `/post`, `/post/test`, `/post/longer/path`, etc. In theory, a template could render pages outside its domain, but this would be a bad idea for structure, and makes your code difficult to understand. +But this doesn't mean you have to write the code for every page individually! Perseus does this for you, and only asks you to write _templates_. A template can generate one page or many, and a great example of one would be a `post` template. Each template has a _root path_, which is essentially like the space on your website that that template controls. For example, a post template might control `/post`, meaning it can render pages at `/post`, `/post/test`, `/post/longer/path`, etc. In theory, a template could render pages outside its domain, but this would be a bad idea for structure, and makes your code difficult to understand. ### State -What differentiates pages from templates is *state*, which tells a page how to fill out its template to give unique content. For example, our post template would probably have a `content` field in its state, and its pages would use that to render their unique content! +What differentiates pages from templates is _state_, which tells a page how to fill out its template to give unique content. For example, our post template would probably have a `content` field in its state, and its pages would use that to render their unique content! In terms of writing code, a page's state is just a `struct` that can be serialized and deserialized with [Serde](https://serde.rs). ## Rendering Strategies -Each template has a rendering strategy, which it uses to create its pages. There are a number of rendering strategies in Perseus, each of which is documented in detail in its own section. What's important to understand for now is that there are two main ways a template can render pages, at *build time*, or at *request time*. If a template renders at build time, it generates the code for your pages when you build your app, which means you end up serving static pages. This is *really fast*. However, sometimes you need information specific to each request to render a page (e.g. an authentication token), and you can't render at build. Instead, you'd render at request time, which gives you access to information about the HTTP request a user sent for your page. +Each template has a rendering strategy, which it uses to create its pages. There are a number of rendering strategies in Perseus, each of which is documented in detail in its own section. What's important to understand for now is that there are two main ways a template can render pages, at _build time_, or at _request time_. If a template renders at build time, it generates the code for your pages when you build your app, which means you end up serving static pages. This is _really fast_. However, sometimes you need information specific to each request to render a page (e.g. an authentication token), and you can't render at build. Instead, you'd render at request time, which gives you access to information about the HTTP request a user sent for your page. Here's a list of Perseus' currently supported rendering strategies. These can all be combined, but some combinations make more sense than others. @@ -28,12 +28,12 @@ Here's a list of Perseus' currently supported rendering strategies. These can al | Revalidation | Rebuilds pages conditionally | Hybrid | | Incremental generation | Builds pages on-demand | Hybrid | -There are two *hybrid* strategies listed above. They're a little more complicated, and out of the scope of this page, but they operate at both build *and* request-time, allowing you to reap the benefits of both worlds! +There are two _hybrid_ strategies listed above. They're a little more complicated, and out of the scope of this page, but they operate at both build _and_ request-time, allowing you to reap the benefits of both worlds! ## Routing -*This section describes how Perseus works under the hood. Skip it if you want.* +_This section describes how Perseus works under the hood. Skip it if you want._ -Perseus doesn't just host your pages at their URLs though. In fact, Perseus has a generic handler for *any URL*, which returns what we call the *app shell*. That's a concept from the single-page app (e.g. ReactJS), where your app always has a constant shell around it, and each page is loaded into that shell, making page transitions more seamless. Perseus adopts this as well, but with the added benefits of super-fast static rendering strategies and a more lightweight shell. +Perseus doesn't just host your pages at their URLs though. In fact, Perseus has a generic handler for _any URL_, which returns what we call the _app shell_. That's a concept from the single-page app (e.g. ReactJS), where your app always has a constant shell around it, and each page is loaded into that shell, making page transitions more seamless. Perseus adopts this as well, but with the added benefits of super-fast static rendering strategies and a more lightweight shell. The shell includes a router (courtesy of [Sycamore](https://github.com/sycamore-rs/sycamore)), which determines what page the user wants, and then sends a request to a special endpoint behind `/.perseus`. That then renders the page and returns some static HTML and the page's state. diff --git a/docs/next/en-US/SUMMARY.md b/docs/next/en-US/SUMMARY.md index 5606824d9f..f913193673 100644 --- a/docs/next/en-US/SUMMARY.md +++ b/docs/next/en-US/SUMMARY.md @@ -39,6 +39,13 @@ - [Communicating with a Server](/docs/server-communication) - [Stores](/docs/stores) - [Static Exporting](/docs/exporting) +- [Plugins](/docs/plugins/intro) + - [Functional Actions](/docs/plugins/functional) + - [Control Actions](/docs/plugins/control) + - [Using Plugins](/docs/plugins/using) + - [The `tinker` Action](/docs/plugins/tinker) + - [Writing Plugins](/docs/plugins/writing) + - [Security Considerations](/docs/plugins/security) - [Deploying](/docs/deploying/intro) - [Server Deployment](/docs/deploying/serverful) - [Serverless Deployment](/docs/deploying/serverless) diff --git a/docs/next/en-US/ejecting.md b/docs/next/en-US/ejecting.md index 71c877e8fd..c9b47419bd 100644 --- a/docs/next/en-US/ejecting.md +++ b/docs/next/en-US/ejecting.md @@ -4,6 +4,8 @@ The Perseus CLI is fantastic at enabling rapid and efficient development, but so However, there are some things that are too advanced for the CLI to support, and, in those cases, you'll need to eject. Don't worry, you'll still be able to use the CLI itself for running your app, but you'll be given access to the engine that underlies it, and you'll be able to tweak basically anything you want. +Before you proceed though, you should know that Perseus supports modularizing the functionality of ejected code through [plugins](:plugins/intro), which let you modify the `.perseus/` directory in all sorts of ways (including arbitrary file modification), without needing to eject in the first place. In nearly all cases (even for smaller apps), plugins are a better way to go than ejecting. In future, you'll even be able to replace the entire `.perseus/` directory with a custom engine (planned for v0.4.0)! + *Note: ejecting from Perseus exposes the bones of the system, and you should be quite familiar with Rust before doing this. That said, if you're just doing it for fun, go right ahead!* ## Ejecting diff --git a/docs/next/en-US/plugins/control.md b/docs/next/en-US/plugins/control.md new file mode 100644 index 0000000000..365cb0c053 --- /dev/null +++ b/docs/next/en-US/plugins/control.md @@ -0,0 +1,22 @@ +# Control Actions + +Control actions in Perseus can only be taken by one plugin, unlike [functional actions](:plugins/functional), because, if multiple plugins took them, Perseus wouldn't know what to do. For example, if more than one plugin tried to replace the [immutable store](:stores), Perseus wouldn't know which alternative to use. + +Control actions can be considered more powerful than functional actions because they allow a plugin to not only extend, but to replace engine functionality. + +## List of Control Actions + +Here's a list of all the control actions currently supported by Perseus, which will likely grow over time. You can see these in [this file](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/plugins/control.rs) in the Perseus repository. + +If you'd like to request that a new action, functional or control, be added, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). + +_Note: there are currently very few control actions, and this list will be expanded over time._ + +- `settings_actions` -- actions that can alter the settings provided by the user with [`define_app!`](:define-app) + - `set_immutable_store` -- sets an alternative [immutable store](:stores) (e.g. to store data somewhere other than the filesystem for some reason) + - `set_locales` -- sets the app's locales (e.g. to fetch locales from a database in a more convenient way) + - `set_app_root` -- sets the HTML `id` of the `div` in which to render Perseus (e.g. to fetch the app root from some other service) +- `build_actions` -- actions that'll be run when the user runs `perseus build` or `perseus serve` as part of the build process (these will not be run in [static exporting](:exporting)) +- `export_actions` -- actions that'll be run when the user runs `perseus export` +- `server_actions` -- actions that'll be run as part of the Perseus server when the user runs `perseus serve` (or when a [serverful production deployment](:deploying/serverful) runs) +- `client_actions` -- actions that'll run in the browser when the user's app is accessed diff --git a/docs/next/en-US/plugins/functional.md b/docs/next/en-US/plugins/functional.md new file mode 100644 index 0000000000..64cb786711 --- /dev/null +++ b/docs/next/en-US/plugins/functional.md @@ -0,0 +1,32 @@ +# Functional Actions + +The first type of action that a Perseus plugin can take is a functional action, and a single functional action can be taken by many plugins. These are the more common type of Perseus action, and are extremely versatile in extending the capabilities of the Perseus engine. However, they don't have the ability to replace critical functionality on their own. + +## List of Functional Actions + +Here's a list of all the functional actions currently supported by Perseus, which will likely grow over time. You can see these in [this file](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/plugins/functional.rs) in the Perseus repository. + +If you'd like to request that a new action, functional or control, be added, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). + +- `tinker` -- see [this section](:plugins/tinker) +- `settings_actions` -- actions that can alter the settings provided by the user with [`define_app!`](:define-app) + - `add_static_aliases` -- adds extra static aliases to the user's app (e.g. a [TailwindCSS](https://tailwindcss.com) stylesheet) + - `add_templates` -- adds extra templates to the user's app (e.g. a prebuilt documentation system) + - `add_error_pages` -- adds extra [error pages](:error-pages) to the user's app (e.g. a prebuilt 404 page) +- `build_actions` -- actions that'll be run when the user runs `perseus build` or `perseus serve` as part of the build process (these will not be run in [static exporting](:exporting)) + - `before_build` -- runs arbitrary code just before the build process starts (e.g. to run a CSS preprocessor) + - `after_successful_build` -- runs arbitrary code after the build process has completed, if it was successful (e.g. copying custom files into `.perseus/dist/`) + - `after_failed_build` -- runs arbitrary code after the build process has completed, if it failed (e.g. to report the failed build to a server crash management system) +- `export_actions` -- actions that'll be run when the user runs `perseus export` + - `before_export` -- runs arbitrary code just before the export process starts (e.g. to run a CSS preprocessor) + - `after_successful_build` -- runs arbitrary code after the build process has completed (inside the export process), if it was successful (e.g. copying custom files into `.perseus/dist/`) + - `after_failed_build` -- runs arbitrary code after the build process has completed (inside the export process), if it failed (e.g. to report the failed export to a server crash management system) + - `after_failed_export` -- runs arbitrary code after the export process has completed, if it failed (e.g. to report the failed export to a server crash management system) + - `after_failed_static_copy` -- runs arbitrary code if the export process fails to copy the `static` directory (e.g. to report the failed export to a server crash management system) + - `after_failed_static_alias_dir_copy` -- runs arbitrary code if the export process fails to copy a static alias that was a directory (e.g. to report the failed export to a server crash management system) + - `after_failed_static_alias_file_copy` -- runs arbitrary code if the export process fails to copy a static alias that was a file (e.g. to report the failed export to a server crash management system) + - `after_successful_export` -- runs arbitrary code after the export process has completed, if it was successful (e.g. copying custom files into `.perseus/dist/`) +- `server_actions` -- actions that'll be run as part of the Perseus server when the user runs `perseus serve` (or when a [serverful production deployment](:deploying/serverful) runs) + - `before_serve` -- runs arbitrary code before the server starts (e.g. to spawn an API server) +- `client_actions` -- actions that'll run in the browser when the user's app is accessed + - `start` -- runs arbitrary code when the Wasm delivered to the browser is executed (e.g. to ping an analytics service) diff --git a/docs/next/en-US/plugins/intro.md b/docs/next/en-US/plugins/intro.md new file mode 100644 index 0000000000..7ef20512b2 --- /dev/null +++ b/docs/next/en-US/plugins/intro.md @@ -0,0 +1,9 @@ +# Plugins + +Perseus is extremely versatile, but there are some cases where is needs to be modified a little under the hood to do something very advanced. For example, as you'll learn [here](:deploying/size), the common need for applying size optimizations requires modifying a file in the `.perseus/` directory, which requires [ejecting](:ejecting). This is a laborious process, and makes updating difficult, so Perseus support a system of _plugins_ to automatically apply common modifications under the hood! + +First, a little bit of background. The `.perseus/` directory contains what's called the Perseus engine, which is basically the core of your app. The code you write is actually imported by this and used to invoke various methods from the `perseus` crate. If you had to build all this yourself, it would take a very long time! Because this directory can be automatically generated though, there's no need to check it into version control (like Git). However, this becomes problematic if you then want to change even a single file inside, because you'll then need to commit the whole directory, which can be unwieldy. More importantly, when updates come along that involve changes to that directory, you'll either have to delete it and re-apply your modifications to the updated directory, or apply the updates manually, either of which is overly tedious for simple cases. + +Perseus has plugins to help with this. At various points in the engine, plugins have what are called _actions_ that they can take. Those actions are then executed by the engine at the appropriate time. For example, if a plugin needed to run some code before a Perseus app initialized, it could do that by taking a particular action, and then the engine would execute that action just before the app initialized. + +There are two types of actions a plugin can take: _functional actions_, and _control actions_. A single functional action can be taken by many plugins, and they (usually) won't interfere with each other. For example, many plugins can add additional [static aliases](:static-content) to an app. A single control action can only be taken by one plugin, because otherwise Perseus would have conflicting data. For example, if multiple plugins all set their own custom [immutable stores](:stores), Perseus wouldn't know which one to use. Both types of actions are explained in detail in the following sections. diff --git a/docs/next/en-US/plugins/security.md b/docs/next/en-US/plugins/security.md new file mode 100644 index 0000000000..70b6d4979a --- /dev/null +++ b/docs/next/en-US/plugins/security.md @@ -0,0 +1,14 @@ +# Security Considerations of Plugins + +Perseus' plugins system makes it phenomenally versatile, and allows you to reshape default behavior in ways that are possible in very few other frameworks (especially frameworks built in compiled languages like Rust). However, this comes with a major security risk to your system, because plugins have the power to execute arbitrary code. + +## The Risks + +If you enable a plugin in your app, it will have the opportunity to run arbitrary code. The actions that plugins take are just functions that they provide, so a plugin could easily be saying that it's adding an extra [static alias](:static-content) while simultaneously installing malware on your computer. + +## Precautions + +1. **Only ever use plugins that you trust!** Anyone can create a Perseus plugin, and some people may create plugins designed to install malware on your system. Optimally, you should review the code of every plugin that you install. +2. **Never run Perseus as root!** If you run Perseus and any plugins as the root user, a plugin can do literally anything on your computer, which could include installing privileged malware (by which point your computer would be owned by an attacker). + +**TL;DR:** don't use shady code, and don't run things with unnecessary privileges in general. diff --git a/docs/next/en-US/plugins/tinker.md b/docs/next/en-US/plugins/tinker.md new file mode 100644 index 0000000000..b3664475a2 --- /dev/null +++ b/docs/next/en-US/plugins/tinker.md @@ -0,0 +1,11 @@ +# The `tinker` Action + +There's one [functional action](:plugins/functional) that's quite special in Perseus: the `tinker` action. This action doesn't run as part of any of the usual processes, and it actually has its own command in the CLI: `perseus tinker`. That's because this action allows plugins to modify the code of the Perseus engine. For example, applying [size optimizations](:deploying/size) is a common requirement in Perseus apps, which means modifying `.perseus/Cargo.toml`. This is the perfect job for a plugin, but if it were done by any other action, you'd be modifying the `Cargo.toml` *after* the code had been compiled, which means the modifications would have no effect until the next run. + +The `tinker` action solves this problem by creating its own process that's specifically designed for engine modification and tweaking. Until [#59](https://github.com/arctic-hen7/perseus/issues/59) is resolved, this is how you'd make major modifications to the `.perseus/` engine efficiently. + +## `perseus tinker` + +The `tinker` subcommand in the CLI has one simple function: to execute the tinkers of all the plugins an app uses. By default, it will delete and re-create the `.perseus/` directory to remove any corruptions (which are common with plugins that arbitrarily modify Perseus' code, as you can probably imagine). You can disable that behavior with the `--no-clean` flag. + +If you've ejected, running this command will lead to an error, because running tinkers after you've ejected may delete some of your modifications. Most plugins expect to start with the default engines, and your modifications may cause all sorts of problems. If you're certain your modifications won't interfere with things, you can add the `--force` flag to push on. Note that if you don't provide `--no-clean` as well, the entire `.perseus/` directory will be deleted irrecoverably! diff --git a/docs/next/en-US/plugins/using.md b/docs/next/en-US/plugins/using.md new file mode 100644 index 0000000000..ffff972b85 --- /dev/null +++ b/docs/next/en-US/plugins/using.md @@ -0,0 +1,11 @@ +# Using Plugins + +The plugins system is designed to be as easy as possible to use, and you can import plugins into your app like so (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/plugins/src/lib.rs)): + +```rust +{{#include ../../../../examples/plugins/src/lib.rs}} +``` + +In addition to the usual `define_app!` calls, this also uses the `plugins` parameter, passing to it an instance of `perseus::plugins::Plugins`, which manages all the intricacies of the plugins system. If this parameter isn't provided, it'll default to `Plugins::new()`, which creates the configuration for plugins without registering any. + +To register a plugin, we use the `.plugin()` function on `Plugins`, which takes two parameters: the plugin's definition (a `perseus::plugins::Plugin`) and any data that should be provided to the plugin. The former should be exported from the plugin's crate, and the latter you'll need to provide based on the plugin's documentation. Note that plugins can accept almost anything as data (specifically, anything that can be expressed as `dyn Any`). diff --git a/docs/next/en-US/plugins/writing.md b/docs/next/en-US/plugins/writing.md new file mode 100644 index 0000000000..464451656d --- /dev/null +++ b/docs/next/en-US/plugins/writing.md @@ -0,0 +1,41 @@ +# Writing Plugins + +Writing Perseus plugins is a relatively seamless process once you get the hang of the structure, and this section will guide you through the process. If you just want to use plugins, you can skip this section. + +## Structure + +A plugin will usually occupy its own crate, but it may also be part of a larger app that just uses plugins for convenience and to avoid [ejection](:ejecting). The only thing you'll need in a plugin is the `perseus` crate, though you'll probably want to bring other libraries in (like `sycamore` if you're adding templates or error pages). + +## Defining a Plugin + +To define a plugin, you'll call `perseus::plugins::Plugin::new()`, which takes three: + +- The name of the plugin as a `&str`, which should be the name of the crate the plugin is in (or the name of a larger app with some extension) (**all plugins MUST have unique names**) +- A [functional actions](:plugins/functional) registrar function, which is given some functional actions and then extends them +- A [control actions](:plugins/control) registrar, which is given some control actions and then extends them + +Here's an example of a very simple plugin that adds a static alias for the project's `Cargo.toml`, creates an about page, and prints the working directory at [tinker](:plugins/tinker)-time (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/plugins/src/plugin.rs)): + +```rust +{{#include ../../../../examples/plugins/src/plugin.rs}} +``` + +One particularly important thing to note here is the absence of any control actions in this plugin. Because you still have to provide a registrar, this function is using the `empty_control_actions_registrar` convenience function, which does exactly what its name implies. + +Another notable thing is the presence of `GenericNode` as a type parameter, because some plugin actions take this, so you'll need to pass it through. We also tell Perseus what type of data out plugin will take in the second type parameter, which enables type checking in the `.plugin()` call when the user imports the plugin. + +The rest of the code is the functional actions registrar, which just registers the plugin on the `functional_actions.settings_actions.add_static_aliases`, `functional_actions.settings_actions.add_templates`, and `functional_actions.tinker` actions. The functions provided to the `.register_plugin()` function are *runners*, which will be executed at the appropriate time by the Perseus engine. Runners take two parameters, *action data*, and *plugin data*. Action data are data provided to every runner for the given action (e.g. an action that runs after a failed build will be told what the error was). You should refer to [the API docs](https://docs.rs/perseus) to learn more about these for different actions. The second parameter is plugin data, covered below. + +## Plugin Data + +Quite often, plugins should accept user configuration, and this is supported through the second runner parameter, which will be given any data that the user defined for your plugin. You can define the type of this with the second type parameter to `Plugin`. + +However, because Perseus is juggling all the data for all the plugins the user has installed, across all their different runners, it can't store the type of the data that the user gives (but don't worry, whatever they provide will be type-checked). This means that your runner ends up being given what Rust considers to be *something*. Basically, **we know that it's your plugin data, but Rust doesn't**. Specifically, you'll be given `&dyn Any`, which means you'll need to *downcast* this to a concrete type (the type of your plugin data). As in the above example, we can do this with `plugin_data.downcast_ref::()`, which will return an `Option`. **This will always be `Some`**, which is why it's perfectly safe to label the `None` branch as `unreachable!()`. If this ever does result in `None`, then either you've tried to downcast to something that's not your plugin's data type, or there's a critical bug in Perseus' plugins system, which you should [report to us](https://github.com/arctic-hen7/perseus/issues/new/choose). + +## Caveats + +Right now, there are few things that you can't do with Perseus plugins, which can be quite weird. + +- You can't extend the engine's server (due to a limitation of Actix Web types), you'll need to manually run a `tinker` on it (add your code into the file by writing it in using [the `tinker` action](:plugins/tinker)) +- You can't set the [mutable store](:stores) from a plugin due to a traits issue, so you'll need to provide something for the user to provide to the `mutable_store` parameter of the `define_app!` macro +- Similarly, you can't set the translations manager from a plugin diff --git a/docs/next/en-US/what-is-perseus.md b/docs/next/en-US/what-is-perseus.md index de85ec7f7b..e01ae0e435 100644 --- a/docs/next/en-US/what-is-perseus.md +++ b/docs/next/en-US/what-is-perseus.md @@ -62,7 +62,7 @@ To our knowledge, the only other framework in the world right now that supports [Benchmarks show](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) that [Sycamore](https://sycamore-rs.netlify.app) is slightly faster than [Svelte](https://svelte.dev) in places, one of the fastest JS frameworks ever. Perseus uses it and [Actix Web](https://actix.rs), one of the fastest web servers in the world. Essentially, Perseus is built on the fastest tech and is itself made to be fast. -The speed of web frameworks is often measured by [Lighthouse] scores, which are scores out of 100 (higher is better) that measure a whole host of things, like *total blocking time*, *first contentful paint*, and *time to interactive*. These are then aggregated into a final score and grouped into three brackets: 0-49 (slow), 50-89 (medium), and 90-100 (fast). This website, which is built with Perseus, using [static exporting](:exporting) and [size optimizations](:deploying/size), consistently scores a 100 on desktop and above 90 for mobile. You can see this for yourself [here](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Farctic-hen7.github.io%2Fperseus%2Fen-US%2F&tab=desktop) on Google's PageSpeed Insights tool. +The speed of web frameworks is often measured by [Lighthouse](https://developers.google.com/web/tools/lighthouse) scores, which are scores out of 100 (higher is better) that measure a whole host of things, like *total blocking time*, *first contentful paint*, and *time to interactive*. These are then aggregated into a final score and grouped into three brackets: 0-49 (slow), 50-89 (medium), and 90-100 (fast). This website, which is built with Perseus, using [static exporting](:exporting) and [size optimizations](:deploying/size), consistently scores a 100 on desktop and above 90 for mobile. You can see this for yourself [here](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Farctic-hen7.github.io%2Fperseus%2Fen-US%2F&tab=desktop) on Google's PageSpeed Insights tool.
Why not 100 on mobile? diff --git a/examples/README.md b/examples/README.md index 4354a2d408..31f90288d6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,3 +9,4 @@ These examples are all fully self-contained, and do not serve as examples in the - This has `.perseus/` included in Git, it's where that's developed - i18n -- a simple app that showcases internationalization in particular - Tiny -- the smallest Perseus can get, the _Hello World!_ example +- Plugins -- an example of creating and integrating plugins into Perseus diff --git a/examples/basic/.perseus/Cargo.toml b/examples/basic/.perseus/Cargo.toml index 1226d36309..aa019289d5 100644 --- a/examples/basic/.perseus/Cargo.toml +++ b/examples/basic/.perseus/Cargo.toml @@ -2,7 +2,7 @@ # IMPORTANT: spacing matters in this file for runtime replacements, do NOT change it! [package] -name = "perseus-cli-builder" +name = "perseus-engine" version = "0.3.0-beta.9" edition = "2018" default-run = "perseus-builder" @@ -25,6 +25,7 @@ console_error_panic_hook = "0.1.6" urlencoding = "2.1" futures = "0.3" fs_extra = "1" +lazy_static = "1" # This section is needed for Wasm Pack (which we use instead of Trunk for flexibility) [lib] @@ -38,3 +39,7 @@ path = "src/bin/build.rs" [[bin]] name = "perseus-exporter" path = "src/bin/export.rs" + +[[bin]] +name = "perseus-tinker" # Yes, the noun is 'tinker', not 'tinkerer' +path = "src/bin/tinker.rs" diff --git a/examples/basic/.perseus/server/Cargo.toml b/examples/basic/.perseus/server/Cargo.toml index 76d6767388..2eda5a0f96 100644 --- a/examples/basic/.perseus/server/Cargo.toml +++ b/examples/basic/.perseus/server/Cargo.toml @@ -9,9 +9,12 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +# TODO remove this dependency, we no longer need it # We alias here because the package name will change based on whatever's in the user's manifest app = { package = "perseus-example-basic", path = "../../" } +perseus = { path = "../../../../packages/perseus" } perseus-actix-web = { path = "../../../../packages/perseus-actix-web" } actix-web = "3.3" futures = "0.3" +perseus-engine = { path = "../" } diff --git a/examples/basic/.perseus/server/src/main.rs b/examples/basic/.perseus/server/src/main.rs index 64a4d25b30..0e51d33c1f 100644 --- a/examples/basic/.perseus/server/src/main.rs +++ b/examples/basic/.perseus/server/src/main.rs @@ -1,10 +1,12 @@ use actix_web::{App, HttpServer}; -use app::{ - get_error_pages, get_immutable_store, get_locales, get_mutable_store, get_static_aliases, - get_templates_map, get_translations_manager, APP_ROOT, -}; use futures::executor::block_on; +use perseus::plugins::PluginAction; +use perseus::SsrNode; use perseus_actix_web::{configurer, Options}; +use perseus_engine::app::{ + get_app_root, get_error_pages_contained, get_immutable_store, get_locales, get_mutable_store, + get_plugins, get_static_aliases, get_templates_map_contained, get_translations_manager, +}; use std::collections::HashMap; use std::env; use std::fs; @@ -16,15 +18,27 @@ use std::fs; #[actix_web::main] async fn main() -> std::io::Result<()> { + let plugins = get_plugins::(); + // So we don't have to define a different `FsConfigManager` just for the server, we shift the execution context to the same level as everything else // The server has to be a separate crate because otherwise the dependencies don't work with Wasm bundling // If we're not running as a standalone binary, assume we're running in dev mode under `.perseus/` + let is_standalone; if env::var("PERSEUS_STANDALONE").is_err() { env::set_current_dir("../").unwrap(); + is_standalone = false; + } else { + is_standalone = true; } + plugins + .functional_actions + .server_actions + .before_serve + .run((), plugins.get_plugin_data()); + // This allows us to operate inside `.perseus/` and as a standalone binary in production - let (html_shell_path, static_dir_path) = if env::var("PERSEUS_STANDALONE").is_ok() { + let (html_shell_path, static_dir_path) = if is_standalone { ("./index.html", "./static") } else { ("../index.html", "../static") @@ -35,30 +49,38 @@ async fn main() -> std::io::Result<()> { .unwrap_or_else(|_| "8080".to_string()) .parse::(); if let Ok(port) = port { + let immutable_store = get_immutable_store(&plugins); + let locales = get_locales(&plugins); + let app_root = get_app_root(&plugins); + let static_aliases = get_static_aliases(&plugins); HttpServer::new(move || { + // TODO find a way to configure the server with plugins without using `actix-web` in the `perseus` crate (it won't compile to Wasm) App::new().configure(block_on(configurer( Options { + // We don't support setting some attributes from `wasm-pack` through plugins/`define_app!` because that would require CLI changes as well (a job for an alternative engine) index: html_shell_path.to_string(), // The user must define their own `index.html` file - js_bundle: "dist/pkg/perseus_cli_builder.js".to_string(), + js_bundle: "dist/pkg/perseus_engine.js".to_string(), // Our crate has the same name, so this will be predictable - wasm_bundle: "dist/pkg/perseus_cli_builder_bg.wasm".to_string(), - templates_map: get_templates_map(), - locales: get_locales(), - root_id: APP_ROOT.to_string(), + wasm_bundle: "dist/pkg/perseus_engine_bg.wasm".to_string(), + // It's a nightmare to get the templates map to take plugins, so we use a self-contained version + // TODO reduce allocations here + templates_map: get_templates_map_contained(), + locales: locales.clone(), + root_id: app_root.to_string(), snippets: "dist/pkg/snippets".to_string(), - error_pages: get_error_pages(), + error_pages: get_error_pages_contained(), // The CLI supports static content in `../static` by default if it exists // This will be available directly at `/.perseus/static` - static_dirs: if fs::metadata(static_dir_path).is_ok() { + static_dirs: if fs::metadata(&static_dir_path).is_ok() { let mut static_dirs = HashMap::new(); static_dirs.insert("".to_string(), static_dir_path.to_string()); static_dirs } else { HashMap::new() }, - static_aliases: get_static_aliases(), + static_aliases: static_aliases.clone(), }, - get_immutable_store(), + immutable_store.clone(), get_mutable_store(), block_on(get_translations_manager()), ))) diff --git a/examples/basic/.perseus/src/app.rs b/examples/basic/.perseus/src/app.rs new file mode 100644 index 0000000000..4d66d0c9a3 --- /dev/null +++ b/examples/basic/.perseus/src/app.rs @@ -0,0 +1,145 @@ +// This file is used for processing data from the `define_app!` macro +// It also applies plugin opportunities for changing aspects thereof + +pub use app::get_plugins; +use perseus::{ + plugins::{PluginAction, Plugins}, + stores::ImmutableStore, + ErrorPages, GenericNode, Locales, TemplateMap, +}; +use std::collections::HashMap; + +pub use app::{get_mutable_store, get_translations_manager}; + +// These functions all take plugins so we don't have to perform possibly very costly allocation more than once in an environment (e.g. browser, build process, export process, server) + +// pub fn get_mutable_store() -> impl MutableStore { +// todo!() +// } +pub fn get_immutable_store(plugins: &Plugins) -> ImmutableStore { + let immutable_store = app::get_immutable_store(); + plugins + .control_actions + .settings_actions + .set_immutable_store + .run(immutable_store.clone(), plugins.get_plugin_data()) + .unwrap_or(immutable_store) +} +pub fn get_app_root(plugins: &Plugins) -> String { + plugins + .control_actions + .settings_actions + .set_app_root + .run((), plugins.get_plugin_data()) + .unwrap_or_else(|| app::APP_ROOT.to_string()) +} +// pub async fn get_translations_manager() -> impl TranslationsManager { +// todo!() +// } +pub fn get_locales(plugins: &Plugins) -> Locales { + let locales = app::get_locales(); + plugins + .control_actions + .settings_actions + .set_locales + .run(locales.clone(), plugins.get_plugin_data()) + .unwrap_or(locales) +} +// This also performs rescoping and security checks so that we don't include anything outside the project root +pub fn get_static_aliases(plugins: &Plugins) -> HashMap { + let mut static_aliases = app::get_static_aliases(); + // This will return a map of plugin name to another map of static aliases that that plugin produced + let extra_static_aliases = plugins + .functional_actions + .settings_actions + .add_static_aliases + .run((), plugins.get_plugin_data()); + for (_plugin_name, aliases) in extra_static_aliases { + let new_aliases: HashMap = aliases + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + static_aliases.extend(new_aliases); + } + + let mut scoped_static_aliases = HashMap::new(); + for (url, path) in static_aliases { + // We need to move this from being scoped to the app to being scoped for `.perseus/` + // TODO make sure this works properly on Windows + let new_path = if path.starts_with('/') { + // Absolute paths are a security risk and are disallowed + panic!( + "it's a security risk to include absolute paths in `static_aliases` ('{}')", + path + ); + } else if path.starts_with("../") { + // Anything outside this directory is a security risk as well + panic!("it's a security risk to include paths outside the current directory in `static_aliases` ('{}')", path); + } else if path.starts_with("./") { + // `./` -> `../` (moving to execution from `.perseus/`) + // But if we're operating standalone, it stays the same + if ::std::env::var("PERSEUS_STANDALONE").is_ok() { + path.to_string() + } else { + format!(".{}", path) + } + } else { + // Anything else gets a `../` prepended + // But if we're operating standalone, it stays the same + if ::std::env::var("PERSEUS_STANDALONE").is_ok() { + path.to_string() + } else { + format!("../{}", path) + } + }; + + scoped_static_aliases.insert(url, new_path); + } + + scoped_static_aliases +} +// This doesn't take plugins because that would actually increase allocation and indirection on the server +pub fn get_templates_map(plugins: &Plugins) -> TemplateMap { + let mut templates = app::get_templates_map::(); + // This will return a map of plugin name to a vector of templates to add + let extra_templates = plugins + .functional_actions + .settings_actions + .add_templates + .run((), plugins.get_plugin_data()); + for (_plugin_name, plugin_templates) in extra_templates { + // Turn that vector into a template map by extracting the template root paths as keys + for template in plugin_templates { + templates.insert(template.get_path(), template); + } + } + + templates +} +pub fn get_error_pages(plugins: &Plugins) -> ErrorPages { + let mut error_pages = app::get_error_pages::(); + // This will return a map of plugin name to a map of status codes to error pages + let extra_error_pages = plugins + .functional_actions + .settings_actions + .add_error_pages + .run((), plugins.get_plugin_data()); + for (_plugin_name, plugin_error_pages) in extra_error_pages { + for (status, error_page) in plugin_error_pages { + error_pages.add_page_rc(status, error_page); + } + } + + error_pages +} + +// We provide alternatives for `get_templates_map` and `get_error_pages` that get their own plugins +// This avoids major allocation/sync problems on the server +pub fn get_templates_map_contained() -> TemplateMap { + let plugins = get_plugins::(); + get_templates_map(&plugins) +} +pub fn get_error_pages_contained() -> ErrorPages { + let plugins = get_plugins::(); + get_error_pages(&plugins) +} diff --git a/examples/basic/.perseus/src/bin/build.rs b/examples/basic/.perseus/src/bin/build.rs index 58a5e92541..5dd931440f 100644 --- a/examples/basic/.perseus/src/bin/build.rs +++ b/examples/basic/.perseus/src/bin/build.rs @@ -1,9 +1,9 @@ -use app::{ - get_immutable_store, get_locales, get_mutable_store, get_templates_vec, +use futures::executor::block_on; +use perseus::{build_app, plugins::PluginAction, SsrNode}; +use perseus_engine::app::{ + get_immutable_store, get_locales, get_mutable_store, get_plugins, get_templates_map, get_translations_manager, }; -use futures::executor::block_on; -use perseus::{build_app, SsrNode}; fn main() { let exit_code = real_main(); @@ -11,14 +11,24 @@ fn main() { } fn real_main() -> i32 { - let immutable_store = get_immutable_store(); + let plugins = get_plugins::(); + + plugins + .functional_actions + .build_actions + .before_build + .run((), plugins.get_plugin_data()); + + let immutable_store = get_immutable_store(&plugins); let mutable_store = get_mutable_store(); let translations_manager = block_on(get_translations_manager()); - let locales = get_locales(); + let locales = get_locales(&plugins); // Build the site for all the common locales (done in parallel) + // All these parameters can be modified by `define_app!` and plugins, so there's no point in having a plugin opportunity here + let templates_map = get_templates_map::(&plugins); let fut = build_app( - get_templates_vec::(), + &templates_map, &locales, (&immutable_store, &mutable_store), &translations_manager, @@ -27,9 +37,20 @@ fn real_main() -> i32 { ); let res = block_on(fut); if let Err(err) = res { - eprintln!("Static generation failed: '{}'.", err); + let err_msg = format!("Static generation failed: '{}'.", &err); + plugins + .functional_actions + .build_actions + .after_failed_build + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); 1 } else { + plugins + .functional_actions + .build_actions + .after_successful_build + .run((), plugins.get_plugin_data()); println!("Static generation successfully completed!"); 0 } diff --git a/examples/basic/.perseus/src/bin/export.rs b/examples/basic/.perseus/src/bin/export.rs index abe1c5d184..6e053e4740 100644 --- a/examples/basic/.perseus/src/bin/export.rs +++ b/examples/basic/.perseus/src/bin/export.rs @@ -1,10 +1,12 @@ -use app::{ - get_immutable_store, get_locales, get_mutable_store, get_static_aliases, get_templates_map, - get_templates_vec, get_translations_manager, APP_ROOT, -}; use fs_extra::dir::{copy as copy_dir, CopyOptions}; use futures::executor::block_on; -use perseus::{build_app, export_app, path_prefix::get_path_prefix_server, SsrNode}; +use perseus::{ + build_app, export_app, path_prefix::get_path_prefix_server, plugins::PluginAction, SsrNode, +}; +use perseus_engine::app::{ + get_app_root, get_immutable_store, get_locales, get_mutable_store, get_plugins, + get_static_aliases, get_templates_map, get_translations_manager, +}; use std::fs; use std::path::PathBuf; @@ -14,15 +16,24 @@ fn main() { } fn real_main() -> i32 { - let immutable_store = get_immutable_store(); + let plugins = get_plugins::(); + + plugins + .functional_actions + .build_actions + .before_build + .run((), plugins.get_plugin_data()); + + let immutable_store = get_immutable_store(&plugins); // We don't need this in exporting, but the build process does let mutable_store = get_mutable_store(); let translations_manager = block_on(get_translations_manager()); - let locales = get_locales(); + let locales = get_locales(&plugins); // Build the site for all the common locales (done in parallel), denying any non-exportable features + let templates_map = get_templates_map::(&plugins); let build_fut = build_app( - get_templates_vec::(), + &templates_map, &locales, (&immutable_store, &mutable_store), &translations_manager, @@ -30,33 +41,58 @@ fn real_main() -> i32 { true, ); if let Err(err) = block_on(build_fut) { - eprintln!("Static exporting failed: '{}'.", err); + let err_msg = format!("Static exporting failed: '{}'.", &err); + plugins + .functional_actions + .export_actions + .after_failed_build + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); return 1; } + plugins + .functional_actions + .export_actions + .after_successful_build + .run((), plugins.get_plugin_data()); // Turn the build artifacts into self-contained static files + let app_root = get_app_root(&plugins); let export_fut = export_app( - get_templates_map(), + &templates_map, + // Perseus always uses one HTML file, and there's no point in letting a plugin change that "../index.html", &locales, - APP_ROOT, + &app_root, &immutable_store, &translations_manager, get_path_prefix_server(), ); if let Err(err) = block_on(export_fut) { - eprintln!("Static exporting failed: '{}'.", err); + let err_msg = format!("Static exporting failed: '{}'.", &err); + plugins + .functional_actions + .export_actions + .after_failed_export + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); return 1; } // Copy the `static` directory into the export package if it exists - // We don't use a config manager here because static files are always handled on-disk in Perseus (for now) + // If the user wants extra, they can use static aliases, plugins are unnecessary here let static_dir = PathBuf::from("../static"); if static_dir.exists() { if let Err(err) = copy_dir(&static_dir, "dist/exported/.perseus/", &CopyOptions::new()) { - eprintln!( + let err_msg = format!( "Static exporting failed: 'couldn't copy static directory: '{}''", - err.to_string() + &err ); + plugins + .functional_actions + .export_actions + .after_failed_static_copy + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); return 1; } } @@ -64,27 +100,44 @@ fn real_main() -> i32 { // Unlike with the server, these could override pages! // We'll copy from the alias to the path (it could be a directory or a file) // Remember: `alias` has a leading `/`! - for (alias, path) in get_static_aliases() { + for (alias, path) in get_static_aliases(&plugins) { let from = PathBuf::from(path); let to = format!("dist/exported{}", alias); if from.is_dir() { if let Err(err) = copy_dir(&from, &to, &CopyOptions::new()) { - eprintln!( + let err_msg = format!( "Static exporting failed: 'couldn't copy static alias directory: '{}''", err.to_string() ); + plugins + .functional_actions + .export_actions + .after_failed_static_alias_dir_copy + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); return 1; } } else if let Err(err) = fs::copy(&from, &to) { - eprintln!( + let err_msg = format!( "Static exporting failed: 'couldn't copy static alias file: '{}''", err.to_string() ); + plugins + .functional_actions + .export_actions + .after_failed_static_alias_file_copy + .run(err, plugins.get_plugin_data()); + eprintln!("{}", err_msg); return 1; } } + plugins + .functional_actions + .export_actions + .after_successful_export + .run((), plugins.get_plugin_data()); println!("Static exporting successfully completed!"); 0 } diff --git a/examples/basic/.perseus/src/bin/tinker.rs b/examples/basic/.perseus/src/bin/tinker.rs new file mode 100644 index 0000000000..29fa0ff2e2 --- /dev/null +++ b/examples/basic/.perseus/src/bin/tinker.rs @@ -0,0 +1,19 @@ +use perseus::{plugins::PluginAction, SsrNode}; +use perseus_engine::app::get_plugins; + +fn main() { + let exit_code = real_main(); + std::process::exit(exit_code) +} + +fn real_main() -> i32 { + let plugins = get_plugins::(); + // Run all the tinker actions + plugins + .functional_actions + .tinker + .run((), plugins.get_plugin_data()); + + println!("Tinkering complete!"); + 0 +} diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index c37e0238f8..006c3d0944 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -1,5 +1,8 @@ -use app::{get_error_pages, get_locales, get_templates_map, APP_ROOT}; +pub mod app; + +use crate::app::{get_app_root, get_error_pages, get_locales, get_plugins, get_templates_map}; use perseus::error_pages::ErrorPageData; +use perseus::plugins::PluginAction; use perseus::router::{RouteInfo, RouteVerdict}; use perseus::shell::{checkpoint, get_initial_state, get_render_cfg, InitialState}; use perseus::{app_shell, create_app_route, detect_locale, ClientTranslationsManager, DomNode}; @@ -12,21 +15,31 @@ use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; /// The entrypoint into the app itself. This will be compiled to Wasm and actually executed, rendering the rest of the app. #[wasm_bindgen] pub fn run() -> Result<(), JsValue> { + let plugins = get_plugins::(); + checkpoint("begin"); // Panics should always go to the console std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + + plugins + .functional_actions + .client_actions + .start + .run((), plugins.get_plugin_data()); + checkpoint("initial_plugins_complete"); + // Get the root we'll be injecting the router into let root = web_sys::window() .unwrap() .document() .unwrap() - .query_selector(&format!("#{}", APP_ROOT)) + .query_selector(&format!("#{}", get_app_root(&plugins))) .unwrap() .unwrap(); // Get the root that the server will have injected initial load content into // This will be moved into a reactive `
` by the app shell - // This is an `Option` until we know we aren't doing loclae detection (in which case it wouldn't exist) + // This is an `Option` until we know we aren't doing locale detection (in which case it wouldn't exist) let initial_container = web_sys::window() .unwrap() .document() @@ -37,29 +50,34 @@ pub fn run() -> Result<(), JsValue> { let container_rx = NodeRef::new(); // Create a mutable translations manager to control caching - let translations_manager = - Rc::new(RefCell::new(ClientTranslationsManager::new(&get_locales()))); + let locales = get_locales(&plugins); + let translations_manager = Rc::new(RefCell::new(ClientTranslationsManager::new(&locales))); // Get the error pages in an `Rc` so we aren't creating hundreds of them - let error_pages = Rc::new(get_error_pages()); + let error_pages = Rc::new(get_error_pages(&plugins)); // Create the router we'll use for this app, based on the user's app definition create_app_route! { name => AppRoute, // The render configuration is injected verbatim into the HTML shell, so it certainly should be present render_cfg => &get_render_cfg().expect("render configuration invalid or not injected"), - templates => &get_templates_map(), - locales => &get_locales() + // TODO avoid unnecessary allocation here (major problem!) + // The `G` parameter is ambient here for `RouteVerdict` + templates => &get_templates_map::(&get_plugins()), + locales => &get_locales::(&get_plugins()) } + // Put the locales into an `Rc` so we can use them in locale detection (which is inside a future) + let locales = Rc::new(locales); + sycamore::render_to( - || { + move || { template! { Router(RouterProps::new(HistoryIntegration::new(), move |route: StateHandle>| { create_effect(cloned!((container_rx) => move || { // Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here // We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late) let _ = route.get(); - wasm_bindgen_futures::spawn_local(cloned!((route, container_rx, translations_manager, error_pages, initial_container) => async move { + wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, translations_manager, error_pages, initial_container) => async move { let container_rx_elem = container_rx.get::().unchecked_into::(); checkpoint("router_entry"); match &route.get().as_ref().0 { @@ -82,7 +100,7 @@ pub fn run() -> Result<(), JsValue> { // If the user is using i18n, then they'll want to detect the locale on any paths missing a locale // Those all go to the same system that redirects to the appropriate locale // Note that `container` doesn't exist for this scenario - RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), get_locales()), + RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales), // To get a translator here, we'd have to go async and dangerously check the URL // If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error` RouteVerdict::NotFound => { diff --git a/examples/plugins/.gitignore b/examples/plugins/.gitignore new file mode 100644 index 0000000000..9405098b45 --- /dev/null +++ b/examples/plugins/.gitignore @@ -0,0 +1 @@ +.perseus/ diff --git a/examples/plugins/Cargo.toml b/examples/plugins/Cargo.toml new file mode 100644 index 0000000000..6a043aee97 --- /dev/null +++ b/examples/plugins/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "perseus-example-plugins" +version = "0.3.0-beta.8" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +perseus = { path = "../../packages/perseus" } +sycamore = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +fantoccini = "0.17" +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/examples/plugins/index.html b/examples/plugins/index.html new file mode 100644 index 0000000000..edc8a66246 --- /dev/null +++ b/examples/plugins/index.html @@ -0,0 +1,10 @@ + + + + + + + +
+ + diff --git a/examples/plugins/src/error_pages.rs b/examples/plugins/src/error_pages.rs new file mode 100644 index 0000000000..edba2da57a --- /dev/null +++ b/examples/plugins/src/error_pages.rs @@ -0,0 +1,17 @@ +use perseus::{ErrorPages, GenericNode}; +use sycamore::template; + +pub fn get_error_pages() -> ErrorPages { + let mut error_pages = ErrorPages::new(|url, status, err, _| { + template! { + p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } + } + }); + error_pages.add_page(404, |_, _, _, _| { + template! { + p { "Page not found." } + } + }); + + error_pages +} diff --git a/examples/plugins/src/lib.rs b/examples/plugins/src/lib.rs new file mode 100644 index 0000000000..5d2323df3f --- /dev/null +++ b/examples/plugins/src/lib.rs @@ -0,0 +1,18 @@ +mod error_pages; +mod plugin; +mod templates; + +use perseus::define_app; +use perseus::plugins::Plugins; + +define_app! { + templates: [ + crate::templates::index::get_template::(), + crate::templates::about::get_template::() + ], + error_pages: crate::error_pages::get_error_pages(), + plugins: Plugins::new() + .plugin(plugin::get_test_plugin(), plugin::TestPluginData { + about_page_greeting: "Hey from a plugin!".to_string() + }) +} diff --git a/examples/plugins/src/plugin.rs b/examples/plugins/src/plugin.rs new file mode 100644 index 0000000000..8b8cd4f42d --- /dev/null +++ b/examples/plugins/src/plugin.rs @@ -0,0 +1,45 @@ +use perseus::plugins::*; +use perseus::Template; + +#[derive(Debug)] +pub struct TestPluginData { + pub about_page_greeting: String, +} + +pub fn get_test_plugin() -> Plugin { + Plugin::new( + "test-plugin", + |mut actions| { + actions + .settings_actions + .add_static_aliases + .register_plugin("test-plugin", |_, _| { + let mut map = std::collections::HashMap::new(); + map.insert("/Cargo.toml".to_string(), "Cargo.toml".to_string()); + map + }); + actions.settings_actions.add_templates.register_plugin( + "test-plugin", + |_, plugin_data| { + if let Some(plugin_data) = plugin_data.downcast_ref::() { + let about_page_greeting = plugin_data.about_page_greeting.to_string(); + vec![Template::new("about") + .template(move |_| sycamore::template! { p { (about_page_greeting) } }) + .head(|_| { + sycamore::template! { + title { "About Page (Plugin Modified) | Perseus Example – Plugins" } + } + })] + } else { + unreachable!() + } + }, + ); + actions.tinker.register_plugin("test-plugin", |_, _| { + println!("{:?}", std::env::current_dir().unwrap()) + }); + actions + }, + empty_control_actions_registrar, + ) +} diff --git a/examples/plugins/src/templates/about.rs b/examples/plugins/src/templates/about.rs new file mode 100644 index 0000000000..03e8c8756e --- /dev/null +++ b/examples/plugins/src/templates/about.rs @@ -0,0 +1,24 @@ +use perseus::Template; +use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; + +// This page will actually be replaced entirely by a plugin! +#[component(AboutPage)] +pub fn about_page() -> SycamoreTemplate { + template! { + p { "About." } + } +} + +pub fn get_template() -> Template { + Template::new("about") + .template(|_| { + template! { + AboutPage() + } + }) + .head(|_| { + template! { + title { "About Page | Perseus Example – Plugins" } + } + }) +} diff --git a/examples/plugins/src/templates/index.rs b/examples/plugins/src/templates/index.rs new file mode 100644 index 0000000000..13552dac98 --- /dev/null +++ b/examples/plugins/src/templates/index.rs @@ -0,0 +1,24 @@ +use perseus::{GenericNode, Template}; +use sycamore::prelude::{component, template, Template as SycamoreTemplate}; + +#[component(IndexPage)] +pub fn index_page() -> SycamoreTemplate { + template! { + p { "Hello World!" } + a(href = "about", id = "about-link") { "About!" } + } +} + +pub fn get_template() -> Template { + Template::new("index") + .template(|_| { + template! { + IndexPage() + } + }) + .head(|_| { + template! { + title { "Index Page | Perseus Example – Plugins" } + } + }) +} diff --git a/examples/plugins/src/templates/mod.rs b/examples/plugins/src/templates/mod.rs new file mode 100644 index 0000000000..9b9cf18fc5 --- /dev/null +++ b/examples/plugins/src/templates/mod.rs @@ -0,0 +1,2 @@ +pub mod about; +pub mod index; diff --git a/examples/plugins/tests/main.rs b/examples/plugins/tests/main.rs new file mode 100644 index 0000000000..97d6b09f08 --- /dev/null +++ b/examples/plugins/tests/main.rs @@ -0,0 +1,43 @@ +use fantoccini::{Client, Locator}; +use perseus::wait_for_checkpoint; + +// Most of this has already been tested under `basic`, so we only test the impacts of plugins +#[perseus::test] +async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { + c.goto("http://localhost:8080").await?; + wait_for_checkpoint!("begin", 0, c); + let url = c.current_url().await?; + assert!(url.as_ref().starts_with("http://localhost:8080")); + + // The greeting was passed through using build state + wait_for_checkpoint!("initial_state_present", 0, c); + wait_for_checkpoint!("page_visible", 0, c); + let greeting = c.find(Locator::Css("p")).await?.text().await?; + assert_eq!(greeting, "Hello World!"); + // For some reason, retrieving the inner HTML or text of a `` doens't work + let title = c.find(Locator::Css("title")).await?.html(false).await?; + assert_eq!( + title, + "<title>Index Page | Perseus Example – Plugins" + ); + + // Go to `/about`, which should've been modified by a plugin + c.find(Locator::Id("about-link")).await?.click().await?; + let url = c.current_url().await?; + assert!(url.as_ref().starts_with("http://localhost:8080/about")); + wait_for_checkpoint!("initial_state_not_present", 0, c); + wait_for_checkpoint!("page_visible", 1, c); + // Make sure the hardcoded text there exists + let text = c.find(Locator::Css("p")).await?.text().await?; + assert_eq!(text, "Hey from a plugin!"); + let title = c.find(Locator::Css("title")).await?.html(false).await?; + assert_eq!( + title, + "About Page (Plugin Modified) | Perseus Example – Plugins" + ); + // Make sure we get initial state if we refresh + c.refresh().await?; + wait_for_checkpoint!("initial_state_present", 0, c); + + Ok(()) +} diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index d193365af0..da4cfdc35a 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -4,7 +4,7 @@ use perseus_cli::errors::*; use perseus_cli::{ build, check_env, delete_artifacts, delete_bad_dir, deploy, eject, export, has_ejected, parse::{Opts, Subcommand}, - prepare, serve, + prepare, serve, tinker, }; use std::env; use std::io::Write; @@ -136,6 +136,18 @@ fn core(dir: PathBuf) -> Result { eject(dir)?; 0 } + Subcommand::Tinker(tinker_opts) => { + // We shouldn't run arbitrary plugin code designed to alter the engine if the user has made their own changes after ejecting + if has_ejected(dir.clone()) && !tinker_opts.force { + return Err(EjectionError::TinkerAfterEject.into()); + } + // Unless we've been told not to, we start with a blank slate + // This will remove old tinkerings and eliminate any possible corruptions (which are very likely with tinkering!) + if !tinker_opts.no_clean { + delete_bad_dir(dir.clone())?; + } + tinker(dir)? + } Subcommand::Prep => { // The `.perseus/` directory has already been set up in the preliminaries, so we don't need to do anything here 0 diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index afff0e073b..485165d136 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -134,6 +134,8 @@ pub enum EjectionError { }, #[error("can't clean after ejection unless `--force` is provided (maybe you meant to use `--dist`?)")] CleanAfterEject, + #[error("can't tinker after ejection unless `--force` is provided (ejecting and using plugins can be problematic depending on the plugins used)")] + TinkerAfterEject, } /// Errors that can occur while running `perseus export`. diff --git a/packages/perseus-cli/src/export.rs b/packages/perseus-cli/src/export.rs index d126fc0e5e..f82bf4ca33 100644 --- a/packages/perseus-cli/src/export.rs +++ b/packages/perseus-cli/src/export.rs @@ -52,12 +52,12 @@ pub fn finalize_export(target: &Path) -> Result<(), ExportError> { // Copy files over (the directory structure should already exist from exporting the pages) copy_file!( - "dist/pkg/perseus_cli_builder.js", + "dist/pkg/perseus_engine.js", "dist/exported/.perseus/bundle.js", target ); copy_file!( - "dist/pkg/perseus_cli_builder_bg.wasm", + "dist/pkg/perseus_engine_bg.wasm", "dist/exported/.perseus/bundle.wasm", target ); diff --git a/packages/perseus-cli/src/lib.rs b/packages/perseus-cli/src/lib.rs index 22318eea25..25190141b2 100644 --- a/packages/perseus-cli/src/lib.rs +++ b/packages/perseus-cli/src/lib.rs @@ -37,6 +37,7 @@ pub mod parse; mod prepare; mod serve; mod thread; +mod tinker; mod deploy; mod extraction; @@ -53,6 +54,7 @@ pub use eject::{eject, has_ejected}; pub use export::export; pub use prepare::{check_env, prepare}; pub use serve::serve; +pub use tinker::tinker; /// Deletes a corrupted '.perseus/' directory. This will be called on certain error types that would leave the user with a half-finished /// product, which is better to delete for safety and sanity. diff --git a/packages/perseus-cli/src/parse.rs b/packages/perseus-cli/src/parse.rs index ea9ee9ca12..07859cce6d 100644 --- a/packages/perseus-cli/src/parse.rs +++ b/packages/perseus-cli/src/parse.rs @@ -27,6 +27,7 @@ pub enum Subcommand { Deploy(DeployOpts), /// Prepares the `.perseus/` directory (done automatically by `build` and `serve`) Prep, + Tinker(TinkerOpts), } /// Builds your app #[derive(Clap)] @@ -75,3 +76,13 @@ pub struct DeployOpts { #[clap(short, long)] pub export_static: bool, } +/// Runs the `tinker` action of plugins, which lets them modify the Perseus engine +#[derive(Clap)] +pub struct TinkerOpts { + /// Don't remove and recreate the `.perseus/` directory + #[clap(long)] + pub no_clean: bool, + /// Force this command to run, even if you've ejected (this may result in some or all of your changes being removed, it depends on the plugins you're using) + #[clap(long)] + pub force: bool, +} diff --git a/packages/perseus-cli/src/prepare.rs b/packages/perseus-cli/src/prepare.rs index 9ac2a5111d..c76b851640 100644 --- a/packages/perseus-cli/src/prepare.rs +++ b/packages/perseus-cli/src/prepare.rs @@ -89,10 +89,15 @@ pub fn prepare(dir: PathBuf) -> Result<(), PrepError> { &format!("\"{}\"", PERSEUS_VERSION), ); #[cfg(not(debug_assertions))] - let updated_server_manifest = updated_server_manifest.replace( - "{ path = \"../../../../packages/perseus-actix-web\" }", - &format!("\"{}\"", PERSEUS_VERSION), - ); + let updated_server_manifest = updated_server_manifest + .replace( + "{ path = \"../../../../packages/perseus\" }", + &format!("\"{}\"", PERSEUS_VERSION), + ) + .replace( + "{ path = \"../../../../packages/perseus-actix-web\" }", + &format!("\"{}\"", PERSEUS_VERSION), + ); // Write the updated manifests back if let Err(err) = fs::write(&root_manifest, updated_root_manifest) { diff --git a/packages/perseus-cli/src/tinker.rs b/packages/perseus-cli/src/tinker.rs new file mode 100644 index 0000000000..b1ffa83953 --- /dev/null +++ b/packages/perseus-cli/src/tinker.rs @@ -0,0 +1,79 @@ +use crate::cmd::{cfg_spinner, run_stage}; +use crate::errors::*; +use crate::thread::{spawn_thread, ThreadHandle}; +use console::{style, Emoji}; +use indicatif::{MultiProgress, ProgressBar}; +use std::env; +use std::path::PathBuf; + +// Emojis for stages +static TINKERING: Emoji<'_, '_> = Emoji("🔧", ""); // TODO + +/// Returns the exit code if it's non-zero. +macro_rules! handle_exit_code { + ($code:expr) => { + let (_, _, code) = $code; + if code != 0 { + return ::std::result::Result::Ok(code); + } + }; +} + +/// Actually tinkers the engione, program arguments having been interpreted. This needs to know how many steps there are in total +/// and takes a `MultiProgress` to interact with so it can be used truly atomically. This returns handles for waiting on the component +/// threads so we can use it composably. +#[allow(clippy::type_complexity)] +pub fn tinker_internal( + dir: PathBuf, + spinners: &MultiProgress, + num_steps: u8, +) -> Result< + ThreadHandle Result, Result>, + Error, +> { + let target = dir.join(".perseus"); + + // Tinkering message + let tk_msg = format!( + "{} {} Running plugin tinkers", + style(format!("[1/{}]", num_steps)).bold().dim(), + TINKERING + ); + + // We make sure to add them at the top (other spinners may have already been instantiated) + let tk_spinner = spinners.insert(0, ProgressBar::new_spinner()); + let tk_spinner = cfg_spinner(tk_spinner, &tk_msg); + let tk_target = target.clone(); + let tk_thread = spawn_thread(move || { + handle_exit_code!(run_stage( + vec![&format!( + "{} run --bin perseus-tinker", + env::var("PERSEUS_CARGO_PATH").unwrap_or_else(|_| "cargo".to_string()), + )], + &tk_target, + &tk_spinner, + &tk_msg + )?); + + Ok(0) + }); + + Ok(tk_thread) +} + +/// Runs plugin tinkers on the engine and returns an exit code. This doesn't have a release mode because tinkers should be applied in +/// development to work in both development and production. +pub fn tinker(dir: PathBuf) -> Result { + let spinners = MultiProgress::new(); + + let tk_thread = tinker_internal(dir.clone(), &spinners, 1)?; + let tk_res = tk_thread + .join() + .map_err(|_| ExecutionError::ThreadWaitFailed)??; + if tk_res != 0 { + return Ok(tk_res); + } + + // We've handled errors in the component threads, so the exit code is now zero + Ok(0) +} diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index f1faa246b2..a59f5e3bbd 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -34,6 +34,7 @@ async-trait = "0.1" fluent-bundle = { version = "0.15", optional = true } unic-langid = { version = "0.9", optional = true } js-sys = "0.3" +fs_extra = "1" [features] default = ["translator-fluent", "translator-dflt-fluent"] diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index 35a72c82f1..1116eaee97 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -7,7 +7,7 @@ use crate::Translator; use crate::{ decode_time_str::decode_time_str, stores::{ImmutableStore, MutableStore}, - template::Template, + Template, TemplateMap, }; use futures::future::try_join_all; use std::collections::HashMap; @@ -243,7 +243,7 @@ async fn build_template_and_get_cfg( /// Runs the build process of building many different templates for a single locale. If you're not using i18n, provide a `Translator::empty()` /// for this. You should only build the most commonly used locales here (the rest should be built on demand). pub async fn build_templates_for_locale( - templates: &[Template], + templates: &TemplateMap, translator_raw: Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, @@ -253,7 +253,7 @@ pub async fn build_templates_for_locale( let mut render_cfg: HashMap = HashMap::new(); // Create each of the templates let mut futs = Vec::new(); - for template in templates { + for template in templates.values() { futs.push(build_template_and_get_cfg( template, Rc::clone(&translator), @@ -278,7 +278,7 @@ pub async fn build_templates_for_locale( /// Gets a translator and builds templates for a single locale. async fn build_templates_and_translator_for_locale( - templates: &[Template], + templates: &TemplateMap, locale: String, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, @@ -301,7 +301,7 @@ async fn build_templates_and_translator_for_locale( /// Runs the build process of building many templates for the given locales data, building directly for all supported locales. This is /// fine because of how ridiculously fast builds are. pub async fn build_app( - templates: Vec>, + templates: &TemplateMap, locales: &Locales, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, @@ -312,7 +312,7 @@ pub async fn build_app( for locale in locales { futs.push(build_templates_and_translator_for_locale( - &templates, + templates, locale.to_string(), (immutable_store, mutable_store), translations_manager, diff --git a/packages/perseus/src/error_pages.rs b/packages/perseus/src/error_pages.rs index dedf6f45a2..8fbc71d610 100644 --- a/packages/perseus/src/error_pages.rs +++ b/packages/perseus/src/error_pages.rs @@ -37,6 +37,11 @@ impl ErrorPages { ) { self.status_pages.insert(status, Rc::new(page)); } + /// Adds a new page for the given status code. If a page was already defined for the given code, it will be updated by the mechanics of + /// the internal `HashMap`. This differs from `.add_page()` in that it takes an `Rc`, which is useful for plugins. + pub fn add_page_rc(&mut self, status: u16, page: ErrorPageTemplate) { + self.status_pages.insert(status, page); + } /// Gets the internal template function to render. fn get_template_fn(&self, status: &u16) -> &ErrorPageTemplate { // Check if we have an explicitly defined page for this status code diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index d9c5c57ab3..90f887ef8f 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -42,7 +42,7 @@ async fn get_static_page_data( /// been built, and that no templates are using non-static features (which can be ensured by passing `true` as the last parameter to /// `build_app`). pub async fn export_app( - templates: TemplateMap, + templates: &TemplateMap, html_shell_path: &str, locales: &Locales, root_id: &str, diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 4bdd38c9c5..367e372cb7 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -53,6 +53,8 @@ mod log; mod macros; /// Utilities relating to working with path prefixes for when a site is hosted at a relative path. pub mod path_prefix; +/// Utilities for managing and creating plugins. +pub mod plugins; /// Utilities regarding routing. pub mod router; /// Utilities for serving your app. These are platform-agnostic, and you probably want an integration like [perseus-actix-web](https://crates.io/crates/perseus-actix-web). diff --git a/packages/perseus/src/locale_detector.rs b/packages/perseus/src/locale_detector.rs index 864f46801c..96e8974f9c 100644 --- a/packages/perseus/src/locale_detector.rs +++ b/packages/perseus/src/locale_detector.rs @@ -6,7 +6,7 @@ use crate::Locales; /// that direct to this should be explicitly excluded from search engines (they don't show anything until redirected). This is guided /// by [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt), but is not yet fully compliant (only supports `xx-XX` form locales). /// Note that this bypasses Sycamore's routing logic and triggers a full reload. -pub fn detect_locale(url: String, locales: Locales) { +pub fn detect_locale(url: String, locales: &Locales) { // If nothing matches, we'll use the default locale let mut locale = locales.default.clone(); @@ -19,7 +19,7 @@ pub fn detect_locale(url: String, locales: Locales) { if let Some(lang) = navigator.language() { locale = match compare_locale(&lang, &locales.get_all()) { LocaleMatch::Exact(matched) | LocaleMatch::Language(matched) => matched, - LocaleMatch::None => locales.default, + LocaleMatch::None => locales.default.to_string(), } } } else { diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index cced90fa8f..7a173618c1 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -1,4 +1,5 @@ /// An internal macro used for defining a function to get the user's preferred immutable store (which requires multiple branches). +/// This can be reset by a control action. #[doc(hidden)] #[macro_export] macro_rules! define_get_immutable_store { @@ -15,7 +16,8 @@ macro_rules! define_get_immutable_store { } }; } -/// An internal macro used for defining a function to get the user's preferred mutable store (which requires multiple branches). +/// An internal macro used for defining a function to get the user's preferred mutable store (which requires multiple branches). This +/// can be reset by a control action. #[doc(hidden)] #[macro_export] macro_rules! define_get_mutable_store { @@ -34,7 +36,7 @@ macro_rules! define_get_mutable_store { }; } /// An internal macro used for defining the HTML `id` at which to render the Perseus app (which requires multiple branches). The default -/// is `root`. +/// is `root`. This can be reset by a control action. #[doc(hidden)] #[macro_export] macro_rules! define_app_root { @@ -46,6 +48,7 @@ macro_rules! define_app_root { }; } /// An internal macro used for defining a function to get the user's preferred translations manager (which requires multiple branches). +/// This is not plugin-extensible, but a control action can reset it later. #[doc(hidden)] #[macro_export] macro_rules! define_get_translations_manager { @@ -93,7 +96,8 @@ macro_rules! define_get_translations_manager { } }; } -/// An internal macro used for defining locales data. This is abstracted because it needs multiple branches. +/// An internal macro used for defining locales data. This is abstracted because it needs multiple branches. The `Locales` `struct` is +/// plugin-extensible. #[doc(hidden)] #[macro_export] macro_rules! define_get_locales { @@ -127,45 +131,19 @@ macro_rules! define_get_locales { }; } /// An internal macro for defining a function that gets the user's static content aliases (abstracted because it needs multiple -/// branches). +/// branches). This returns a plugin-extensible `HashMap`. #[doc(hidden)] #[macro_export] macro_rules! define_get_static_aliases { ( static_aliases: { - $($url:literal => $resource:literal)* + $($url:literal => $resource:literal),* } ) => { pub fn get_static_aliases() -> ::std::collections::HashMap { let mut static_aliases = ::std::collections::HashMap::new(); $( - let resource = $resource.to_string(); - // We need to move this from being scoped to the app to being scoped for `.perseus/` - // TODO make sure this works properly on Windows - let resource = if resource.starts_with("/") { - // Absolute paths are a security risk and are disallowed - panic!("it's a security risk to include absolute paths in `static_aliases`"); - } else if resource.starts_with("../") { - // Anything outside this directory is a security risk as well - panic!("it's a security risk to include paths outside the current directory in `static_aliases`"); - } else if resource.starts_with("./") { - // `./` -> `../` (moving to execution from `.perseus/`) - // But if we're operating standalone, it stays the same - if ::std::env::var("PERSEUS_STANDALONE").is_ok() { - resource - } else { - format!(".{}", resource) - } - } else { - // Anything else gets a `../` prepended - // But if we're operating standalone, it stays the same - if ::std::env::var("PERSEUS_STANDALONE").is_ok() { - resource - } else { - format!("../{}", resource) - } - }; - static_aliases.insert($url.to_string(), resource); + static_aliases.insert($url.to_string(), $resource.to_string()); )* static_aliases } @@ -176,13 +154,29 @@ macro_rules! define_get_static_aliases { } }; } +/// An internal macro used for defining the plugins for an app. Unsurprisingly, the results of this are not plugin-extensible! That +/// said, plugins can certainly register third-party runners in their own registrars (though they need to be mindful of security). +#[doc(hidden)] +#[macro_export] +macro_rules! define_plugins { + () => { + pub fn get_plugins() -> $crate::plugins::Plugins { + $crate::plugins::Plugins::new() + } + }; + ($plugins:expr) => { + pub fn get_plugins() -> $crate::plugins::Plugins { + $plugins + } + }; +} /// Defines the components to create an entrypoint for the app. The actual entrypoint is created in the `.perseus/` crate (where we can /// get all the dependencies without driving the user's `Cargo.toml` nuts). This also defines the template map. This is intended to make /// compatibility with the Perseus CLI significantly easier. /// /// Warning: all properties must currently be in the correct order (`root`, `templates`, `error_pages`, `locales`, `static_aliases`, -/// `dist_path`, `mutable_store`, `translations_manager`). +/// `plugins`, `dist_path`, `mutable_store`, `translations_manager`). #[macro_export] macro_rules! define_app { // With locales @@ -199,8 +193,9 @@ macro_rules! define_app { other: [$($other_locale:literal),*] } $(,static_aliases: { - $($url:literal => $resource:literal)* + $($url:literal => $resource:literal),* })? + $(,plugins: $plugins:expr)? $(,dist_path: $dist_path:literal)? $(,mutable_store: $mutable_store:expr)? $(,translations_manager: $translations_manager:expr)? @@ -219,8 +214,9 @@ macro_rules! define_app { other: [$($other_locale),*] } $(,static_aliases: { - $($url => $resource)* + $($url => $resource),* })? + $(,plugins: $plugins)? $(,dist_path: $dist_path)? $(,mutable_store: $mutable_store)? $(,translations_manager: $translations_manager)? @@ -235,8 +231,9 @@ macro_rules! define_app { ], error_pages: $error_pages:expr $(,static_aliases: { - $($url:literal => $resource:literal)* + $($url:literal => $resource:literal),* })? + $(,plugins: $plugins:expr)? $(,dist_path: $dist_path:literal)? $(,mutable_store: $mutable_store:expr)? } => { @@ -255,8 +252,9 @@ macro_rules! define_app { no_i18n: true } $(,static_aliases: { - $($url => $resource)* + $($url => $resource),* })? + $(,plugins: $plugins)? $(,dist_path: $dist_path)? $(,mutable_store: $mutable_store)? } @@ -280,13 +278,17 @@ macro_rules! define_app { $(,no_i18n: $no_i18n:literal)? } $(,static_aliases: { - $($url:literal => $resource:literal)* + $($url:literal => $resource:literal),* })? + $(,plugins: $plugins:expr)? $(,dist_path: $dist_path:literal)? $(,mutable_store: $mutable_store:expr)? $(,translations_manager: $translations_manager:expr)? } ) => { + /// Gets the plugins for the app. + $crate::define_plugins!($($plugins)?); + /// The html `id` that will find the app root to render Perseus in. For server-side interpolation, this MUST be an element of /// the form
` in your markup (double or single quotes, `root_id` replaced by what this property is set to). $crate::define_app_root!($($root_selector)?); @@ -311,30 +313,23 @@ macro_rules! define_app { $(, no_i18n: $no_i18n)? } - /// Gets a map of all the templates in the app by their root paths. + /// Gets any static content aliases provided by the user. + $crate::define_get_static_aliases!( + $(static_aliases: { + $($url => $resource),* + })? + ); + + /// Gets a map of all the templates in the app by their root paths. This returns a `HashMap` that is plugin-extensible. pub fn get_templates_map() -> $crate::TemplateMap { $crate::get_templates_map![ $($template),+ ] } - /// Gets a list of all the templates in the app in the order the user provided them. - pub fn get_templates_vec() -> Vec<$crate::Template> { - vec![ - $($template),+ - ] - } - - /// Gets the error pages (done here so the user doesn't have to worry about naming). + /// Gets the error pages (done here so the user doesn't have to worry about naming). This is plugin-extensible. pub fn get_error_pages() -> $crate::ErrorPages { $error_pages } - - /// Gets any static content aliases provided by the user. - $crate::define_get_static_aliases!( - $(static_aliases: { - $($url => $resource)* - })? - ); }; } diff --git a/packages/perseus/src/path_prefix.rs b/packages/perseus/src/path_prefix.rs index f1ca4dbce0..03c84a1482 100644 --- a/packages/perseus/src/path_prefix.rs +++ b/packages/perseus/src/path_prefix.rs @@ -3,7 +3,8 @@ use wasm_bindgen::JsCast; use web_sys::{HtmlBaseElement, Url}; /// Gets the path prefix to apply on the server. This uses the `PERSEUS_BASE_PATH` environment variable, which avoids hardcoding -/// something as changeable as this into the final binary. Hence however, that variable must be the same as wht's set in ``. +/// something as changeable as this into the final binary. Hence however, that variable must be the same as what's set in `` (done +/// automatically). /// Trailing forward slashes will be trimmed automatically. pub fn get_path_prefix_server() -> String { let base_path = env::var("PERSEUS_BASE_PATH").unwrap_or_else(|_| "".to_string()); diff --git a/packages/perseus/src/plugins/action.rs b/packages/perseus/src/plugins/action.rs new file mode 100644 index 0000000000..c59372f379 --- /dev/null +++ b/packages/perseus/src/plugins/action.rs @@ -0,0 +1,20 @@ +use std::any::Any; +use std::collections::HashMap; + +/// A runner function, which takes action data and plugin data. +pub type Runner = Box R>; + +/// A trait for the interface for a plugin action, which abstracts whether it's a functional or a control action. +pub trait PluginAction { + /// Runs the action. This takes data that the action should expect, along with a map of plugins to their data. + fn run(&self, action_data: A, plugin_data: &HashMap>) -> R2; + /// Registers a plugin that takes this action. + /// + /// # Panics + /// If the action type can only be taken by one plugin, and one has already been set, this may panic (e.g. for control actions), + /// as this is a critical, unrecoverable error that Perseus doesn't need to do anything after. If a plugin registration fails, + /// we have to assume that all work in the engine may be not what the user intended. + fn register_plugin(&mut self, name: &str, runner: impl Fn(&A, &dyn Any) -> R + 'static); + /// Same as `.register_plugin()`, but takes a prepared runner in a `Box`. + fn register_plugin_box(&mut self, name: &str, runner: Runner); +} diff --git a/packages/perseus/src/plugins/control.rs b/packages/perseus/src/plugins/control.rs new file mode 100644 index 0000000000..79eb7c52b6 --- /dev/null +++ b/packages/perseus/src/plugins/control.rs @@ -0,0 +1,92 @@ +use crate::plugins::{PluginAction, Runner}; +use std::any::Any; +use std::collections::HashMap; + +/// A control action, which can only be taken by one plugin. When run, control actions will return an `Option` on what +/// their runners return, which will be `None` if no runner is set. +pub struct ControlPluginAction { + /// The name of the plugin that controls this action. As this is a control action, only one plugin can manage a single action. + controller_name: String, + /// The single runner function for this action. This may not be defined if no plugin takes this action. + runner: Option>, +} +impl PluginAction> for ControlPluginAction { + /// Runs the single registered runner for the action. + fn run(&self, action_data: A, plugin_data: &HashMap>) -> Option { + // If no runner is defined, this won't have any effect (same as functional actions with no registered runners) + self.runner.as_ref().map(|runner| { + runner( + &action_data, + // We must have data registered for every active plugin (even if it's empty) + &**plugin_data.get(&self.controller_name).unwrap_or_else(|| { + panic!( + "no plugin data for registered plugin {}", + &self.controller_name + ) + }), + ) + }) + } + fn register_plugin(&mut self, name: &str, runner: impl Fn(&A, &dyn Any) -> R + 'static) { + self.register_plugin_box(name, Box::new(runner)) + } + fn register_plugin_box(&mut self, name: &str, runner: Runner) { + // Check if the action has already been taken by another plugin + if self.runner.is_some() { + // We panic here because an explicitly requested plugin couldn't be loaded, so we have to assume that any further behavior in the engine is unwanted + // Therefore, a graceful error would be inappropriate, this is critical in every sense + panic!("attempted to register runner from plugin '{}' for control action that already had a registered runner from plugin '{}' (these plugins conflict, see the book for further details)", name, self.controller_name); + } + + self.controller_name = name.to_string(); + self.runner = Some(runner); + } +} +// Using a default implementation allows us to avoid the action data having to implement `Default` as well, which is frequently infeasible +impl Default for ControlPluginAction { + fn default() -> Self { + Self { + controller_name: String::default(), + runner: None, + } + } +} + +/// All the control actions that a plugin can take. +#[derive(Default)] +pub struct ControlPluginActions { + /// Actions pertaining to the modification of settings created with the `define_app!` macro. + pub settings_actions: ControlPluginSettingsActions, + /// Actions pertaining to the build process. + pub build_actions: ControlPluginBuildActions, + /// Actions pertaining to the export process. + pub export_actions: ControlPluginExportActions, + /// Actions pertaining to the server. + pub server_actions: ControlPluginServerActions, + /// Actions pertaining to the client-side code. + pub client_actions: ControlPluginClientActions, +} + +/// Control actions that pertain to altering settings from `define_app!`. +#[derive(Default)] +pub struct ControlPluginSettingsActions { + /// Sets an immutable store to be used everywhere. This will provided the current immutable store for reference. + pub set_immutable_store: + ControlPluginAction, + /// Sets the locales to be used everywhere, providing the current ones for reference. + pub set_locales: ControlPluginAction, + /// Sets the app root to be used everywhere. This must correspond to the ID of an empty HTML `div`. + pub set_app_root: ControlPluginAction<(), String>, +} +/// Control actions that pertain to the build process. +#[derive(Default)] +pub struct ControlPluginBuildActions {} +/// Control actions that pertain to the export process. +#[derive(Default)] +pub struct ControlPluginExportActions {} +/// Control actions that pertain to the server. +#[derive(Default)] +pub struct ControlPluginServerActions {} +/// Control actions that pertain to the client-side code. As yet, there are none of these. +#[derive(Default)] +pub struct ControlPluginClientActions {} diff --git a/packages/perseus/src/plugins/functional.rs b/packages/perseus/src/plugins/functional.rs new file mode 100644 index 0000000000..83d8b26e50 --- /dev/null +++ b/packages/perseus/src/plugins/functional.rs @@ -0,0 +1,148 @@ +use crate::plugins::{PluginAction, Runner}; +use crate::GenericNode; +use std::any::Any; +use std::collections::HashMap; + +/// An action which can be taken by many plugins. When run, a functional action will return a map of plugin names to their return types. +pub struct FunctionalPluginAction { + /// The runners that will be called when this action is run. + runners: HashMap>, +} +impl PluginAction> for FunctionalPluginAction { + fn run( + &self, + action_data: A, + plugin_data: &HashMap>, + ) -> HashMap { + let mut returns: HashMap = HashMap::new(); + for (plugin_name, runner) in &self.runners { + let ret = runner( + &action_data, + // We must have data registered for every active plugin (even if it's empty) + &**plugin_data.get(plugin_name).unwrap_or_else(|| { + panic!("no plugin data for registered plugin {}", plugin_name) + }), + ); + returns.insert(plugin_name.to_string(), ret); + } + + returns + } + fn register_plugin(&mut self, name: &str, runner: impl Fn(&A, &dyn Any) -> R + 'static) { + self.register_plugin_box(name, Box::new(runner)) + } + fn register_plugin_box(&mut self, name: &str, runner: Runner) { + self.runners.insert(name.to_string(), runner); + } +} +// Using a default implementation allows us to avoid the action data having to implement `Default` as well, which is frequently infeasible +impl Default for FunctionalPluginAction { + fn default() -> Self { + Self { + runners: HashMap::default(), + } + } +} + +/// Actions designed to be compatible with other plugins such that two plugins can execute the same action. +pub struct FunctionalPluginActions { + /// The all-powerful action that can modify the Perseus engine itself. Because modifying the code you're running doesn't work with + /// compiled languages like Rust, this has its own command in the CLI, `perseus tinker`. This is best used for modifying + /// `.perseus/Cargo.toml` or other files. Ensure that you add signal comments so you don't apply the same modifications twice! + /// This will be executed in the context of `.perseus/`. As usual, do NOT change the directory here, because that will affect every + /// other plugin as well, just use `../`s if you need to work outside `.perseus/`. + /// + /// If your plugin uses this action in a way that may confuse other plugins, you should note this in your documentation. + pub tinker: FunctionalPluginAction<(), ()>, + /// Actions pertaining to the modification of settings created with the `define_app!` macro. + pub settings_actions: FunctionalPluginSettingsActions, + /// Actions pertaining to the build process. + pub build_actions: FunctionalPluginBuildActions, + /// Actions pertaining to the export process. + pub export_actions: FunctionalPluginExportActions, + /// Actions pertaining to the server. + pub server_actions: FunctionalPluginServerActions, + /// Actions pertaining to the client-side code. + pub client_actions: FunctionalPluginClientActions, +} +impl Default for FunctionalPluginActions { + fn default() -> Self { + Self { + tinker: FunctionalPluginAction::default(), + settings_actions: FunctionalPluginSettingsActions::::default(), + build_actions: FunctionalPluginBuildActions::default(), + export_actions: FunctionalPluginExportActions::default(), + server_actions: FunctionalPluginServerActions::default(), + client_actions: FunctionalPluginClientActions::default(), + } + } +} + +/// Functional actions that pertain to altering the settings exported from the `define_app!` macro. +pub struct FunctionalPluginSettingsActions { + /// Adds additional static aliases. Note that a static alias is a mapping of a URL path to a filesystem path (relative to the + /// project root). These will be vetted to ensure they don't access anything outside the project root for security reasons. If they + /// do, the user's app will not run. Note that these have the power to override the user's static aliases. + pub add_static_aliases: FunctionalPluginAction<(), HashMap>, + /// Adds additional templates. These will be applied to both the templates map and the templates list (separate entities), and + /// they must be generic about Sycamore rendering backends. Note that these have the power to override the user's templates. + pub add_templates: FunctionalPluginAction<(), Vec>>, + /// Adds additional error pages. This must return a map of HTTP status codes to erro page templates. Note that these have the + /// power to override the user's error pages. + pub add_error_pages: + FunctionalPluginAction<(), HashMap>>, +} +impl Default for FunctionalPluginSettingsActions { + fn default() -> Self { + Self { + add_static_aliases: FunctionalPluginAction::default(), + add_templates: FunctionalPluginAction::default(), + add_error_pages: FunctionalPluginAction::default(), + } + } +} + +/// Functional actions that pertain to the build process. Note that these actions are not available for the build +/// stage of the export process, and those should be registered separately. +#[derive(Default)] +pub struct FunctionalPluginBuildActions { + /// Runs before the build process. + pub before_build: FunctionalPluginAction<(), ()>, + /// Runs after the build process if it completes successfully. + pub after_successful_build: FunctionalPluginAction<(), ()>, + /// Runs after the build process if it fails. + pub after_failed_build: FunctionalPluginAction, +} +/// Functional actions that pertain to the export process. +#[derive(Default)] +pub struct FunctionalPluginExportActions { + /// Runs before the export process. + pub before_export: FunctionalPluginAction<(), ()>, + /// Runs after the build stage in the export process if it completes successfully. + pub after_successful_build: FunctionalPluginAction<(), ()>, + /// Runs after the build stage in the export process if it fails. + pub after_failed_build: FunctionalPluginAction, + /// Runs after the export process if it fails. + pub after_failed_export: FunctionalPluginAction, + /// Runs if copying the static directory failed. + pub after_failed_static_copy: FunctionalPluginAction, + /// Runs if copying a static alias that was a directory failed. + pub after_failed_static_alias_dir_copy: FunctionalPluginAction, + /// Runs if copying a static alias that was a file failed. + pub after_failed_static_alias_file_copy: FunctionalPluginAction, + /// Runs after the export process if it completes successfully. + pub after_successful_export: FunctionalPluginAction<(), ()>, +} +/// Functional actions that pertain to the server. +#[derive(Default)] +pub struct FunctionalPluginServerActions { + /// Runs before the server activates. This runs AFTER the current directory has been appropriately set for a standalone binary vs + /// running in the development environment (inside `.perseus/`). + pub before_serve: FunctionalPluginAction<(), ()>, +} +/// Functional actions that pertain to the client-side code. These in particular should be as fast as possible. +#[derive(Default)] +pub struct FunctionalPluginClientActions { + /// Runs before anything else in the browser. Note that this runs after panics have been set to go to the console. + pub start: FunctionalPluginAction<(), ()>, +} diff --git a/packages/perseus/src/plugins/mod.rs b/packages/perseus/src/plugins/mod.rs new file mode 100644 index 0000000000..2f5e797a0c --- /dev/null +++ b/packages/perseus/src/plugins/mod.rs @@ -0,0 +1,16 @@ +mod action; +mod control; +mod functional; +mod plugin; +mod plugins_list; + +pub use action::*; +pub use control::*; +pub use functional::*; +pub use plugin::*; +pub use plugins_list::*; + +/// A helper function for plugins that don't take any control actions. This just inserts an empty registrar. +pub fn empty_control_actions_registrar(_: ControlPluginActions) -> ControlPluginActions { + ControlPluginActions::default() +} diff --git a/packages/perseus/src/plugins/plugin.rs b/packages/perseus/src/plugins/plugin.rs new file mode 100644 index 0000000000..52961312fc --- /dev/null +++ b/packages/perseus/src/plugins/plugin.rs @@ -0,0 +1,39 @@ +use crate::plugins::*; +use crate::GenericNode; +use std::any::Any; +use std::marker::PhantomData; + +type FunctionalActionsRegistrar = + Box) -> FunctionalPluginActions>; +type ControlActionsRegistrar = Box ControlPluginActions>; + +/// A Perseus plugin. This must be exported by all plugin crates so the user can register the plugin easily. +pub struct Plugin { + /// The machine name of the plugin, which will be used as a key in a HashMap with many other plugins. This should be the public + /// crate name in all cases. + pub name: String, + /// A function that will be provided functional actions. It should then register runners from the plugin for every action that it + /// takes. + pub functional_actions_registrar: FunctionalActionsRegistrar, + /// A function that will be provided control actions. It should then register runners from the plugin for every action + /// that it takes. + pub control_actions_registrar: ControlActionsRegistrar, + + plugin_data_type: PhantomData, +} +impl Plugin { + /// Creates a new plugin with a name, functional actions, and optional control actions. + pub fn new( + name: &str, + functional_actions_registrar: impl Fn(FunctionalPluginActions) -> FunctionalPluginActions + + 'static, + control_actions_registrar: impl Fn(ControlPluginActions) -> ControlPluginActions + 'static, + ) -> Self { + Self { + name: name.to_string(), + functional_actions_registrar: Box::new(functional_actions_registrar), + control_actions_registrar: Box::new(control_actions_registrar), + plugin_data_type: PhantomData::default(), + } + } +} diff --git a/packages/perseus/src/plugins/plugins_list.rs b/packages/perseus/src/plugins/plugins_list.rs new file mode 100644 index 0000000000..bb6a32d2fb --- /dev/null +++ b/packages/perseus/src/plugins/plugins_list.rs @@ -0,0 +1,53 @@ +use crate::plugins::*; +use crate::GenericNode; +use std::any::Any; +use std::collections::HashMap; + +type PluginDataMap = HashMap>; + +/// A representation of all the plugins used by an app. Due to the sheer number and compexity of nested fields, this is best transferred +/// in an `Rc`, which unfortunately results in double indirection for runner functions. +pub struct Plugins { + /// The functional actions that this plugin takes. This is defined by default such that all actions are assigned to a default, and + /// so they can all be run without long chains of matching `Option`s. + pub functional_actions: FunctionalPluginActions, + /// The control actions that this plugin takes. This is defined by default such that all actions are assigned to a default, and so + /// they can all be run without long chains of matching `Option`s. + pub control_actions: ControlPluginActions, + plugin_data: PluginDataMap, +} +impl Default for Plugins { + fn default() -> Self { + Self { + functional_actions: FunctionalPluginActions::::default(), + control_actions: ControlPluginActions::default(), + plugin_data: HashMap::default(), + } + } +} +impl Plugins { + /// Creates a new instance of `Plugins`, with no actions taken by any plugins, and the data map empty. + pub fn new() -> Self { + Self::default() + } + /// Registers a new plugin, consuming `self`. For control actions, this will check if a plugin has already registered on an action, + /// and throw an error if one has, noting the conflict explicitly in the error message. + pub fn plugin(mut self, plugin: Plugin, plugin_data: D) -> Self { + // Insert the plugin data + let plugin_data: Box = Box::new(plugin_data); + let res = self.plugin_data.insert(plugin.name.clone(), plugin_data); + // If there was an old value, there are two plugins with the same name, which is very bad (arbitrarily inconsistent behavior overriding) + if res.is_some() { + panic!("two plugins have the same name '{}', which could lead to arbitrary and inconsistent behavior modification (please file an issue with the plugin that doesn't have the same name as its crate)", &plugin.name); + } + // Register functional and control actions using the plugin's provided registrar + self.functional_actions = (plugin.functional_actions_registrar)(self.functional_actions); + self.control_actions = (plugin.control_actions_registrar)(self.control_actions); + + self + } + /// Gets a reference to the map of plugin data. Note that each element of plugin data is additionally `Box`ed. + pub fn get_plugin_data(&self) -> &PluginDataMap { + &self.plugin_data + } +} diff --git a/website/website/src/templates/docs/generation.rs b/website/website/src/templates/docs/generation.rs index 49ec1da4c3..a6c14d6000 100644 --- a/website/website/src/templates/docs/generation.rs +++ b/website/website/src/templates/docs/generation.rs @@ -196,7 +196,6 @@ pub async fn get_build_state(path: String, locale: String) -> RenderFnResultWith let vec: Vec<&str> = incl_path_with_lines_suffix.split(':').collect(); (vec[0], vec[1].parse::()?, vec[2].parse::()?) }; - // TODO use Git to get file contents if we're no on the `next` version // If we're on the `next` version, read from the filesystem directly // Otherwise, use Git to get the appropriate version (otherwise we get #60) let incl_contents_full = if version == "next" { diff --git a/website/website/src/templates/docs/get_file_at_version.rs b/website/website/src/templates/docs/get_file_at_version.rs index d8ada5cf08..85bcf6f73f 100644 --- a/website/website/src/templates/docs/get_file_at_version.rs +++ b/website/website/src/templates/docs/get_file_at_version.rs @@ -12,7 +12,6 @@ pub fn get_file_at_version( .args(["show", &format!("{}:{}", version, filename)]) .current_dir(git_dir) .output()?; - // TODO error handling let contents = String::from_utf8(output.stdout) .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidData))?; Ok(contents)