diff --git a/docs/0.2.x/theme/header.hbs b/docs/0.2.x/theme/header.hbs new file mode 120000 index 0000000000..e2d3670739 --- /dev/null +++ b/docs/0.2.x/theme/header.hbs @@ -0,0 +1 @@ +../../common/header_old.hbs \ No newline at end of file diff --git a/docs/0.2.x/theme/index.hbs b/docs/0.2.x/theme/index.hbs new file mode 120000 index 0000000000..485815ad53 --- /dev/null +++ b/docs/0.2.x/theme/index.hbs @@ -0,0 +1 @@ +../../common/index.hbs \ No newline at end of file diff --git a/docs/0.3.x/book.toml b/docs/0.3.x/book.toml new file mode 120000 index 0000000000..bc4f95b8e1 --- /dev/null +++ b/docs/0.3.x/book.toml @@ -0,0 +1 @@ +../common/book.toml \ No newline at end of file diff --git a/docs/0.3.x/src/SUMMARY.md b/docs/0.3.x/src/SUMMARY.md new file mode 100644 index 0000000000..f669666806 --- /dev/null +++ b/docs/0.3.x/src/SUMMARY.md @@ -0,0 +1,51 @@ +# Summary + +- [Introduction](./intro.md) + - [What is Perseus?](./what-is-perseus.md) + - [Hello World!](./hello-world.md) +- [Your Second App](./second-app.md) +*** +# Reference + +- [`define_app!`](./define-app.md) +- [Writing Views](./views.md) +- [Debugging](./debugging.md) +- [Templates and Routing](./templates/intro.md) + - [Modifying the ``](./templates/metadata-modification.md) + - [Modifying HTTP Headers](./templates/setting-headers.md) +- [Error Pages](./error-pages.md) +- [Static Content](./static-content.md) +- [Internationalization](./i18n/intro.md) + - [Defining Translations](./i18n/defining.md) + - [Using Translations](./i18n/using.md) + - [Translations Managers](./i18n/translations-managers.md) + - [Other Translation Engines](./i18n/other-engines.md) +- [Rendering Strategies](./strategies/intro.md) + - [Build State](./strategies/build-state.md) + - [Build Paths](./strategies/build-paths.md) + - [Request State](./strategies/request-state.md) + - [Revalidation](./strategies/revalidation.md) + - [Incremental Generation](./strategies/incremental.md) + - [State Amalgamation](./strategies/amlagamation.md) +- [CLI](./cli.md) + - [Ejecting](./ejecting.md) +- [Testing](./testing/intro.md) + - [Checkpoints](./testing/checkpoints.md) + - [Fantoccini Basics](./testing/fantoccini-basics.md) + - [Manual Testing](./testing/manual.md) +- [Styling](./styling.md) +- [Stores](./stores.md) +- [Static Exporting](./exporting.md) +- [Deploying](./deploying/intro.md) + - [Server Deployment](./deploying/serverful.md) + - [Serverless Deployment](./deploying/serverless.md) + - [Optimizing Code Size](./deploying/size.md) +- [Migrating from v0.2.x](./updating.md) +*** +# Advanced + +- [Under the Hood](./advanced/intro.md) + - [Architecture](./advanced/arch.md) + - [Initial Loads](./advanced/initial-loads.md) + - [Subsequent Loads](./advanced/subsequent-loads.md) + - [Routing](./advanced/routing.md) diff --git a/docs/0.3.x/src/advanced/arch.md b/docs/0.3.x/src/advanced/arch.md new file mode 100644 index 0000000000..6f5643e1cf --- /dev/null +++ b/docs/0.3.x/src/advanced/arch.md @@ -0,0 +1,50 @@ +# Architecture + +Perseus has five main components: + +- `perseus` -- the core module that defines everything necessary to build a Perseus app if you try hard enough +- `perseus-actix-web` -- an integration that makes it easy to run Perseus on the [Actix Web](https://actix.rs) framework +- `perseus-cli` -- the command-line interface used to run Perseus apps conveniently +- `perseus-cli-builder` -- an internal crate created by the CLI responsible for building an app +- `perseus-cli-server` -- an internal crate created by the CLI responsible for serving an app and performing runtime logic + +## Core + +At the core of Perseus is the [`perseus`](https://docs.rs/perseus) module, which is used for nearly everything in Perseus. In theory, you could build a fully-functional app based on this crate alone, but you'd be reinventing the wheel at least three times. This crate exposes types for the i18n systems, configuration management, routing, and asset fetching, most of which aren't intended to be used directly by the user. + +What is intended to be used directly is the `Template` `struct`, which is integral to Perseus. This stores closures for every rendering strategy, which are executed as provided and necessary at build and runtime. Note that these are all stored in `Rc`s, and `Template`s are cloned. + +The other commonly used system from this crate is the `Translator` system, explained in detail in [the i18n section](../i18n/intro.md). `Translator`s are passed around in `Rc`s, and `TranslationsManager` on the server caches all translations by default in memory on the server. + +## Actix Web Integration + +The core of Perseus provides very few systems to set up a functional Perseus server though, which requires a significant amount of additional work. To this end, [`perseus-actix-web`](https://docs.rs/perseus-actix-web) is used to make this process easy. If you've ejected, you'll be working with this directly, which should be relatively simple, as it just accepts configuration options and then should simply work. + +Note that this module provides a `configurer` function, which allows it to be modularly added to any existing Actix Web server, which is particularly useful if you want to run other endpoint on your server, or a system like [Diana](https://github.com/arctic-hen7/diana). + +## CLI + +As documented in [this section](../cli.md), the CLI simply runs commands to execute the last two components of the Perseus system, acting as a convenience. It also contains these two components inside its binary (using [`include_dir!`](https://github.com/Michael-F-Bryan/include_dir)) + +## CLI Builder + +This system can be further broken down into two parts. + +### Static Generator + +This is a single binary that just imports the user's templates and some other information (like locales) and then calls `build_app`. This will result in generating a number of files to `.perseus/dist`, which will be served by the server to any clients, which will then hydrate those static pages into fully-fledged Sycamore templates. + +### App Shell + +This is encapsulated in `.perseus/src/lib.rs`, and it performs a number of integral functions: + +- Ensures that any `panic!`s or the like ar printed properly in the browser console +- Creates and manages the internal router +- Renders your actual app +- Handles locale detection +- Invokes the core app shell to manage initial/subsequent loads and translations +- Handles error page displaying + +## CLI Server + +This is just an invocation of the `perseus-actix-web` module's systems with the data provided by the user through the `define_app!` macro. This also sets the default location for static content and the `index.html` file. diff --git a/docs/0.3.x/src/advanced/initial-loads.md b/docs/0.3.x/src/advanced/initial-loads.md new file mode 100644 index 0000000000..108ceeacc2 --- /dev/null +++ b/docs/0.3.x/src/advanced/initial-loads.md @@ -0,0 +1,23 @@ +# Initial Loads + +Perseus handles _initial loads_ very differently from _subsequent loads_. The former refers to what's done when a user visits a page on a Perseus app from an external source (e.g. visiting from a search engine, redirected from another site), and this requires a full HTMl page to be sent that can be interpreted by the browser. By contrast, subsequent loads are loads between pages within the same Perseus app, which can be performed by the app shell (described in the next section). + +The process of initial loads is slightly complex, and occurs like so (this example is for a page called `/posts/test`, rendered with incremental generation): + +1. Browser requests `/posts/test` from the server. +2. Server matches requested URL to wildcard (`*`) and handles it with the server-side inferred router, determining which `Template` to use. +3. Server calls internal core methods to render the page (using incremental generation strategy, but it doesn't need to know that), producing an HTML snippet and a set of JSON properties. +4. Server calls `template.render_head_str()` and injects the result into the document's `` (avoiding `` flashes and improving SEO) after a delimiter comment that separates it from the metadata on every page (which is hardcoded into `index.html`). +5. Server interpolates JSON state into `index.html` as a global variable in a `<script>`. +6. Server interpolates HTML snippet directly into the user's `index.html` file. +7. Server sends final HTML package to client, including Wasm (injected at build-time). +8. Browser renders HTML package, user sees content immediately. +9. Browser invokes Wasm, hands control to the app shell. +10. App shell checks if initial state declaration global variable is present, finds that it is and unsets it (so that it doesn't interfere with subsequent loads). +11. App shell moves server-rendered content out of `__perseus_content_initial` and into `__perseus_content_rx`, which Sycamore's router had control over (allowing it to catch links and use the subsequent loads system). +12. App shell gets a translator if the app uses i18n. +13. App shell hydrates content at `__perseus_content_rx` with Sycamore and returns, the page is now interactive and has a translator context. + +Note: if this app had used i18n, the server would've returned the app shell with no content, and the app shell, when invoked, would've immediately redirected the user to their preferred locale (or the closest equivalent). + +The two files integral to this process are [`initial_load.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus-actix-web/src/initial_load.rs) and [`shell.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/shell.rs). diff --git a/docs/0.3.x/src/advanced/intro.md b/docs/0.3.x/src/advanced/intro.md new file mode 100644 index 0000000000..ed92658732 --- /dev/null +++ b/docs/0.3.x/src/advanced/intro.md @@ -0,0 +1,3 @@ +# Under the Hood + +This section of the documentation is devoted to explaining the inner workings of Perseus, which will be particularly useful if you choose to eject from the CLI's harness or if you want to contribute to Perseus! diff --git a/docs/0.3.x/src/advanced/routing.md b/docs/0.3.x/src/advanced/routing.md new file mode 100644 index 0000000000..458219e5b9 --- /dev/null +++ b/docs/0.3.x/src/advanced/routing.md @@ -0,0 +1,38 @@ +# Routing + +Perseus' routing system is quite unique in that it's almost entirely *inferred*, meaning that you don't ever have to define a router or explain to the system which paths go where. Instead, they're inferred from templates in a system that's explained in detail in the [templates section](../templates/intro.md). + +## Template Selection Algorithm + +Perseus has a very specific algorithm that it uses to determine which template to use for a given route, which is greatly dependent on `.perseus/dist/render_conf.json`. This is executed on the client-side for *subsequent loads* and on the server-side for *initial loads*. + +Here's an example render configuration (for the [showcase example](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase)), which maps path to template root path. + +```json +{ + "about": "about", + "index": "index", + "post/new": "post/new", + "ip": "ip", + "post/*": "post", + "timeisr/test": "timeisr", + "timeisr/*": "timeisr", + "time": "time", + "amalgamation": "amalgamation", + "post/blah/test/blah": "post", + "post/test": "post" +} +``` + +Here are the algorithm's steps (see [`router.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/router.rs)): + +1. If the path is empty, set it to `index` (which is used for the landing page). +2. Try to directly get the template name by trying the path as a key. This would work for anything not using incremental generation (in the above example, anything other than `post/*`). +3. Split the path into sections by `/` and iterate through them, performing the following on each section (iterating forwards from the beginning of the path, becoming more and more specific): + 1. Make a path out of all segments up to the current point, adding `/*` at the end (indicative of incremental generation in the render configuration). + 2. Try that as a key, return if it works. + 3. Even if we have something, continue iterating until we have nothing. This way, we get the most specific path possible (and we can have incremental generation in incremental generation). + +## Relationship with Sycamore's Router + +Sycamore has its own [routing system](https://sycamore-rs.netlify.app/docs/v0.6/advanced/routing), which Perseus depends on extensively under the hood. This is evident in `.perseus/src/lib.rs`, which invokes the router. However, rather than using the traditional Sycamore approach of having an `enum` with variants for each possible route (which was the approach in Perseus v0.1.x), Perseus provides the router with a `struct` that performs routing logic and returns either `RouteVerdict::Found`, `RouteVerdict::LocaleDetection`, or `RouteVerdict::NotFound`. The render configuration is accessed through a global variable implanted in the user's HTML shell when the server initializes. diff --git a/docs/0.3.x/src/advanced/subsequent-loads.md b/docs/0.3.x/src/advanced/subsequent-loads.md new file mode 100644 index 0000000000..ca5cb214e4 --- /dev/null +++ b/docs/0.3.x/src/advanced/subsequent-loads.md @@ -0,0 +1,20 @@ +# Subsequent Loads + +if the user follows a link inside a Perseus app to another page within that same app, the Sycamore router will catch it and prevent the browser from requesting the new file from the server. The following will then occur (for an `/about` page rendered simply): + +1. Sycamore router calls Perseus inferred router logic. +2. Perseus inferred router determines from new URL that template `about` should be used, returns to Sycamore router. +3. Sycamore router passes that to closure in `perseus-cli-builder` shell, which executes core app shell. +4. App shell checks if an initial load declaration global variable is present and finds none, hence it will proceed with the subsequent load system. +5. App shell fetches page data from `/.perseus/page/<locale>/about?template_name=about` (if the app isn't using i18n, `<locale>` will verbatim be `xx-XX`). +6. Server checks to ensure that locale is supported. +7. Server renders page using internal systems (in this case that will just return the static HTML file from `.perseus/dist/static/`). +8. Server renders document `<head>`. +9. Server returns JSON of HTML snippet (not complete file), stringified properties, and head. +10. App shell deserializes page data into state and HTML snippet. +11. App shell interpolates HTML snippet directly into `__perseus_content_rx` (which Sycamore router controls), user can now see new page. +12. App shell interpolates new document `<head>`. +13. App shell initializes translator if the app is using i18n. +14. App shell hydrates content at `__perseus_content_rx`, page is now interactive. + +The two files integral to this process are [`page_data.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus-actix-web/src/page_data.rs) and [`shell.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/shell.rs). diff --git a/docs/0.3.x/src/cli.md b/docs/0.3.x/src/cli.md new file mode 100644 index 0000000000..3502e46ba3 --- /dev/null +++ b/docs/0.3.x/src/cli.md @@ -0,0 +1,47 @@ +# CLI + +One of the things that makes Perseus so different from most Rust frameworks is that it has its own CLI for development. The reason for this is to make using Perseus as simple as possible, and also because, if you have a look at what's in `.perseus/`, building without the CLI is really hard! + +## Commands + +### `build` + +Builds your app, performing static generation and preparing a Wasm package in `.perseus/dist/`. + +### `serve` + +Builds your app in the same way as `build`, and then builds the Perseus server (which has dependencies on your code, and so needs to rebuilt on any changes just like the stuff in `.perseus/dist/`), finally serving your app at <http://localhost:8080>. You can change the default host and port this serves on with the `HOST` and `PORT` environment variables. + +You can also provide `--no-build` to this command to make it skip building your app to Wasm and performing static generation. In this case, it will just build the serve rand run it (ideal for restarting the server if you've made no changes). + +### `test` + +Exactly the same as `serve`, but runs your app in testing mode, which you can read more about [here](./testing/intro.md). + +### `export` + +Builds and exports your app to a series of purely static files at `.perseus/dist/exported/`. This will only work if your app doesn't use any strategies that can't be run at build time, but if that's the case, then you can easily use Perseus without a server after running this command! You can read more about static exporting [here](./exporting.md). + +### `deploy` + +Builds your app for production and places it in `pkg/`. You can then upload that folder to a server of your choosing to deploy your app live! You can (and really should) read more about deployment and the potential problems you may encounter [here](./deploying/intro.md). + +### `clean` + +This command is the solution to just about any problem in your app that doesn't make sense, it deletes the `.perseus/` directory entirely, which should remove any corruptions! If this doesn't work, then the problem is in your code (unless you just updated to a new version and now something doesn't work, then it's probably on us, please [open an issue](https://github.com/arctic-hen7/perseus)!). + +Note that this command will force Perseus to rebuild `.perseus/` the next time you run `perseus build` or `perseus serve`, which can be annoying in terms of build times. It's almost always sufficient to run this command with the `--dist` flag, which will only delete some content in `.perseus/dist/` that's likely to be problematic. + +### `eject` + +See the next section for the details of this command. + +## Watching + +Right now, the Perseus CLI doesn't support watching files for changes and rebuilding, but it soon will. Until then, you can replicate this behavior with a tool like [`entr`](https://github.com/eradman/entr) or the equivalent. Anything that watches file and reruns commands when they change will work for this. + +Here's an example of watching files with `entr`: + +``` +find . -not -path "./.perseus/*" -not -path "./target/*" | entr -s "perseus serve" +``` diff --git a/docs/0.3.x/src/debugging.md b/docs/0.3.x/src/debugging.md new file mode 100644 index 0000000000..4f2984bc98 --- /dev/null +++ b/docs/0.3.x/src/debugging.md @@ -0,0 +1,5 @@ +# Debugging + +If you're used to Rust, you might be expecting to be able to call `println!` or `dbg!` to easily print a value to the browser console while working on an app, however this is unfortunately not yet the case (this is an issue in the lower-level libraries that Perseus depends on). + +However, Perseus exports a macro called `web_log!` that can be used to print to the console. It accepts syntax identical to `format!`, `println!`, and the like and behaves in the same way, but it will print to the browser console instead of the terminal. diff --git a/docs/0.3.x/src/define-app.md b/docs/0.3.x/src/define-app.md new file mode 100644 index 0000000000..d2a40a6c8b --- /dev/null +++ b/docs/0.3.x/src/define-app.md @@ -0,0 +1,42 @@ +# `define_app!` + +The core of Perseus is how it interacts with the CLI, which acts as the engine that runs your code. The bridge between these two systems is the `define_app!` macro, which accepts a number of options that define your app. + +The smallest this can reasonably get is a fully self-contained app (taken from [here](https://github.com/arctic-hen7/perseus/tree/main/examples/tiny/src/lib.rs)): + +```rust,no_run,no_playground +{{#include ../../../examples/tiny/src/lib.rs}} +``` + +In a more complex app though, this macro still remains very manageable (taken from [here](https://github.com/arctic-hen7/perseus/tree/main/examples/showcase/src/lib.rs)): + +```rust,no_run,no_playground +{{#include ../../../examples/showcase/src/lib.rs}} +``` + +## Parameters + +Here's a list of everything you can provide to the macro and what each one does (note that the order of these matters): + +- `root` (optional) -- the HTML `id` to which your app will be rendered, the default is `root`; this MUST be reflected in your `index.html` file as an exact replication (spacing and all) of `<div id="root-id-here"></div>` (replacing `root-id-here` with the value of this property) +- `templates` -- defines a list of your templates in which order is irrelevant +- `error_pages` -- defines an instance of `ErrorPages`, which tells Perseus what to do on an error (like a *404 Not Found*) +- `locales` (optional) -- defines options for i18n (internationalization), this shouldn't be specified for apps not using i18n + - `default` -- the default locale of your app (e.g. `en-US`) + - `other` -- a list of the other locales your app supports +- `static_aliases` (optional) -- a list of aliases to static files in your project (e.g. for a favicon) +- `dist_path` (optional) -- a custom path to distribution artifacts (this is relative to `.perseus/`!) +- `mutable_store` (optional) -- a custom mutable store +- `translations_manager` (optional) -- a custom translations manager + +**WARNING:** if you try to include something from outside the current directory in `static_aliases`, **no part of your app will load**! If you could include such content, you might end up serving `/etc/passwd`, which would be a major security risk. + +## Other Files + +There's only one other file that the `define_app!` macro expects to exist: `index.html`. Note that any content in the `<head>` of this will be on every page, above anything inserted by the template. + +Here's an example of this file (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/index.html)): + +```html +{{#include ../../../examples/basic/index.html}} +``` diff --git a/docs/0.3.x/src/deploying/intro.md b/docs/0.3.x/src/deploying/intro.md new file mode 100644 index 0000000000..e1c5817ab5 --- /dev/null +++ b/docs/0.3.x/src/deploying/intro.md @@ -0,0 +1,25 @@ +# Deploying + +> **WARNING:** although Perseus is technically ready for deployment, the system is not yet recommended for production! See [here](../what-is-perseus.md#how-stable-is-it) for more details. + +Perseus is a complex system, but we aim to make deploying it as easy as possible. This section will describe a few different types of Perseus deployments, and how they can be managed. + +## Release Mode + +The Perseus CLI supports the `--release` flag on the `build`, `serve`, and `export` commands. When you're preparing a production release of your app, be sure to use this flag! + +## `perseus deploy` + +If you haven't [ejected](../cli/ejecting.md), then you can prepare your app for deployment with a single command: `perseus deploy`. If you can use [static exporting](../exporting.md), then you should run `perseus deploy -e`, otherwise you should just use `perseus deploy`. + +This will create a new directory `pkg/` for you (you can change that by specifying `--output`) which will contain everything you need to deploy your app. That directory is entirely self-contained, and can be copied to an appropriate hosting provider for production deployment! + +Note that this command will run a number of optimizations in the background, including using the `--release` flag, but it won't try to aggressively minimize your Wasm code size. For tips on how to do that, see [here](./size.md). + +### Static Exporting + +If you use `perseus deploy -e`, the contents of `pkg/` can be served by any file host that can handle the [slight hiccup](../exporting.md#file-extensions) of file extensions. Locally, you can test this out with [`serve`](https://github.com/vercel/serve), a JavaScript package designed for this purpose. + +### Fully-Fledged Server + +If you just use `perseus deploy`, the `pkg/` directory will contain a binary called `server` for you to run, which will serve your app on its own. However, it's important to note that this binary is structured to support either the development configuration of running inside `.perseus/` or the production configuration of running inside `pkg/`, and you have to provide the `PERSEUS_STANDALONE` environment variable to tell it to do the latter. This binary can then be run on any server with a writable filesystem. For more details on this, see the next subsection. diff --git a/docs/0.3.x/src/deploying/serverful.md b/docs/0.3.x/src/deploying/serverful.md new file mode 100644 index 0000000000..4c37dcd8ff --- /dev/null +++ b/docs/0.3.x/src/deploying/serverful.md @@ -0,0 +1,27 @@ +# Server Deployment + +If your app uses rendering strategies that need a server, you won't be able to export your app to purely static files, and so you'll need to host the Perseus server itself. + +You can prepare your production server by running `perseus deploy`, which will create a new directory called `pkg/`, which will contain the standalone binary and everything needed to run it. You should then upload this file to your server and set the `PERSEUS_STANDALONE` environment variable to `true` so that Perseus expects a standalone binary configuration. Note that this process will vary depending on your hosting provider. + +## Hosting Providers + +As you may recall from [this section](../stores.md) on immutable and mutable stores, Perseus modifies some data at runtime, which is problematic if your hosting provider imposes the restriction that you can't write to the filesystem (as Netlify does). Perseus automatically handles this as well as it can by separating out mutable from immutable data, and storing as much as it can on the filesystem without causing problems. However, data for pages that use the *revalidation* or *incremental generation* strategies must be placed in a location where it can be changed while Perseus is running. + +If you're only using *build state* and/or *build paths* (or neither), you should export your app to purely static files instead, which you can read more about doing [here](../exporting.md). That will avoid this entire category of problems, and you can deploy basically wherever you want. + +If you're bringing *request state* into the mix, you can't export to static files, but you can run on a read-only filesystem, because only the *revalidation* and *incremental generation* strategies require mutability. Perseus will use a mutable store on the filesystem in the background, but won't ever need it. + +If you're using *revalidation* and *incremental generation*, you have two options, detailed below. + +### Writable Filesystems + +The first of these is to use an old-school provider that gives you a filesystem that you can write to. This may be more expensive for hosting, but it will allow you to take full advantage of all Perseus' features in a highly performant way. + +You can deploy to one of these providers without any further changes to your code, as they mimic your local system almost entirely (with a writable filesystem). Just run `perseus deploy` and copy the resulting `pkg/` folder to the server! + +### Alternative Mutable Stores + +The other option you have is deploying to a modern provider that has a read-only filesystem and then using an alternative mutable store. That is, you store your mutable data in a database or the like rather than on the filesystem. This requires you to implement the `MutableStore` `trait` for your storage system (see the [API docs](https://docs.rs/perseus)), which should be relatively easy. You can then provide this to the `define_app!` macro with the `mutable_store` parameter. Make sure to test this on your local system to ensure that your connections all work as expected before deploying to the server, which you can do with `perseus deploy` and by then copying the `pkg/` directory to the server. + +This approach may seem more resilient and modern, but it comes with a severe downside: speed. Every request that involves mutable data (so any request for a revalidating page or an incrementally generated one) must go through four trips (an extra one to and from the database) rather than two, which is twice as many as usual! This will bring down your site's time to first byte (TTFB) radically, so you should ensure that your mutable store is as close to your server as possible so that the latency between them is negligible. If this performance pitfall is not acceptable, you should use an old-school hosting provider instead. diff --git a/docs/0.3.x/src/deploying/serverless.md b/docs/0.3.x/src/deploying/serverless.md new file mode 100644 index 0000000000..923dca4156 --- /dev/null +++ b/docs/0.3.x/src/deploying/serverless.md @@ -0,0 +1,3 @@ +# Serverless Deployment + +> This strategy of Perseus deployment will be possible eventually, but right now more work needs to be done on support for read-only filesystems before work on this can even be considered. diff --git a/docs/0.3.x/src/deploying/size.md b/docs/0.3.x/src/deploying/size.md new file mode 100644 index 0000000000..f348ca2809 --- /dev/null +++ b/docs/0.3.x/src/deploying/size.md @@ -0,0 +1,43 @@ +# Optimizing Code Size + +If you're used to working with Rust, you're probably used to two things: performance is everything, and Rust produces big binaries. With Wasm, these actually become problems because of the way the web works. If you think about it, your Wasm files (big because Rust optimizes for speed instead of size by default) need to be sent to browsers. So, the larger they are, the slower your site will be. Fortunately, Perseus only makes this relevant when a user first navigates to your site with its [subsequent loads](../advanced/subsequent-loads.md) system. However, it's still worth optimizing code size in places. + +If you've worked with Rust and Wasm before, you may be familiar with `wasm-opt`, which performs a ton of optimizations for you. Perseus does this automatically with `wasm-pack`. But we can do better. + +## `wee_alloc` + +Rust's memory allocator takes up quite a lot of space in your final Wasm binary, and this can be solved by trading off performance for smaller sizes, which can actually make your site snappier because it will load faster. `wee_alloc` is an alternative allocator built for Wasm, and you can enable it by adding it to your `Cargo.toml` as a dependency: + +```toml +wee_alloc = "0.4" +``` + +And then you can add it to the top of your `src/lib.rs`: + +```rust,no_run,no_playground +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; +``` + +With the [basic example](https://github.com/arctic-hen7/perseus/tree/main/examples/basic), we saw improvements from 369.2kb to 367.8kb with `wee_alloc` and release mode. These aren't much though, and we can do better. + +## Aggressive Optimizations + +More aggressive optimizations need to be applied to both Perseus' engine and your own code, so you'll need to [eject](../ejecting.md) for this to work properly. Just run `perseus eject`, and then add the following to `.perseus/Cargo.toml`: + +```toml +[profile.release] +lto = true +opt-level = "z" +``` + +Then add the same thing to your own `Cargo.toml`. Note that, if this is the only modification you make after ejecting, `perseus deploy` will still work perfectly as expected. + +What this does is enable link-time optimizations, which do magic stuff to make your code smaller, and then we set the compiler to optimize aggressively for speed. On the [basic example](https://github.com/arctic-hen7/perseus/tree/main/examples/basic), we say improvements from 367.8kb with `wee_alloc` and release mode to 295.3kb when we added these extra optimizations. That's very significant, and we recommend using these if you don't have a specific reason not to. Note however that you should definitely test your site's performance after applying these to make sure that you feel you've achieved the right trade-off between performance and speed. If not, you could try setting `opt-level = "s"` instead of `z` to optimize less aggressively for speed, or you could try disabling some optimizations. + +<details> +<summary>Read this if something blows up in your face.</summary> + +As of time of writing, Netlify (and possibly other providers) doesn't support Rust binaries that use `lto = true` for some reason, it simply doesn't detect them, so you shouldn't use that particular optimization if you're working with Netlify. + +</details> diff --git a/docs/0.3.x/src/ejecting.md b/docs/0.3.x/src/ejecting.md new file mode 100644 index 0000000000..71c877e8fd --- /dev/null +++ b/docs/0.3.x/src/ejecting.md @@ -0,0 +1,30 @@ +# Ejecting + +The Perseus CLI is fantastic at enabling rapid and efficient development, but sometimes it can be overly restrictive. If there's a use-case that the CLI doesn't seem to support, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose) on GitHub, and we'll look into supporting it out-of-the-box. + +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. + +*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 + +`perseus eject` + +This command does two things: it removes `.perseus/` from your `.gitignore` file, and it adds a new file called `.perseus/.ejected`. + +After ejecting, there are a few things that change. + +- You can no longer run `perseus clean` unless you provide the `--dist` flag (otherwise it would delete the engine you're tweaking!) +- A ton of files appear in Git that you should commit, all from `.perseus/` + +## Architecture + +Under the hood, Perseus' CLI is only responsible for running commands like `cargo run` and `wasm-pack build`. All the logic is done in `.perseus/`, which provides two crates, one for your app itself (which also contains a binary for running static generation) and another for the server that will run your app. That means that you can still use the CLI! + +One of the first things you'll probably want to do if you choose to eject is to remove the `[workspace]` declaration from `.perseus/Cargo.toml` and instead add both crates inside to your project's workspace. This will make sure that linters like RLS will check your modifications to `.perseus/` for any problems, and you won't be flying blind. + +The rest of the documentation on how Perseus works under the hood can be found in the *Advanced* section of the book, which you'll want to peruse if you choose to eject. + +## Reversing Ejection + +If, after taking a look at the innards, you decide that you'd like to find a solution for your problem that works without having to perform what can easily seem like the programming equivalent of brain surgery, you can easily reverse ejection by deleting the `.perseus/.ejected` file and running `perseus clean`, which will permanently delete your modifications and allow you to start again with a clean slate. Note that the reversal of ejection is irreversible, so it pays to have a backup of your changes in case you want to check something later! diff --git a/docs/0.3.x/src/error-pages.md b/docs/0.3.x/src/error-pages.md new file mode 100644 index 0000000000..35eaa023f8 --- /dev/null +++ b/docs/0.3.x/src/error-pages.md @@ -0,0 +1,30 @@ +# Error Pages + +When developing for the web, you'll need to be familiar with the idea of an *HTTP status code*, which is a numerical indication in HTTP (HyperText Transfer Protocol) of how the server reacted to a client's request. The most well-known of these is the infamous *404 Not Found* error, but there are dozens of these in total. Don't worry, you certainly don't need to know all of them by heart! + +## Handling HTTP Status Codes in Perseus + +Perseus has an *app shell* that manages fetching pages for the user (it's a little more complicated than the traditional design of that kind of a system, but that's all you need to know for now), and this is where HTTP errors will occur as it communicates with the Perseus server. If the status code is an error, this shell will fail and render an error page instead of the page the user visited. This way, an error page can be displayed at any route, without having to navigate to a special route. + +You can define one error page for each HTTP status code in Perseus, and you can see a list of those [here](https://httpstatuses.com). Here's an example of doing so for *404 Not Found* and *400* (a generic error caused by the client) (taken from [here](https://github.com/arctic-hen7/perseus/tree/main/examples/showcase/src/error_pages.rs)): + +```rust,no_run,no_playground +{{#include ../../../examples/showcase/src/error_pages.rs}} +``` + +It's conventional in Perseus to define a file called `src/error_pages.rs` and put your error pages in here for small apps, but for larger apps where your error pages are customized with beautiful logos and animations, you'll almost certainly want this to be a folder, and to have a separate file for each error page. + +When defining an instance of `ErrorPages`, you'll need to provide a fallback page, which will be used for all the status codes that you haven't specified unique pages for. In the above example, this fallback would be used for, say, a *500* error, which indicates an internal server error. + +The most important thing to note about these error pages is the arguments they each take, which have all been ignored in the above example with `_`s. There are four of these: + +- URL that caused the error +- HTTP status code (`u16`) +- Error message +- Translator (inside an `Option<T>`) + +## Translations in Error Pages + +Error pages are also available for you to use yourself (see the [API docs](https://docs.rs/perseus) on the functions to call for that) if an error occurs in one of your own pages, and in that case, if you're using i18n, you'll have a `Translator` available. However, there are *many* cases in Perseus in which translators are not available to error pages (e.g. the error page might have been rendered because the translator couldn't be initialized for some reason), and in these cases, while it may be tempting to fall back to the default locale, you should optimally make your page as easy to decipher for speakers of other languages as possible. This means emoji, pictures, icons, etc. Bottom line: if the fourth parameter to an error page is `None`, then communicate as universally as possible. + +An alternative is just to display an error message in every language that your app supports, which may in some cases be easier and more practical. diff --git a/docs/0.3.x/src/exporting.md b/docs/0.3.x/src/exporting.md new file mode 100644 index 0000000000..8d52c7c7d6 --- /dev/null +++ b/docs/0.3.x/src/exporting.md @@ -0,0 +1,11 @@ +# Static Exporting + +Thus far, we've used `perseus serve` to build and serve Perseus apps, but there is an alternative way that offers better performance in some cases. Namely, if your app doesn't need any rendering strategies that can't be run at build time (so if you're only using *build state* and/or *build paths* or neither), you can export your app to a set of purely static files that can be served by almost any hosting provider. You can do this by running `perseus export`, which will create a new directory `.perseus/dist/exported/`, the contents of which can be served on a system like [GitHub Pages](https:://pages.github.com). Your app should behave in the exact same way with exporting as with normal serving. If this isn't the case, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). + +There is only one known difference between the behavior of your exported site and your normally served site, and that's regarding [static aliases](../static-content.md). In a normal serving scenario, any static aliases that conflicted with a Perseus page or internal asset would be ignored, but, in an exporting context, **any static aliases that conflict with Perseus pages will override them**! If you suspect this might be happening to you, try exporting without those aliases and make sure the URL of your alias file doesn't already exist (in which case it would be a Perseus component). + +## File Extensions + +One slight hiccup with Perseus' static exporting system comes with regards to the `.html` file extension. Perseus' server expects that pages shouldn't have such extensions (hence `/about` rather than `/about.html`), but, when statically generated, they must have these extensions in the filesystem. So, if you don't want these extensions for your users (and if you want consistent behavior between exporting and serving), it's up to whatever system you're hosting your files with to strip these extensions. Many systems do this automatically, though some (like Python's `http.server`) do not. + +One of the best systems for testing static exporting on your local machine is the [`serve`](https://github.com/vercel/serve) JavaScript package, which can be run from the command-line without touching any JavaScript, and it handles this problem automatically. However, other solutions certainly exist if you don't want any JS polluting your system! diff --git a/docs/0.3.x/src/hello-world.md b/docs/0.3.x/src/hello-world.md new file mode 100644 index 0000000000..527d1ca914 --- /dev/null +++ b/docs/0.3.x/src/hello-world.md @@ -0,0 +1,99 @@ +# Hello World! + +Let's get started with Perseus! + +*To follow along here, you'll want to be familiar with Rust, which you can learn more about [here](https://rust-lang.org). You should also have it and `cargo` installed.* + +To begin, create a new folder for your project, let's call it `my-perseus-app`. Now, create a `Cargo.toml` file in that folder. This tells Rust which packages you want to use in your project and some other metadata. Put the following inside: + +```toml +{{#include ../../../examples/tiny/Cargo.toml.example}} +``` + +<details> +<summary>What are those dependencies doing?</summary> + +- `perseus` -- the core module for Perseus +- [`sycamore`](https://github.com/sycamore-rs/sycamore) -- the amazing system on which Perseus is built, this allows you to write reactive web apps in Rust + +Note that we've set these dependencies up so that they'll automatically update *patch versions*, which means we'll get bug fixes automatically, but we won't get any updates that will break our app! + +</details> + +Now, create an `index.html` file at the root of your project and put the following inside: + +```html +{{#include ../../../examples/tiny/index.html}} +``` + +<details> +<summary>Why do I need an HTML file?</summary> + +Perseus aims to be as versatile as possible, and so it allows you to include your own `index.html` file, in which you can import things like fonts, analytics, etc. + +This file MUST contain at least the following: + +- `<div id="root"></div>`, which is where your app will be rendered, this must be a `<div>` with no other attributes except the `id`, and that spacing (that way parsing is lightweight and fast) +- A `<head>`, which is where HTML metadata goes (even if you don't have any metadata, Perseus still needs it) + +Note also that we don't have to import anything to make Perseus run here, the server will do that automatically for us! + +</details> + +Now, create a new directory called `src` and add a new file inside called `lib.rs`. Put the following inside: + +```rust,no_run,no_playground +{{#include ../../../examples/tiny/src/lib.rs}} +``` + +<details> +<summary>How does that work?</summary> + +First, we import some things that'll be useful: + +- `perseus::{define_app, ErrorPages, Template}` -- the -`define_app!` macro, which tells Perseus how your app works; the `ErrorPages` `struct`, which lets you tell Perseus how to handle errors (like *404 Not Found* if the user goes to a nonexistent page); and the `Template` `struct`, which is how Perseus manages pages in your app +- `std::rc::Rc` -- a [reference-counted smart pointer](https://doc.rust-lang.org/std/rc/struct.Rc.html) (you don't *have* to understand these to use Perseus, but reading that link would be helpful) +- `sycamore::template` -- Sycamore's [`template!` macro], which lets you write HTML-like code in Rust + +Then, we use the `define_app!` macro to declare the different aspects of the app, starting with the *templates*. We only have one template, which we've called `index` (a special name that makes it render at the root of your app), and then we define how that should look, creating a paragraph (`p`) containing the text `Hello World!`. Perseus does all kinds of clever stuff with this under the hood, and we put it in an `Rc` to enable that. + +Finally, we tell Perseus what to do if something in your app fails, like if the user goes to a page that doesn't exist. This requires creating a new instance of `ErrorPages`, which is a `struct` that lets you define a separate error page for every [HTTP status code](https://httpstatuses.com), as well as a fallback. Here, we've just defined the fallback. That page is given the URL that caused the error, the HTTP status code, and the actual error message, all of which we display with a Sycamore `template!`, with seamless interpolation. + +</details> + +Now install the Perseus CLI with `cargo install perseus-cli` (you'll need `wasm-pack` to let Perseus build your app, use `cargo install wasm-pack` to install it) to make your life way easier, and deploy your app to <http://localhost:8080> by running `perseus serve` inside the root of your project! This will take a while the first time, because it's got to fetch all your dependencies and build your app. + +<details> +<summary>Why do I need a CLI?</summary> + +Perseus is a *very* complex system, and, if you had to write all that complexity yourself, that *Hello World!* example would be more like 1700 lines of code than 17! The CLI lets you abstract away all that complexity into a directory that you might have noticed appear called `.perseus/`. If you take a look inside, you'll actually find two crates (Rust packages): one for your app, and another for the server that serves your app. These are what actually run your app, and they import the code you've written. The `define_app!` macro defines a series of functions and constants at compile-time that make this possible. + +When you run `perseus serve`, the `.perseus/` directory is created and added to your `.gitignore`, and then three stages occur in parallel (they're shown in your terminal): + +- *🔨 Generating your app* -- here, your app is built to a series of static files in `.perseus/dist/static`, which makes your app lightning-fast (your app's pages are ready before it's even been deployed, which is called *static site generation*, or SSG) +- *🏗️ Building your app to Wasm* -- here, your app is built to [WebAssembly](), which is what lets a low-level programming language like Rust run in the browser +- *📡 Building server* -- here, Perseus builds its internal server based on your code, and prepares to serve your app + +The first time you run this command, it can take quite a while to get everything ready, but after that it'll be really fast. And, if you haven't changed any code (*at all*) since you last ran it, you can run `perseus serve --no-build` to run the server basically instantaneously. + +</details> + +Once that's done, hop over to <http://localhost:8080> in any modern browser (not Internet Explorer...), and you should see *Hello World!* printed on the screen! If you try going to <http://localhost:8080/about> or any other page, you should see a message that tells you the page wasn't found. + +Congratulations! You've just created your first ever Perseus app! You can see the source code for this section [here](https://github.com/arctic-hen7/perseus/tree/main/examples/tiny). + +## Moving Forward + +The next section creates a slightly more realistic app with more than just one file, which will show you how a Perseus app is usually structured. + +After that, you'll learn how different features of Perseus work, like *incremental generation* (which lets you build pages on-demand at runtime)! + +### Alternatives + +If you've gone through this and you aren't that chuffed with Perseus, here are some similar projects in Rust: + +- [Sycamore](https://github.com/sycamore-rs/sycamore) (without Perseus) -- *A reactive library for creating web apps in Rust and WebAssembly.* +- [Yew](https://github.com/yewstack/yew) -- *Rust/Wasm framework for building client web apps.* +- [Seed](https://github.com/seed-rs/seed) -- *A Rust framework for creating web apps.* +- [Percy](https://github.com/chinedufn/percy) -- *Build frontend browser apps with Rust + WebAssembly. Supports server side rendering.* +- [MoonZoon](https://github.com/MoonZoon/MoonZoon) -- *Rust Fullstack Framework.* diff --git a/docs/0.3.x/src/i18n/defining.md b/docs/0.3.x/src/i18n/defining.md new file mode 100644 index 0000000000..54fecb911e --- /dev/null +++ b/docs/0.3.x/src/i18n/defining.md @@ -0,0 +1,27 @@ +# Defining Translations + +The first part of setting up i18n in Perseus is to state that your app uses it, which is done in the `define-app!` macro like so (taken from [the i18n example](https://github.com/arctic-hen7/perseus/tree/main/examples/i18n)): + +```rust,no_run,no_playground +{{#include ../../../../examples/i18n/src/lib.rs}} +``` + +There are two subfields under the `locales` key: `default` and `other`. Each of these locales should be specified in the form `xx-XX`, where `xx` is the language code (e.g. `en` for English, `fr` for French, `la` for Latin) and `XX` is the region code (e.g. `US` for United States, `GB` for Great Britain, `CN` for China). + +## Routing + +After you've enabled i18n like so, every page on your app will be rendered behind a locale. For example, `/about` will become `/en-US/about`, `/fr-FR/about`, and`/es-ES/about` in the above example. These are automatically rendered by Perseus at build-time, and they behave exactly the same as every other feature of Perseus. + +Of course, it's hardly optimal to direct users to a pre-translated page if they may prefer it in another language, which is why Perseus supports *locale detection* automatically. In other words, you can direct users to `/about`, and they'll automatically be redirected to `/<locale>/about`, where `<locale>` is their preferred locale according to `navigator.languages`. This matching is done based on [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt), which defines how locale detection should be done. + +## Adding Translations + +After you've added those definitions to `define_app!`, if you try to run your app, you'll find that ever page throws an error because it can't find any of the translations files. These must be defined under `translations/` (which should be NEXT to `/src`, not under it!), though this can be customized (explained later). They must also adhere to the naming format `xx-XX.ftl` (e.g. `en-US.ftl`). `.ftl` is the file extension that [Fluent](https://projectfluent.org) files use, which is the default translations system of Perseus. If you'd like to use a different system, this will be explained later. + +Here's an example of a translations file (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/i18n/translations/en-US.ftl)): + +```fluent +{{#include ../../../../examples/i18n/translations/en-US.ftl}} +``` + +You can read more about Fluent's syntax [here](https://projectfluent.org) (it's *very* powerful). diff --git a/docs/0.3.x/src/i18n/intro.md b/docs/0.3.x/src/i18n/intro.md new file mode 100644 index 0000000000..115ebf0220 --- /dev/null +++ b/docs/0.3.x/src/i18n/intro.md @@ -0,0 +1,7 @@ +# Internationalization + +Internationalization (abbreviated *i18n*) is making an app available in many languages. Perseus supports this out-of-the-box with [Fluent](https://projectfluent.org). + +The approach usually taken to i18n is to use translation IDs in your code instead of natural language. For example, instead of writing `format!("Hello, {}!", name.get())`, you'd write something like `t!("greeting", {"name" => name.get()})`. This ensures that your app works well for people across the world, and is crucial for any large apps. + +This section will explain how i18n works in Perseus and how to use it to make lightning-fast apps that work for people across the planet. diff --git a/docs/0.3.x/src/i18n/other-engines.md b/docs/0.3.x/src/i18n/other-engines.md new file mode 100644 index 0000000000..1f654ed429 --- /dev/null +++ b/docs/0.3.x/src/i18n/other-engines.md @@ -0,0 +1,13 @@ +# Other Translation Engines + +Perseus uses [Fluent](https://projectfluent.org) for i18n by default, but this isn't set in stone. Rather than providing only one instance of `Translator`, Perseus can support many through Cargo's features system. By default, Perseus will enable the `translator-fluent` feature to build a `Translator` `struct` that uses Fluent. The `translator-dflt-fluent` feature will also be enabled, which sets `perseus::Translator` to be an alias for `FluentTranslator`. + +If you want to create a translator for a different system, this will need to be integrated into Perseus as a pull request, but we're more than happy to help with these efforts. Optimally, Perseus will in future support multiple translations systems, and developers will be able to pick the one they like the most + +## Why Not a Trait? + +It may seem like this problem could simply be solved with a `Translator` trait, as is done with translations managers, but unfortunately this isn't so simple because of the way translators are transported through the app. The feature-gating solution was chosen as the best compromise between convenience and performance. + +## How Do I Make One? + +If you want to make your own alternative translation engine, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose) about it, explaining the system you want to support. Provided the system is compatible with Perseus' i18n design (which it certainly should be if we've done our job correctly!), we'll be happy to help you get it into Perseus! diff --git a/docs/0.3.x/src/i18n/translations-managers.md b/docs/0.3.x/src/i18n/translations-managers.md new file mode 100644 index 0000000000..5ec2d7eeb8 --- /dev/null +++ b/docs/0.3.x/src/i18n/translations-managers.md @@ -0,0 +1,17 @@ +# Translations Managers + +As mentioned earlier, Perseus expects your translations to be in the very specific location of `translations/<locale>.ftl`, which may not be feasible or preferable in all cases. In fact, there may indeed be cases where translations might be stored in an external database (not recommended for performance as translations are regularly requested, filesystem storage with caching is far faster). + +If you'd like to change this default behavior, this section is for you! Perseus manages the locations of translations with a `TranslationsManager`, which defines a number of methods for accessing translations, and should implement caching internally. Perseus has two inbuilt managers: `FsTranslationsManager` and `DummyTranslationsManager`. The former is used by default, and the latter if i18n is disabled. + +## Using a Custom Translations Manager + +The `define_app!` macro accepts a property called `translations_manager` if you define `locales`, which can be used to specify a non-default translations manager. + +## Using a Custom Directory + +If you just want to change the directory in which translations are stored, you can still use `FsTranslationsmanager`, just initialize it with a different directory, and make sure to set up caching properly. See [here](https://github.com/arctic-hen7/perseus/blob/f7f7892fbf124a7d887b1f22a1641c79773d6246/packages/perseus/src/macros.rs#L35-L50) for how this is done internally. + +## Building a Custom Translations Manager + +This is more complex, and you'll need to consult [this file](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/translations_manager.rs) (note: the client translations manager is very different) in the Perseus source code for guidance. If you're stuck, don't hesitate to ask a question under [discussions](https://github.com/arctic-hen7/perseus/discussions/new) on GitHub! diff --git a/docs/0.3.x/src/i18n/using.md b/docs/0.3.x/src/i18n/using.md new file mode 100644 index 0000000000..ef830a86ed --- /dev/null +++ b/docs/0.3.x/src/i18n/using.md @@ -0,0 +1,21 @@ +# Using Translations + +Perseus tries to make it as easy as possible to use translations in your app by exposing the low-level Fluent primitives necessary to work with very complex translations, as well as a `t!` macro that does the basics. + +All translations in Perseus are done with an instance of `Translator`, which is provided through Sycamore's [context system](https://sycamore-rs.netlify.app/docs/v0.6/advanced/contexts). Here's an example taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/i18n/src/templates/index.rs): + +```rust,no_run,no_playground +{{#include ../../../../examples/i18n/src/templates/index.rs}} +``` + +In that example, we've imported `perseus::t`, and we use it to translate the `hello` ID, which takes an argument for the username. Notice that we don't provide a locale, Perseus handles all this in the background for us. + +## Getting the `Translator` + +That said, there are some cases in which you'll want access to the underlying `Translator` so you can do more complex things. You can get it like so: + +```rust,no_run,no_playground +sycamore::context::use_context::<perseus::template::RenderCtx>().translator; +``` + +To see all the methods available on `Translator`, see [the API docs](https://docs.rs/perseus). diff --git a/docs/0.3.x/src/intro.md b/docs/0.3.x/src/intro.md new file mode 100644 index 0000000000..eeda301c58 --- /dev/null +++ b/docs/0.3.x/src/intro.md @@ -0,0 +1,12 @@ +# Welcome to Perseus! + +[Home][repo] • [Crate Page][crate] • [API Documentation][docs] • [Contributing][contrib] + +Welcome to the Perseus documentation! Here, you'll find guides on how to use Perseus, as well as documentation for specific features and plenty of examples! Note that every code snippet in this book comes from something in the [examples](https://github.com/arctic-hen7/perseus/tree/main/examples), where you can get context from real-world code. + +If you like Perseus, please consider giving us a star [on GitHub](https://github.com/arctic-hen7/perseus)! + +[repo]: https://github.com/arctic-hen7/perseus +[crate]: https://crates.io/crates/perseus +[docs]: https://docs.rs/perseus +[contrib]: ./CONTRIBUTING.md diff --git a/docs/0.3.x/src/second-app.md b/docs/0.3.x/src/second-app.md new file mode 100644 index 0000000000..2e896dbf75 --- /dev/null +++ b/docs/0.3.x/src/second-app.md @@ -0,0 +1,178 @@ +# Your Second App + +This section will cover building a more realistic app than the _Hello World!_ section, with proper structuring and multiple templates. + +If learning by reading isn't really your thing, or you'd like a reference, you can see all the code in [this repository](https://github.com/arctic-hen7/perseus/tree/main/examples/basic)! + +## Setup + +Much like the _Hello World!_ app, we'll start off by creating a new directory for the project, maybe `my-second-perseus-app` (or you could exercise imagination...). Then, we'll create a new `Cargo.toml` file and fill it with the following: + +```toml +{{#include ../../../examples/basic/Cargo.toml.example}} +``` + +The only difference between this and the last `Cargo.toml` we created is two new dependencies: + +- [`serde`](https://serde.rs) -- a really useful Rust library for serializing/deserializing data +- [`serde_json`](https://github.com/serde-rs/json) -- Serde's integration for JSON, which lets us pass around properties for more advanced pages in Perseus + +The next thing to do is to create `index.html`, which is pretty much the same as last time: + +```html +{{#include ../../../examples/basic/index.html}} +``` + +The only notable difference here is the absence of a `<title>`, which is because we'll be creating it inside Perseus! Any Perseus template can modify the `<head>` of the document, but anything you put into `index.html` will persist across all pages. We don't want to have conflicting titles, so we leave that property out of `index.html`. + +## `lib.rs` + +As in every Perseus app, `lib.rs` is how we communicate with the CLI and tell it how our app works. Put the following content in `src/lib.rs`: + +```rust,no_playground,no_run +{{#include ../../../examples/basic/src/lib.rs}} +``` + +This code is quite different from your first app, so let's go through how it works. + +First, we define two other modules in our code: `error_pages` (at `src/error_pages.rs`) and `templates` (at `src/templates`). Don't worry, we'll create those in a moment. The rest of the code creates a new app with two templates, which are expected to be in the `src/templates` directory. Note the use of `<G>` here, which is a Rust _type parameter_ (the `get_template` function can work for the browser or the server, so Rust needs to know which one it is). This parameter is _ambient_ to the `templates` key, which means you can use it without declaring it as long as you're inside `templates: {...}`. This will be set to `DomNode` for the browser and `SsrNode` for the server, but that all happens behind the scenes. + +Also note that we're pulling in our error pages from another file as well (in a larger app you may even want to have a different file for each error page). + +The last thing we do is new, we define `static_aliases` to map the URL `/test.txt` in our app to the file `static/test.txt`. This feature is detailed in more depth later, but it can be extremely useful, for example for defining your site's logo (or favicon), which browsers expect to be available at `/favicon.ico`. Create the `static/test.txt` file now (`static/` should NOT be inside `src/`!) and fill it with whatever you want. + +## Error Handling + +Before we get to the cool part of building the actual pages of the app, we should set up error pages again, which we'll do in `src/error_pages.rs`: + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/error_pages.rs}} +``` + +This is a little more advanced than the last time we did this, and there are a few things we should note. + +The first is the import of `GenericNode`, which we define as a type parameter on the `get_error_pages` function. As we said before, this means your error pages will work on the client or the server, and they're needed in both environments. If you're interested, this separation of browser and server elements is done by Sycamore, and you can learn more about it [here](https://docs.rs/sycamore/0.6/sycamore/generic_node/trait.GenericNode.html). + +In this function, we also define a different error page for a 404 error, which will occur when a user tries to go to a page that doesn't exist. The fallback page (which we initialize `ErrorPages` with) is the same as last time, and will be called for any errors other than a _404 Not Found_. + +## `index.rs` + +It's time to create the first page for this app! But first, we need to make sure that import in `src/lib.rs` of `mod templates;` works, which requires us to create a new file `src/templates/mod.rs`, which declares `src/templates` as a module with its own code. Add the following to that file: + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/templates/mod.rs}} +``` + +It's common practice to have a file for each _template_, which is slightly different to a page (explained in more detail later), and this app has two pages: a landing page (index) and an about page. + +Let's begin with the landing page. Create a new file `src/templates/index.rs` and put the following inside: + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/templates/index.rs}} +``` + +This code is _much_ more complex than the _Hello World!_ example, so let's go through it carefully. + +First, we import a whole ton of stuff: + +- `perseus` + - `RenderFnResultWithCause` -- see below for an explanation of this + - `Template` -- as before + - `GenericNode` -- as before +- `serde` + - `Serialize` -- a trait for `struct`s that can be turned into a string (like JSON) + - `Deserialize` -- a trait for `struct`s that can be *de*serialized from a string (like JSON) +- `std::rc::Rc` -- same as before, you can read more about `Rc`s [here](https://doc.rust-lang.org/std/rc/struct.Rc.html) +- `sycamore` + - `component` -- a macro that turns a function into a Sycamore component + - `template` -- the `template!` macro, same as before + - `Template as SycamoreTemplate` -- the output of the `template!` macro, aliased as `SycamoreTemplate` so it doesn't conflict with `perseus::Template`, which is very different + +Then we define a number of different functions and a `struct`, each of which gets a section now. + +### `IndexPageProps` + +This `struct` represents the properties that the index page will take. In this case, we're building an index page that will display a greeting defined in this, specifically in the `greeting` property. + +Any template can take arguments in Perseus, which should always be given inside a `struct`. For simplicity and performance, Perseus only ever passes your properties around as a `String`, so you'll need to serialize/deserialize them yourself (as in the functions below). + +### `index_page()` + +This is the actual component that your page is. Technically, you could just put this under `template_fn()`, but it's conventional to break it out independently. By annotating it with `#[component(IndexPage<G>)]`, we tell Sycamore to turn it into a complex `struct` that can be called inside `template!` (which we do in `template_fn()`). + +Note that this takes `IndexPageProps` as an argument, which it can then access in the `template!`. This is Sycamore's interpolation system, which you can read about [here](https://sycamore-rs.netlify.app/docs/basics/template), but all you need to know is that it's basically seamless and works exactly as you'd expect. + +The only other thing we do here is define an `<a>` (an HTML link) to `/about`. This link, and any others you define, will automatically be detected by Sycamore's systems, which will pass them to Perseus' routing logic, which means your users **never leave the page**. In this way, Perseus only pulls in the content that needs to change, and gives your users the feeling of a lightning-fast and weightless app. + +_Note: external links will automatically be excluded from this, and you can exclude manually by adding `rel="external"` if you need._ + +### `get_template()` + +This function is what we call in `lib.rs`, and it combines everything else in this file to produce an actual Perseus `Template` to be used. Note the name of the template as `index`, which Perseus interprets as special, which causes this template to be rendered at `/` (the landing page). + +Perseus' templating system is extremely versatile, and here we're using it to define our page itself through `.template()`, and to define a function that will modify the document `<head>` (which allows us to add a title) with `.head()`. Notably, we also use the _build state_ rendering strategy, which tells Perseus to call the `get_build_props()` function when your app builds to get some state. More on that now. + +#### `.template()` + +The result of this function is what Perseus will call when it wants to render your template (which it does more than you might think), and it passes it the props that your template takes as an `Option<String>`. This might seem a bit weird, but there are reasons under the hood. All you need to know here is that if your template takes any properties, they **will** be here, and it's safe to `.unwrap()` them for deserialization. + +#### `.head()` + +This is very similar to `template_fn`, except it can't be reactive. In other words, anything you put in here is like a picture, it can't move (so no buttons, counters, etc.). This is because this modifies the document `<head>`, so you should put metadata, titles, etc. in here. Note that the function we return from here does take an argument (ignored with `_`), that's a string of the properties to your app, but we don't need it in this example. If this page was a generic template for blog posts, you might use this capability to render a different title for each blog post. + +All this does though is set the `<title>`. If you inspect the source code of the HTML in your browser, you'll find a big comment in the `<head>` that says `<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->`, that separates the stuff that should remain the same on every page from the stuff that should update for each page. + +### `get_build_props()` + +This function is part of Perseus' secret sauce (actually _open_ sauce), and it will be called when the CLI builds your app to create properties that the template will take (it expects a string, hence the serialization). Here, we just hard-code a greeting in to be used, but the real power of this comes when you start using the fact that this function is `async`. You might query a database to get a list of blog posts, or pull in a Markdown documentation page and parse it, the possibilities are endless! + +Note that this function returns a `RenderFnResultWithCause<String>`, which means that it returns a normal Rust `Result<String, E>`, where `E` is a `GenericErrorWithCause`, a Perseus type that combines an arbitrary error message with a declaration of who caused the error (either the client or the server). This becomes important when you combine this rendering strategy with others, which are explained in depth later in the book. Note that we use `?` in this example on errors from modules like `serde_json`, showing how versatile this type is. If you don't explicitly construct `GenericErrorWithCause`, blame for the error will be assigned to the server, resulting in a _500 Internal Server Error_ HTTP status code. + +### `set_headers_fn()` + +This function represents a very powerful feature of Perseus, the ability to set any [HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) for a given template. In this case, any time the Perseus server successfully returns our template to the browser, it will call this function on the HTTP response just before it sends it, which will add our custom header `x-test`, setting it to the value `custom value`. + +Note that this function has its own special return type, and that `HeaderMap` is distinct from other types, like a `HashMap`. + +## `about.rs` + +Okay! We're past the hump, and now it's time to define the (much simpler) `/about` page. Create `src/templates/about.rs` and put the following inside: + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/templates/about.rs}} +``` + +This is basically exactly the same as `index.rs`, except we don't have any properties to deal with, and we don't need to generate anything special at build time (but Perseus will still render this page to static HTML, ready to be served to your users). + +## Running It + +`perseus serve` + +That's all. Every time you build a Perseus app, that's all you need to do. + +Once this is finished, your app will be live at <http://localhost:8080>! Note that if you don't like that, you can change the host/port with the `HOST`/`PORT` environment variables (e.g. you'd want to set the host to `0.0.0.0` if you want other people on your network to be able to access your site). + +Hop over to <http://localhost:8080> in any modern browser and you should see your greeting `Hello World!` above a link to the about page! if you click that link, you'll be taken to a page that just says `About.`, but notice how your browser seemingly never navigates to a new page (the tab doesn't show a loading icon)? That's Perseus' _app shell_ in action, which intercepts navigation to other pages and makes it occur seamlessly, only fetching the bare minimum to make the new page load. The same behavior will occur if you use your browser's forward/backward buttons. + +<details> +<summary>Why a 'modern browser'?</summary> + +### Browser Compatibility + +Perseus is compatible with any browser that supports Wasm, which is most modern browsers like Firefox and Chrome. However, legacy browsers like Internet Explorer will not work with any Perseus app, unless you _polyfill_ support for WebAssembly. + +</details> + +By the way, remember this little bit of code in `src/lib.rs`? + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/lib.rs:12:14}} +``` + +If you navigate to <http://localhost:8080/test.txt>, you should see the contents on `static/test.txt`! You can also access them at <http://localhost:8080/.perseus/static/test.txt> + +## Moving Forward + +Congratulations! You're now well on your way to building highly performant web apps in Rust! The remaining sections of this book are more reference-style, and won't guide you through building an app, but they'll focus instead on specific features of Perseus that can be used to make extremely powerful systems. + +So go forth, and build! diff --git a/docs/0.3.x/src/static-content.md b/docs/0.3.x/src/static-content.md new file mode 100644 index 0000000000..7f17a7d7b4 --- /dev/null +++ b/docs/0.3.x/src/static-content.md @@ -0,0 +1,21 @@ +# Static Content + +It's very routine in a web app to need to access *static content*, like images, and Perseus supports this out-of-the-box. Any and all static content for your website that should be served over the network should be put in a directory called `static/`, which should be at the root of your project (NOT under `src/`!). Any files/folders you put in there will be accessible on your website at `/.perseus/static/[filename-here]` **to anyone**. If you need content to be protected in some way, this is not the mechanism to use (consider a separate API endpoint)! + +## Aliasing Static Content + +One problem with making all static content available under `/.perseus/static/` is that there are sometimes occasions where you need it available at other locations. The most common example of this is `/favicon.ico` (the little logo that appears next to your app's title in a browser tab), which must be at that path. + +*Static aliases* allow you to handle these conditions with ease, as they let you define static content to be available at any given path, and to map to any given file in your project's directory. + +You can define static aliases in the `define_app!` macro's `static_aliases` parameter. Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/src/lib.rs): + +```rust,no_run,no_playground +{{#include ../../../examples/basic/src/lib.rs}} +``` + +### Security + +Of course, being able to serve any file on your system in a public-facing app is a major security vulnerability, so Perseus will only allow you to create aliases for paths in the current directory. Any absolute paths or paths that go outside the current directory will be disallowed. Note that these paths are defined relative to the root of your project. + +**WARNING:** if you accidentally violate this requirement, your app **will not load** at all! diff --git a/docs/0.3.x/src/stores.md b/docs/0.3.x/src/stores.md new file mode 100644 index 0000000000..a2420de6fa --- /dev/null +++ b/docs/0.3.x/src/stores.md @@ -0,0 +1,19 @@ +# Stores + +Perseus has a very unique system of managing data as far as frameworks go, because it sometimes needs to change files it generated at build-time. This would be fine on an old-school server where you control the filesystem, but many modern hosting providers have read-only filesystems, which makes working with Perseus problematic. + +As a solution to this, Perseus divides its data storage into two types: *mutable* (possibly changing at runtime) and *immutable* (never changing after build-time). These correspond to two types in Perseus: `ImmutableStore` (a `struct`) and `MutableStore` (a `trait`). + +## Immutable Stores + +An immutable store is used for all data that won't be changed after it's initially created, like for data about pages that are pre-rendered at build-time that don't revalidate. Because it's read-only after the build process, it can be used on a hosting provider with a read-only filesystem without problems, and so immutable stores always work on the filesystem. The only customizable part of them is the path they write to, which can be set with the `dist_path` parameter in the `define_app!` macro (by default it's `dist/`, relative to `.perseus/`). + +## Mutable Stores + +There are two classes of data that need to be modified at runtime in Perseus: data about pages that can revalidate, and pages cached after incremental generation. There are many ways to deploy a Perseus app, and some involve a read-only filesystem, in which case you'll likely want to use an external database or the like for mutable data. Perseus makes this as easy as possible by making `MutableStore` a `trait` with two simple methods: `read` and `write`. You can see more details in the [API docs](https://docs.rs/perseus). + +By default, Perseus will use `FsMutableStore`, an implementation of `MutableStore` that uses the filesystem at the given path, which is set to `.perseus/dist/mutable/` by default. On hosting providers where you can write to the filesystem and have your changes reliably persist, you can leave this as is. But, if you're using a provider like Netlify, which imposes the restriction of a read-only filesystem, you'll need to implement the `MutableStore` `trait` yourself for a database or the like. + +### Performance of External Mutable Stores + +There are significant downsides to using a non-filesystem mutable store in terms of performance, especially if that store is on another server. Remember that every request to an incrementally-generated page or a page that revalidates will use this external store, which means the request has to travel to the server, to the store, from the store, and from the server, twice as many trips as if the store was on the filesystem. For this reason, Perseus splits data storage into mutable and immutable stores, allowing you to incur these performance costs from the smallest amount of data possible. In previous versions, these stores were combined together, which was problematic for large-scale deployments. diff --git a/docs/0.3.x/src/strategies/amlagamation.md b/docs/0.3.x/src/strategies/amlagamation.md new file mode 100644 index 0000000000..4b051e933b --- /dev/null +++ b/docs/0.3.x/src/strategies/amlagamation.md @@ -0,0 +1,13 @@ +# State Amalgamation + +In the introduction to this section, we mentioned that all these rendering strategies are compatible with one another, though we didn't explain how the two strategies that generate unique properties for a template can possible be compatible. That is, how can you use _build state_ and _request state_ in the same template? To our knowledge, Perseus is the only framework in the world (in any language) that supports using both, and it's made possible by _state amalgamation_, which lets you provide an arbitrary function that can merge conflicting states from these two strategies! + +## Usage + +Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/amalgamation.rs): + +```rust,no_run,no-playground +{{#include ../../../../examples/showcase/src/templates/amalgamation.rs}} +``` + +This example illustrates a very simple amalgamation, taking the states of both strategies to produce a new state that combines the two. Note that this also uses `RenderFnWithCause` as a return type (see the section on the [_build state_](./build-state.md) strategy for more information). It will be passed an instance of `States`, which you can learn more about in the [API docs](https://docs.rs/perseus). diff --git a/docs/0.3.x/src/strategies/build-paths.md b/docs/0.3.x/src/strategies/build-paths.md new file mode 100644 index 0000000000..7e52da9ba8 --- /dev/null +++ b/docs/0.3.x/src/strategies/build-paths.md @@ -0,0 +1,17 @@ +# Build Paths + +As touched on in the documentation on the _build state_ strategy, Perseus can easily turn one template into many pages (e.g. one blog post template into many blog post pages) with the _build paths_ strategy, which is a function that returns a `Vec<String>` of paths to build. + +Note that it's often unwise to use this strategy to render all your blog posts or the like, but only render the top give most commonly accessed or the like, if any at all. This is relevant mostly when you have a large number of pages to be generated. The _incremental generation_ strategy is better suited for this, and it also allows you to never need to rebuild your site for new content (as long as the server can access the new content). + +Note that, like _build state_, this strategy may be invoked at build-time or while the server is running if you use the _revalidation_ strategy (_incremental generation_ doesn't affect _build paths_ though). + +## Usage + +Here's the same example as given in the previous section (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), which uses _build paths_ together with _build state_ and _incremental generation_: + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/post.rs}} +``` + +Note the return type of the `get_build_paths` function, which returns a `RenderFnResult<Vec<String>>`, which is just an alias for `Result<T, Box<dyn std::error::Error>>`, which means that you can return any error you want. If you need to explicitly `return Err(..)`, then you should use `.into()` to perform the conversion from your error type to this type automatically. Perseus will then format your errors nicely for you using [`fmterr`](https://github.com/arctic-hen7/fmterr). diff --git a/docs/0.3.x/src/strategies/build-state.md b/docs/0.3.x/src/strategies/build-state.md new file mode 100644 index 0000000000..aaf99cca6d --- /dev/null +++ b/docs/0.3.x/src/strategies/build-state.md @@ -0,0 +1,35 @@ +# Build State + +The most commonly-used rendering strategy for Perseus is static generation, which renders your pages to static HTML files. These can then be served by the server with almost no additional processing, which makes for an extremely fast experience! + +Note that, depending on other strategies used, Perseus may call this strategy at build-time or while the server is running, so you shouldn't depend on anything only present in a build environment (particularly if you're using the _incremental generation_ or _revalidation_ strategies). + +_Note: if you want to export your app to purely static files, see [this section](../exporting.md), which will help you use Perseus without any server._ + +## Usage + +### Without _Build Paths_ or _Incremental Generation_ + +On its own, this strategy will simply generate properties for your template to turn it into a page, which would be perfect for something like a list of blog posts (just fetch the list from the filesystem, a database, etc.). Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/index.rs) for a simple greeting: + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/index.rs}} +``` + +Note that Perseus passes around properties to pages as `String`s, so the function used for this strategy is expected to return a string. Note also the return type `RenderFnResultWithCause`, a Perseus type that represents the possibility of returning almost any kind of error, with an attached cause declaration that blames either the client or the server for the error. Most of the time, the server will be at fault (e.g. if serializing some obvious properties fails), and this is the default if you use `?` or `.into()` on another error type to run an automatic conversion. However, if you want to explicitly state a different cause (or provide a different HTTP status code), you can construct `GenericErrorWithCause`, as done in the below example (under the next subheading) if the path is `post/tests`. We set the error (a `Box<dyn std::error::Error>`) and then set the cause to be the client (they navigated to an illegal page) and tell the server to return a 404, which means our app will display something like _Page not found_. + +### With _Build Paths_ or _Incremental Generation_ + +You may have noticed in the above example that the build state function takes a `path` parameter. This becomes useful once you bring the _build paths_ or _incremental generation_ strategies into play, which allow you to render many paths for a single template. In the following example (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), all three strategies are used together to pre-render some blog posts at build-time, and allow the rest to be requested and rendered if they exist (here, any post will exist except one called `tests`): + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/post.rs}} +``` + +When either of these additional strategies are used, _build state_ will be passed the path of the page to be rendered, which allows it to prepare unique properties for that page. In the above example, it just turns the URL into a title and renders that. + +For further details on _build paths_ and _incremental generation_, see the following sections. + +## Common Pitfalls + +When a user goes to your app from another website, Perseus will send all the data they need down straight away (in the [initial loads](../advanced/initial-loads.md) system), which involves setting any state you provide in a JavaScript global variable so that the browser can access it without needing to talk to the server again (which would slow things down). Unfortunately, JavaScript's concept of 'raw strings' (in which you don't need to escape anything) is quite a bit looser than Rust's, and so Perseus internally escapes any instances of backticks or `${` (JS interpolation syntax). This should all work fine, but, when your state is deserialized, it's not considered acceptable for it to contain *control characters*. In other words, anything like `\n`, `\t` or the like that have special meanings in strings must be escaped before being sent through Perseus! Note that this is a requirement imposed by the lower-level module [`serde_json`](https://github.com/serde-rs/json), not Perseus itself. diff --git a/docs/0.3.x/src/strategies/incremental.md b/docs/0.3.x/src/strategies/incremental.md new file mode 100644 index 0000000000..cbc065cf71 --- /dev/null +++ b/docs/0.3.x/src/strategies/incremental.md @@ -0,0 +1,19 @@ +# Incremental Generation + +Arguable the most powerful strategy in Perseus is *incremental generation*, which is an extension of *build paths* such that any path in the template's root path domain (more info on that concept [here](../templates/intro.md)) will result in calling the *build state* strategy while the server is running. + +A perfect example of this would be an retail site with thousands of products, all using the `product` template. If we built all these with *build paths*, and they all require fetching information from a database, builds could take a very long time. Instead, it's far more efficient to use *incremental generation*, which will allow any path under `/product` to call the *build state* strategy, which you can then use to render the product when it's first requested. This is on-demand building. But how is this different from the *request state* strategy? It caches the pages after they've been built the first time, meaning **you build once on-demand, and then it's static generation from there**. In other words, this strategy provides support for rendering thousands, millions, or even billions of pages from a single template while maintaining static generation times of less than a second! + +Also, this strategy is fully compatible with *build paths*, meaning you could pre-render you most common pages at build-time, and have the rest built on-demand and then cached. + +## Usage + +This is the simplest strategy in Perseus to enable, needing only one line of code. Here's the example from earlier (which you can find [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)) that uses *incremental generation* together with *build paths* (and of course *build state*, which is mandatory for *incremental generation* to work): + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/post.rs}} +``` + +All we need to do is run `.incremental_generation()` on the `Template`, and it's ready. + +Note that this example throws a *404 Not Found* error if we go to `/post/tests`, which is considered an illegal URL. This is a demonstration of preventing certain pages from working with this strategy, and such filtering should be done in the *build state* strategy. diff --git a/docs/0.3.x/src/strategies/intro.md b/docs/0.3.x/src/strategies/intro.md new file mode 100644 index 0000000000..e45fdf0cb9 --- /dev/null +++ b/docs/0.3.x/src/strategies/intro.md @@ -0,0 +1,3 @@ +# Rendering Strategies + +This section describes Perseus' rendering strategies, which differentiate it from every other framework in the world right now. Note that all the strategies detailed here can be used together, and the [showcase example](https://github.com/arctic-hen7/perseus/tree/main/examples/showcase) is the best example of seeing how each one can be used. diff --git a/docs/0.3.x/src/strategies/request-state.md b/docs/0.3.x/src/strategies/request-state.md new file mode 100644 index 0000000000..13aa5c7e2f --- /dev/null +++ b/docs/0.3.x/src/strategies/request-state.md @@ -0,0 +1,26 @@ +# Request State + +While build-time strategies fulfill many use-cases, there are also scenarios in which you may need access to information only available at request-time, like an authentication key that the client sends over HTTP as a cookie. For these cases, Perseus supports the _request state_ strategy, which is akin to traditional server-side rendering, whereby you render the page when a client requests it. + +If you can avoid this strategy, do, because it will bring your app's TTFB (time to first byte) down, remember that anything done in this strategy is done on the server while the client is waiting for a page. + +## Usage + +Here's an example taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/ip.rs) of using this strategy to tell the user their own IP address (albeit not hugely reliably as this header can be trivially spoofed, but this is for demonstration purposes): + +```rust,no-run,no_playground +{{#include ../../../../examples/showcase/src/templates/ip.rs}} +``` + +Note that, just like _build state_, this strategy generates stringified properties that will be passed to the page to render it, and it also uses `RenderFnWithCause` (see the section on [build state](./build-state.md) for more information). The key difference though is that this strategy receives a second, very powerful parameter: the HTTP request that the user sent (`perseus::Request`). + +<details> +<summary>How do you get the user's request information?</summary> + +[Actix Web](https://actix.rs) (and any other framework worth its salt) automatically passes this information to handlers like Perseus. The slightly difficult thing is then converting this from Actix's custom format to Perseus' (which is just an alias for the [`http`](https://docs.rs/http) module's). This is done in the [`perseus-actix-web`](https://docs.rs/perseus-actix-web) crate. + +</details> + +That parameter is actually just an alias for [this](https://docs.rs/http/0.2/http/request/struct.Request.html), which gives you access to all manner of things in the user's HTTP request. The main one we're concerned with in this example though is `X-Forwarded-For`, which contains the user's IP address (unless it's trivially spoofed). Because we can't assume that any HTTP header exists, we fall back to a message saying the IP address is hidden if we can't access the header. + +The other notable thing in the above example is the commented-out line at the beginning of `get_request_state`, which shows you how to return an error if the client didn't provide something that they should've. diff --git a/docs/0.3.x/src/strategies/revalidation.md b/docs/0.3.x/src/strategies/revalidation.md new file mode 100644 index 0000000000..80251644b3 --- /dev/null +++ b/docs/0.3.x/src/strategies/revalidation.md @@ -0,0 +1,39 @@ +# Revalidation + +While the *build state* and *build paths* strategies are excellent for generating pages efficiently, they can't be updated for new content. For example, using these strategies alone, you'd need to rebuild a blog every time you added a new post, even if those posts were stored in a database. With *revalidation*, you can avoid this by instructing Perseus to rebuild a template if certain criteria are met when it's requested. + +There are two types of revalidation: time-based and logic-based. The former lets you re-render a template every 24 hours or the like, while the latter allows you to re-render a template if an arbitrary function returns `true`. + +## Time-Based Revalidation Usage + +Here's an example of time-based revalidation from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/time.rs) (note that this uses *incremental generation* as well): + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/time.rs}} +``` + +This page displays the time at which it was built (fetched with *build state*), but rebuilds every five seconds. Note that this doesn't translate to the server's actually rebuilding it every five seconds, but rather the server will rebuild it at the next request if more than five seconds have passed since it was last built (meaning templates on the same build schedule will likely go our of sync quickly). + +### Time Syntax + +Perseus uses a very simple syntax inspired by [this JavaScript project]() to specify time intervals in the form `xXyYzZ` (e.g. `1w`, `5s`, `1w5s`), where the lower-case letters are number and the upper-case letters are intervals, the supported of which are listed below: + +- `s`: second, +- `m`: minute, +- `h`: hour, +- `d`: day, +- `w`: week, +- `M`: month (30 days used here, 12M ≠ 1y!), +- `y`: year (365 days always, leap years ignored, if you want them add them as days) + +## Logic-Based Revalidation Usage + +Here's an example of logic-based revalidation from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/time_root.rs) (actually, this example uses both types of revalidation): + +```rust,no_run,no_playground +{{#include ../../../../examples/showcase/src/templates/time_root.rs}} +``` + +If it were just `.should_revalidate_fn()` being called here, this page would always be rebuilt every time it's requested (the closure always returns `true`, note that errors would be `String`s), however, the additional usage of time-based revalidation regulates this, and the page will only be rebuilt every five seconds. In short, your arbitrary revalidation logic will only be executed at the intervals of your time-based revalidation intervals (if none are set, it will run on every request). + +Note that you should avoid lengthy operations in revalidation if at all possible, as, like the *request state* strategy, this logic will be executed while the client is waiting for their page to load. diff --git a/docs/0.3.x/src/styling.md b/docs/0.3.x/src/styling.md new file mode 100644 index 0000000000..a0c4b2c176 --- /dev/null +++ b/docs/0.3.x/src/styling.md @@ -0,0 +1,15 @@ +# Styling + +Perseus aims to make styling as easy as possible, though there are a number of things that you should definitely know about before you start to style a Perseus app! + +It's very easy to import stylesheets with Perseus (be they your own, something like [TailwindCSS](https://tailwindcss.com), etc.). You just add them to the `static/` directory at the root of your project, and then they'll be available at `/.perseus/static/your-filename-here`. That's described in more detail in [this section](./static-content.md). + +## Full-Page Layouts + +If you've tried to create something like a stick footer, you've probably become extremely frustrated by Perseus, which puts all your content in a container `<div>` (in addition to the `<div id="root"></div>`). Unfortunately, this is necessary until Sycamore supports creating a template for an existing DOM node, and this does lead to some styling problems. + +Notably, there are actually two of these `<div>`s at the moment: one for the content that the server pre-renders in [initial loads](./advanced/initial-loads.md) and another for when that content is hydrated by Perseus' client-side logic. That means that, if you only style one of these, you'll get a horrible flash of unstyled content, which nobody wants. To make this as easy as possible, Perseus provides a class `__perseus_content` that applies to both of these `<div>`s. Also, note that the `<div>` for the initial content will become `display: none;` as soon as the page is ready, which means you won't get it interfering with your layouts. + +Knowing this, the main changes you'll need to make to any full-page layout code is to apply the styles to `.__perseus_content` instead of `body` or `#root`. As with CSS generally, if you expect `.__perseus_content` to take up the whole page, you'll need to make all its parents (`#root`, `body`, `html`) also take up the whole page (you can do this by setting `height: 100vh;` on `body`). + +Any other issues should be solvable by inspecting the DOM with your browser's DevTools, but you're more than welcome to ask for help on the [Sycamore Discord server](https://discord.gg/PgwPn7dKEk), where Perseus has its own channel! diff --git a/docs/0.3.x/src/templates/intro.md b/docs/0.3.x/src/templates/intro.md new file mode 100644 index 0000000000..4eec4aad8c --- /dev/null +++ b/docs/0.3.x/src/templates/intro.md @@ -0,0 +1,61 @@ +# Templates + +At the core of Perseus is its template system, which is how you'll define every page you'll ever build! However, it's important to understand a few of the nuances of this system so that you can build the best apps possible. + +## Templates vs Pages + +In Perseus, the idea of a _template_ is very different to the idea of a _page_. + +A _page_ corresponds to a URL in your app, like the about page, the landing page, or an individual blog post. + +A _template_ can generate _many_ pages or only one by using _rendering strategies_. + +The best way to illustrate this is with the example of a simple blog, with each page stored in something like a CMS (content management system). This app would only need two templates (in addition to a landing page, an about page, etc.): `blog` and `post`. For simplicity, we'll put the list of all blog posts in `blog`, and then each post will have its own URL under `post`. + +The `blog` template will be rendered to `/blog`, and will only use the _build state_ strategy, fetching a list of all our posts from the CMS every time the blog is rebuilt (or you could use revalidation and incremental generation to mean you never have to rebuild at all, but that's beyond the scope of this section). This template only generates one page, providing it the properties of the list of blog posts. So, in this case, the `blog` template has generated the `/blog` page. + +The `post` template is more complex, and it will generate _many_ pages, one for each blog post. This would probably use the _build paths_ strategy, which lets you fetch a list of blog posts from the CMS at build-time and invoke _build state_ for each of them, which would then get their content, metadata, etc. Thus, the `post` template generates many pages. + +Hopefully that explains the difference between a template and a post. This is a somewhat unintuitive part of Perseus, but it should be clear in the documentation what the difference is. Note however that old versions of the examples in the repository used these terms interchangeably, when they used to be the same. If you see any remaining ambiguity in the docs, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose)! + +## Defining a Template + +You can define a template like so (taken from [the basic example](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/src/templates/about.rs)'s about page): + +```rust,no_run,no_playground +{{#include ../../../../examples/basic/src/templates/about.rs}} +``` + +It's seen as convention in Perseus to define each template in its own file, which should expose a `get_template()` file. Note that this is just convention, and as long as you get an instance of `Template<G>` to the `define_app!` macro, it really doesn't matter. That said, using community conventions makes your code easier to understand and debug for others. + +There's a list of all the methods available on a template [here](https://docs.rs/perseus/0.2/perseus/template/struct.Template.html#implementations), along with explanations of what they all do. Technically, you could just define a template without calling any of these, but that would just render a blank page, which would probably be useless. + +## Routing + +Perseus' routing system is basically invisible, there's no router that you need to work with, nor any place for you to define explicit routes. Instead, Perseus automatically infers the routes for all your templates and the pages they generate from their names! + +The general rule is this: a template called `X` will be rendered at `/X`. Simple. What's more difficult to understand is what we call _template path domains_, which is the system that makes route inference possible. **A template can only ever generate pages within the scope of its root path.** Its root path is its name. In the example of a template called `X`, it can render `/X/Y`, `/X/Y/Z`, etc., but it can _never_ render `/A`. + +To generate paths within a template's domain, you can use the _build paths_ and _incremental generation_ strategies (more on those later). Both of these support dynamic parameters (which might be denoted in other languages as `/post/<title>/info` or the like). + +### Dynamic Parameters Above the Domain + +One niche case is defining a route like this: `/<locale>/about`. In this case, the `about` template is rendered underneath a dynamic parameter. This is currently impossible in Perseus, but the most common reason to need it, internationalization (making your app work in many language), is support out-of-the-box with Perseus. + +### Different Templates in the Same Domain + +It's perfectly possible in Perseus to define one template for `/post` (and its children) and a different one for `/post/new`. In fact, this is exactly what [the showcase example](https://github.com/arctic-hen7/perseus/tree/main/examples/showcase) does, and you can check it out for inspiration. This is based on a simple idea: **more specific templates win** the routing contest. + +There is one use-case though that requires a bit more fiddling: having a different template for the root path. A very common use-case for this would be having one template for `/posts`'s children (one URl for each blog post) and a different template for `/posts` itself that lists all available posts. Currently, the only way to do this is to define a property on the `posts` template that will be `true` if you're rendering for that root, and then to conditionally render the list of posts. Otherwise, you would render the given post content. This does require a lot of `Option<T>`s, but they could be safely unwrapped (data passing in Perseus is logical and safe). + +## Checking Render Context + +It's often necessary to make sure you're only running some logic on the client-side, particularly anything to do with `web_sys`, which will `panic!` if used on the server. Because Perseus renders your templates in both environments, you'll need to explicitly check if you want to do something only on the client (like get an authentication token from a cookie). This can be done trivially with the `is_server!` macro, which does exactly what it says on the tin. Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/i18n/src/templates/about.rs): + +```rust,no_run,no_playground +{{#include ../../../../examples/i18n/src/templates/about.rs}} +``` + +This is a very contrived example, but what you should note if you try this is the flash from `server` to `client` (when you go to the page from the URL bar, not when you go in from the link on the index page), because the page is pre-rendered on the server and then hydrated on the client. This is an important principle of Perseus, and you should be aware of this potential flashing (easily solved by a less contrived example) when your users [initially load](../advanced/initial-loads.md) a page. + +One important thing to note with this macro is that it will only work in a _reactive scope_ because it uses Sycamore's [context system](https://sycamore-rs.netlify.app/docs/advanced/contexts). In other words, you can only use it inside a `template!`, `create_effect`, or the like. diff --git a/docs/0.3.x/src/templates/metadata-modification.md b/docs/0.3.x/src/templates/metadata-modification.md new file mode 100644 index 0000000000..ec127fe6ca --- /dev/null +++ b/docs/0.3.x/src/templates/metadata-modification.md @@ -0,0 +1,13 @@ +# Modifying the `<head>` + +A big issue with only having one `index.html` file for your whole app is that you don't have the ability to define different `<title>`s and HTML metadata for each page. + +However, Perseus overcomes this easily by allowing you to specify `.head()` on a `Template<G>`, which should return a closure that returns a `Template<SsrNode>` (but just write `perseus::template::HeadFn` as the return type, it's an alias for that). The `template!` you define here will be rendered to a `String` and directly interpolated into the `<head>` of any pages this template renders. If you need it to be different based on the properties, you're covered, it takes the same properties as the normal template function! + +The only particular thing to note here is that, because this is rendered to a `String`, this **can't be reactive**. Variable interpolation is fine, but after it's been rendered once, the `<head>` **will not change**. If you need to update it later, you should do that with [`web_sys`](https://docs.rs/web-sys), which lets you directly access any DOM element with similar syntax to JavaScript (in fact, it's your one-stop shop for all things interfacing with the browser, as well as it's companion [`js-sys`](https://docs.rs/js-sys)). + +Here's an example of modifying a page's metadata (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/src/templates/index.rs)): + +```rust,no_run,no_playground +{{#rustdoc_include ../../../../examples/basic/src/templates/index.rs:32:36}} +``` diff --git a/docs/0.3.x/src/templates/setting-headers.md b/docs/0.3.x/src/templates/setting-headers.md new file mode 100644 index 0000000000..90db15d028 --- /dev/null +++ b/docs/0.3.x/src/templates/setting-headers.md @@ -0,0 +1,11 @@ +# Modifying HTTP Headers + +Most of the time, you shouldn't need to touch the HTTP headers of your Perseus templates, but sometimes you will need to. A particular example of this is if you want your users' browsers to only cache a page for a certain amount of time (the default for Perseus if five minutes), then you'd need to set the [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header. + +Perseus supports inserting arbitrary HTTP headers for any response from the server that successfully returns a page generated from the template those headers are defined for. You can do this like so (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/src/templates/index.rs)): + +```rust,no_run,no_playground +{{#include ../../../../examples/basic/src/templates/index.rs}} +``` + +Of note here is the `set_headers_fn` function, which returns a `HeaderMap`. This is then used on the template with `.set_headers_fn()`. Note that the function you provide will be given the state as an argument (ignored here), and you must return some headers (you can't return an error). diff --git a/docs/0.3.x/src/testing/checkpoints.md b/docs/0.3.x/src/testing/checkpoints.md new file mode 100644 index 0000000000..dd5405482e --- /dev/null +++ b/docs/0.3.x/src/testing/checkpoints.md @@ -0,0 +1,48 @@ +# Checkpoints + +If you start using Perseus' testing system now, you'll likely hit a snag very quickly, involving errors to do with *stale DOM elements*. This is an unfortunate side-effect of the way Perseus currently handles initial loads (we move a number of DOM elements around after they've been sent down from the server), which results in the WebDriver thinking half the page has just disappeared out from under it! + +This, and many similar problems, are easily solvable using one of Perseus' most powerful testing tools: *checkpoints*. When you run your app with `perseus test`, a system is enabled in the background that writes a new DOM element to a hidden list of them when any app code calls `checkpoint()`. This can then be detected with Fantoccini! Admittedly, a far nicer solution would be DOM events, but the WebDriver protocol doesn't yet support listening for them (understandable since it's mimicking a user's interaction with the browser). + +Note that checkpoints will never be reached if your app is not run with `perseus test`. If you use `--no-run` and then execute the server binary manually, be sure to provide the `PERSEUS_TESTING=true` environment variable. + +You can wait for a Perseus checkpoint to be reached like so (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/tests/main.rs)): + +```rust,no_run,no_playground +{{#include ../../../../examples/basic/tests/main.rs}} +``` + +Note in particular the use of the `wait_for_checkpoint!` macro, which accepts three arguments: + +- Name of the checkpoint +- Version of the checkpoint +- Fantoccini client + +For want of a better term, that second argument refers to how Perseus manages checkpoints. Because a single checkpoint might be emitted multiple times, Perseus attaches a number to the end of each. The final element `id` looks like this: `__perseus_checkpoint-<checkpoint_name>-<number>`, where `<number>` starts from 0 and increments. + +*Note: checkpoints are not cleared until the page is fully reloaded, so clicking a link to another page will not clear them!* + +## Custom Checkpoints + +In addition to Perseus' internal checkpoints (listed below), you can also use your own checkpoints, though they must follow the following criteria: + +- Must not include hyphens (used as a delimiter character), use underscores instead +- Must not conflict with an internal Perseus checkpoint name + +The best way to uphold the latter of those criteria is to prefix your own checkpoints with something like the name of your app, or even just `custom_`. Of course, if your app has a name like `router`, then that will be a problem (many Perseus checkpoints begin with `router_`), but Perseus will never generate checkpoints internally that begin with `custom_`. + +Note that it's not enough to make sure that your checkpoints don't clash with any existing checkpoints, as new checkpoints may be added in any new release of Perseus, so conflicts may arise with the tiniest of updates! + +## Internal Checkpoints + +Perseus has a number of internal checkpoints that are listed below. Note that this list will increase over time, and potentially in patch releases. + +- `begin` -- when the Perseus system has been initialized +- `router_entry` -- when the Perseus router has reached a verdict and is about to either render a new page, detect the user's locale and redirect, or show an error page +- `not_found` -- when the page wasn't found +- `app_shell_entry` -- when the page was found and it's being rendered +- `initial_state_present` -- when the page has been rendered for the first time, and the server has preloaded everything (see [here](../advanced/initial-loads.md) for details) +- `page_visible` -- when the user is able to see page content (but the page isn't interactive yet) +- `page_interactive` -- when the page has been hydrated, and is now interactive +- `initial_state_not_present` -- when the initial state is not present, and the app shell will need to fetch page data from the server +- `initial_state_error` -- when initial state showed an error diff --git a/docs/0.3.x/src/testing/fantoccini-basics.md b/docs/0.3.x/src/testing/fantoccini-basics.md new file mode 100644 index 0000000000..ad4669115c --- /dev/null +++ b/docs/0.3.x/src/testing/fantoccini-basics.md @@ -0,0 +1,27 @@ +# Fantoccini Basics + +Now that you know a bit more about how Perseus tests work, it's time to go through how to write them! + +Remember, you're controlling an actual browser, so you basically have everything available to you that a user can do (mostly). You can even take screenshots! All this is achieved with [Fantoccini](https://github.com/jonhoo/fantoccini), which you can learn more about [here](https://docs.rs/fantoccini). For now though, here's a quick tutorial on the basics, using [this](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/tests/main.rs) example: + +```rust,no_run,no_playground +{{#include ../../../../examples/basic/tests/main.rs}} +``` + +## Going to a Page + +You can trivially go to a page of your app by running `c.goto("...")`. The above example ensures that the URL is valid, but you shouldn't have to do this unless you're testing a page that automatically redirects the user. Also, if you're using [i18n](../i18n/intro.md), don't worry about testing automatic locale redirection, we've already done that for you! + +Once you've arrived at a page, you should wait for the `router_entry` (this example uses `begin` because it tests internal parts of Perseus) checkpoint, which will be reached when Perseus has decided what to do with your app. If you're testing particular page logic, you should wait instead for `page_visible`, which will be reached when the user could see content on your page, and then for `page_interactive`, which will be reached when the page is completely ready. Remember though, you only need to wait for the checkpoints that you actually use (e.g. you don't need to wait for `page_visible` and `page_interactive` if you're not doing anything in between). + +## Finding an Element + +You can find an element easily by using Fantoccini's `Locator` `enum`. This has two options, `Id` or `Css`. The former will find an element by its HTML `id`, and the latter will use a CSS selector ([here](https://www.w3schools.com/cssref/css_selectors.asp)'s a list of them). In the above example, we've used `Locator::Css("p")` to get all paragraph elements, and then we've plugged that into `c.find()` to get the first one. Then, we can get its `innerText` with `.text()` and assert that is what we want it to be. + +### Caveats + +As you may have noticed above, asserting on the contents of a `<title>` is extremely unintuitive, as it requires using `.html(false)` (meaning include the element tag itself) and asserting against that. For some reason, neither `.html(true)` nor `.text()` return anything. There's a tracking issue for this [here](https://github.com/jonhoo/fantoccini/issues/136). + +## Miscellaneous + +For full documentation of how Fantoccini works, see its API documentation [here](https://docs.rs/fantoccini). diff --git a/docs/0.3.x/src/testing/intro.md b/docs/0.3.x/src/testing/intro.md new file mode 100644 index 0000000000..a8314665a9 --- /dev/null +++ b/docs/0.3.x/src/testing/intro.md @@ -0,0 +1,62 @@ +# Testing + +When building a web app, testing is extremely important, and also extremely helpful. If you're familiar with Rust, you're probably used to having two types of tests (unit tests and integration tests), but Perseus follows the JavaScript model of testing slightly more, which is better suited to a user-facing web app, and has three types of tests: + +- Unit tests -- same as in Rust, they test a small amount of logic in isolation +- Integration tests -- same as in Rust, they test the system itself, but sometimes mocking things like a database +- End-to-end tests -- not mocking anything at all, and fully testing the entire system as if a real user were operating it + +It's that last type that Perseus is particularly concerned with, because that's the way that you can create highly resilient web apps that are tested for real user interaction. In fact, most of Perseus itself is tested this way! Also, E2E tests are more effective at automating otherwise manual testing of going through a browser and checking that things work, and they're far less brittle than any other type of test (all that matters is the final user experience). + +In terms of unit tests, these can be done for normal logic (that doesn't render something) with Rust's own testing system. Any integration tests, as well as unit tests that do render things, should be done with [`wasm-bindgen-test`](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/index.html). This module provides a custom *test harness* macro (alternative to `#[test]`) that spins up a *headless browser* (browser without a GUI) that can be used to render your code. Note that this should be done for testing Sycamore components, and not for testing integrated Perseus systems. + +When you want to test logic flows in your app, like the possibilities of how a user will interact with a login form, the best way is to use end-to-end testing, which Perseus supports with a custom test harness macro that can be used like so (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/basic/tests/main.rs)): + +```rust,no_run,no_playground +{{#include ../../../../examples/basic/tests/main.rs}} +``` + +The first thing to note is the module that this test imports. It's called [Fantoccini](https://github.com/jonhoo/fantoccini), and it basically lets you control a web browser with code. We'll get to exactly how this works soon. This test goes to <http://localhost:8080> (where a Perseus app is hosted) and then clicks a link on it and makes sure that it's been taken to the correct new URL. + +The other important thing to note is the signature of this test function. First, it's annotated with `#[perseus::test]`, which means this will expand into a larger function that makes your function work. It takes a Fantoccini client as a parameter (which we've called `c` for convenience, you'll use it a lot), and returns a result. **In Perseus E2E tests, you shouldn't panic, but return an error gracefully instead**, which gives the harness time to disconnect from the headless browser. If you don't do this, you'll leave the browser in limbo, and other connections will fail, and everything will blow up in your face. Note that `assert!`, `assert_eq!`, and `assert_ne!` do `panic!` if they fail, which will cause the browser to be left in limbo. + +## Writing a Test + +You can write your own tests by creating files of whatever names you'd like under `test/` in your project's root directory (as you would with traditional Rust integration tests), and then you can write tests like the above example. Don't worry if you stuff up the arguments or the return type slightly, Perseus will let you know. Also note that **test functions must be asynchronous**. + +You'll also need to add the following to your `Cargo.toml` (`tokio` is needed for running your tests asynchronously): + +```toml +{{#include ../../../../examples/basic/Cargo.toml:14:16}} +``` + +## Running Tests + +Perseus tests can be run with `cargo test` as usual, but you'll need to provide the `PERSEUS_RUN_WASM_TESTS` environment variable as true. This makes sure that you don't accidentally run tests that have external dependencies (like a headless browser). Note that, by default, your tests will run in a full browser, so you'll get GUI windows opening on your screen that are controlled by your tests. These can be extremely useful for debugging, but they're hardly helpful on CI, so you can remove them and run *headlessly* (without a GUI window) by providing the `PERSEUS_RUN_WASM_TESTS_HEADLESS` environment variable. + +Before running E2E tests, you need to have two things running in the background: + +- Something that allows you to interact with a headless browser using the *WebDriver* protocol (see below) +- Your app, invoked with `perseus test` (different to `perseus serve`) + +<details> +<summary>How would I automate all that?</summary> + +It may be most convenient to create a shell script to do these for you, or to use a tool like [Bonnie](https://github.com/arctic-hen7/bonnie) to automate the process. You can see an example of how this is done for a large number of tests across multiple different example apps in the [Perseus repository](https://github.com/arctic-hen7/perseus). + +</details> + +*Note: Cargo runs your tests in parallel by default, which won't work with some WebDrivers, like Firefox's `geckodriver`. To run your tests sequentially instead (slower), use `cargo test -- --test-threads 1` (this won't keep your tests in the same order though, but that's generally unnecessary).* + +## WebDrivers? + +So far, we've mostly gone through this without explaining the details of a headless browser, which will be necessary to have some basic understanding of. Your web browser is composed a huge number of complex moving parts, and these are perfect for running end-to-end tests. They have rendering engines, Wasm execution environments, etc. Modern browsers support certain protocols that allow them to be controlled by code, and this can be done through a server like [Selenium](https://selenium.dev). In the case of Perseus though, we don't need something quite so fancy, and a simple system like [`geckodriver`](https://github.com/mozilla/geckodriver) for Firefox or [`chromedriver`](https://chromedriver.chromium.org/) for Chromium/Chrome will do fine. + +If you're completely new to headless browsers, here's a quick how-to guide with Firefox so we're all on the same page (there are similar steps for Google Chrome as well): + +1. Install [Firefox](https://firefox.com). +2. Install [`geckodriver`](https://github.com/mozilla/geckodriver). On Ubuntu, this can be done with `sudo apt install firefox-geckodriver`. +3. Run `geckodriver` in a terminal window on its own and run your Perseus tests elsewhere. +4. Press Ctrl+C in the `geckodriver` terminal when you're done. + +*Note: if your WebDriver instance is running somewhere other than <http://localhost:4444>, you can specify that with `#[perseus::test(webdriver_url = "custom-url-here")]`.* diff --git a/docs/0.3.x/src/testing/manual.md b/docs/0.3.x/src/testing/manual.md new file mode 100644 index 0000000000..f696b32f68 --- /dev/null +++ b/docs/0.3.x/src/testing/manual.md @@ -0,0 +1,38 @@ +# Manual Testing + +Occasionally, the Perseus testing harness may be a little too brittle for your needs, particularly if you'd like to pass custom arguments to the WebDriver (e.g. to spoof media streams). In these cases, you'll want to break out of it entirely and work with Fantoccini manually. + +You should do this by wrapping your normal test function in another function and annotating that with `[tokio::test]`, which will mark it as a normal asynchronous test. Then, you can mimic the behavior of the Perseus test harness almost exactly with the following code (adapted from the macro [here](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus-macro/src/test.rs)): + +```rust,no_run,no_playground +// Only run the test if the environment variable is specified (avoids having to do exclusions for workspace `cargo test`) +if ::std::env::var("PERSEUS_RUN_WASM_TESTS").is_ok() { + let headless = ::std::env::var("PERSEUS_RUN_WASM_TESTS_HEADLESS").is_ok(); + // Set the capabilities of the client + let mut capabilities = ::serde_json::Map::new(); + let firefox_opts; + let chrome_opts; + if headless { + firefox_opts = ::serde_json::json!({ "args": ["--headless"] }); + chrome_opts = ::serde_json::json!({ "args": ["--headless"] }); + } else { + firefox_opts = ::serde_json::json!({ "args": [] }); + chrome_opts = ::serde_json::json!({ "args": [] }); + } + capabilities.insert("moz:firefoxOptions".to_string(), firefox_opts); + capabilities.insert("goog:chromeOptions".to_string(), chrome_opts); + + let mut client = ::fantoccini::ClientBuilder::native() + .capabilities(capabilities) + .connect(&"http://localhost:4444").await.expect("failed to connect to WebDriver"); + let output = fn_internal(&mut client).await; + // Close the client no matter what + client.close().await.expect("failed to close Fantoccini client"); + // Panic if the test failed + if let Err(err) = output { + panic!("test failed: '{}'", err.to_string()) + } +} +``` + +Then, you can relatively easily modify the properties sent to the WebDriver instance with `firefox_opts` and `chrome_opts`. You can see the documentation for their options [here](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions) (Firefox) and [here](https://sites.google.com/a/chromium.org/chromedriver/capabilities) (Chrome/Chromium). diff --git a/docs/0.3.x/src/updating.md b/docs/0.3.x/src/updating.md new file mode 100644 index 0000000000..86344dfa6f --- /dev/null +++ b/docs/0.3.x/src/updating.md @@ -0,0 +1,23 @@ +# Migrating from v0.2.x + +Perseus v0.3.0 added significant architectural changes to Perseus under the hood, and also made it easier to use for you! Additionally, this update provides inbuilt deployment functionality for moving Perseus to production (though that's not recommended yet)! If you're currently running v0.2.x, here's how to upgrade! + +1. Update your `Cargo.toml` dependencies for `perseus` to `0.3`. +2. Upgrade the Perseus CLI with `cargo install perseus-cli`. +3. Run `perseus clean` to remove the old `.perseus/` directory. +4. Remove any custom config managers you may have, they've been replaced by [mutable and immutable stores](./stores.md). +5. Update your code for the remaining breaking changes listed in [the CHANGELOG](https://github.com/arctic-hen7/perseus/blob/main/CHANGELOG.md). + +Perseus v0.3.0 also changed a few common idioms, like breaking out the `.template()` call into a separate function `template_fn()`. This is no longer recommended, though it will still work fine. You can check out the [examples directory](https://github.com/arctic-hen7/perseus/tree/main/examples) to see how things are a bit nicer now in terms of formatting. + +## If You've Ejected + +If you were running Perseus v0.2.x and had ejected, here are the steps you should take in addition to the above. + +1. Rename `.perseus/` to `.perseus.old/`. +2. Run `perseus build` to create a new `.perseus/` directory. +3. Apply your changes to the new directory. +4. Delete `.perseus.old/` when you're done. +5. Confirm everything works with `perseus serve`. + +This may seem arduous, but v0.3.0 includes notable updates to the innards in `.perseus/`, and it's typically simpler to re-apply your own changes to the new system than it is to apply Perseus' updates to your existing directory (though you could do this if you really wanted). diff --git a/docs/0.3.x/src/views.md b/docs/0.3.x/src/views.md new file mode 100644 index 0000000000..37afe1d1fc --- /dev/null +++ b/docs/0.3.x/src/views.md @@ -0,0 +1,11 @@ +# Writing Views + +Perseus is fundamentally a high-level framework around [Sycamore](https://github.com/sycamore-rs/sycamore), which provides all the underlying reactivity and the ability to write code that turns into visible HTML elements. + +It would be foolish to reproduce here all the fantastic work of Sycamore, and you can read [their docs](https://sycamore-rs.netlify.app/docs/v0.6/getting_started/installation) to understand how reactivity, variable interpolation, and all the rest of their amazing systems work. + +Note that Perseus makes some sections of Sycamore's docs irrelevant (namely the sections on routing and SSR), as they're managed internally. Note that if you want to use Perseus without the CLI (*very* brave), these sections will be extremely relevant. + +## Using Sycamore without Perseus + +If you want to create a pure SPA without all the overhead of Perseus, you may want to use Sycamore without Perseus. Note that this won't provide as good SEO (search engine optimization), and you'll miss out on a number of additional features (like i18n, inferred routing, rendering strategies, and pre-optimized static exporting without a server), but for applications where these are unnecessary, Sycamore is perfect on its own. diff --git a/docs/0.3.x/src/what-is-perseus.md b/docs/0.3.x/src/what-is-perseus.md new file mode 100644 index 0000000000..7bbfaf3cfa --- /dev/null +++ b/docs/0.3.x/src/what-is-perseus.md @@ -0,0 +1,85 @@ +# What is Perseus? + +If you're familiar with [NextJS](https://nextjs.org), Perseus is that for Wasm. If you're familiar with [SvelteKit](https://kit.svelte.dev), it's that for [Sycamore](https://github.com/sycamore-rs/sycamore). + +If none of that makes any sense, this is the section for you! If you're not in the mood for a lecture, [here's a TL;DR](#summary)! + +### Rust web development + +[Rust](https://www.rust-lang.org/) is an extremely powerful programming language, but I'll leave the introduction of it [to its developers](https://www.rust-lang.org/). + +[WebAssembly](https://webassembly.org) (abbreviated Wasm) is like a low-level programming language for your browser. This is revolutionary, because it allows websites and web apps to be built in programming languages other than JavaScript. Also, it's [really fast](https://medium.com/@torch2424/webassembly-is-fast-a-real-world-benchmark-of-webassembly-vs-es6-d85a23f8e193) (usually >30% faster than JS). + +But developing directly for the web with Rust using something like [`web-sys`](https://docs.rs/web-sys) isn't a great experience, it's generally agreed in the web development community that developer experience and productivity are vastly improved by having a *reactive* framework. Let's approach this from a traditional JavaScript and HTML perspective first. + +Imagine you want to create a simple counter. Here's how you might do it in a non-reactive framework (again, JS and HTML here, no Rust yet): + +```html +<p id="counter">0</p><br/> +<button onclick="document.getElementById('counter').innerHTML = parseInt(document.getElementById('counter').innerHTML) + 1">Increment</button> +``` + +If you're unfamiliar with HTML and JS, don't worry. All this does is create a paragraph with a number inside and then increment it. But the problem is clear in terms of expression: why can't we just put a variable in the paragraph and have that re-render when we increment that variable? Well, that's reactivity! + +In JS, there are frameworks like [Svelte](https://svelte.dev) and [ReactJS](https://reactjs.org) that solve this problem, but they're all bound significantly by the language itself. JavaScript is slow, dynamically typed, and [a bit of a mess](https://medium.com/netscape/javascript-is-kinda-shit-im-sorry-2e973e36fec4). Like all things to do with the web, changing things is really difficult because people have already started using them, and there will always be *someone* still using Internet Explorer, which supports almost no modern web standards at all. + +[Wasm](https://webassembly.org) solves all these problems by creating a unified format that other programming languages, like Rust, can compile into for the browser environment. This makes websites safer, faster, and development more productive. The equivalent of these reactive frameworks for Rust in particular would be projects like [Sycamore](https://sycamore-rs.netlify.app), [Seed](https://seed-rs.org), and [Yew](https://yew.rs). Sycamore is the most extensible and low-level of those options, and it's more performant because it doesn't use a [virtual DOM](https://svelte.dev/blog/virtual-dom-is-pure-overhead) (link about JS rather than Rust), and so it was chosen to be the backbone of Perseus. Here's what that counter might look like in [Sycamore](https://sycamore-rs.netlify.app) (the incrementation has been moved into a new closure for convenience): + +```rust,no_run,no_playground +use sycamore::prelude::*; + +let counter = Signal::new(0); +let increment = cloned!((counter) => move |_| counter.set(*counter.get() + 1)); + +template! { + p { (counter.get()) } + button(on:click = increment) { "Increment" } +} +``` + +You can learn more about Sycamore's amazing systems [here](https://sycamore-rs.netlify.app). + +### This sounds good... + +But there's a catch to all this: rendering. With all these approaches in Rust so far (except for a few mentioned later), all your pages are rendered *in the user's browser*. That means your users have to download your Wasm code and run it before they see anything at all on their screens. Not only does that increase your loading time ([which can drive away users](https://medium.com/@vikigreen/impact-of-slow-page-load-time-on-website-performance-40d5c9ce568a)), it reduces your search engine rankings as well. + +This can be solved through *server-side rendering* (SSR), which means that we render pages on the server and send them to the client, which means your users see something very quickly, and then it becomes *interactive* (usable) a moment later. This is better for user retention (shorter loading times) and SEO (search engine optimization). + +The traditional approach to SSR is to wait for a request for a particular page (say `/about`), and then render it on the server and send that to the client. This is what [Seed](https://seed-rs.org) (an alternative to Perseus) does. However, this means that your website's *time to first byte* (TTFB) is slower, because the user won't even get *anything* from the server until it has finished rendering. In times of high load, that can drive loading times up worryingly. + +The solution to this is *static site generation* (SSG), whereby your pages are rendered *at build time*, and they can be served almost instantly on any request. This approach is fantastic, and thus far widely unimplemented in Rust. The downside to this is that you don't get as much flexibility, because you have to render everything at build time. That means you don't have access to any user credentials or anything else like that. Every page you render statically has to be the same for every user. + +Perseus supports SSR *and* SSG out of the box, along with the ability to use both on the same page, rebuild pages after a certain period of time (e.g. to update a list of blog posts every 24 hours) or based on certain conditions (e.g. if the hash of a file has changed), or even to statically build pages on demand (the first request is SSR, all the rest are SSG), meaning you can get the best of every world and faster build times. + +To our knowledge, the only other framework in the world right now that supports this feature set is [NextJS](https://nextjs.org) (with growing competition from [GatsbyJS](https://www.gatsbyjs.com)), which only works with JavaScript. Perseus goes above and beyond this for Wasm by supporting whole new combinations of rendering options not previously available, allowing you to create optimized websites and web apps extremely efficiently. + +## How fast is it? + +[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. + +Right now, Perseus is undergoing major improvements to make it even faster and to introduce new features, like support for internationalization (making your app available in many languages) out of the box, which involves significant changes to the code. Once these are ready, benchmarks for Perseus itself will be written to show how fast Perseus really is, but right now none exist. + +## How convenient is it? + +Perseus aims to be more convenient than any other Rust web framework by taking an approach similar to that of [ReactJS](https://reactjs.org). Perseus itself is an extremely complex system consisting of many moving parts that can all be brought together to create something amazing, but the vast majority of apps don't need all that customizability, so we built a command-line interface (CLI) that handles all that complexity for you, allowing you to focus entirely on your app's code. + +Basically, here's your workflow: + +1. Create a new project. +2. Define your app in around 12 lines of code and some listing. +3. Code your amazing app. +4. Run `perseus serve`. + +## How stable is it? + +Okay, there had to be one caveat! Perseus is *very* new, and as such can't be recommended for *production* usage yet. However, we're aiming to get it ready for that really soon, which will hopefully include even being able to deploy Perseus with [serverless functions](https://en.wikipedia.org/wiki/Serverless_computing), the step beyond a server! + +For now though, Perseus is perfect for anything that doesn't face the wider internet, like internal tools, personal projects, or the like. Just don't use it to run a nuclear power plant, okay? + +## Summary + +If all that was way too long, here's a quick summary of what Perseus does and why it's useful! + +- JS is slow and a bit of a mess, [Wasm](https://webassembly.org) lets you run most programing languages, like Rust, in the browser, and is really fast +- Doing web development without reactivity is really annoying, so [Sycamore](https://sycamore-rs.netlify.app) is great +- Perseus lets you render your app on the server, making the client's experience *really* fast, and adds a ton of features to make that possible, convenient, and productive (even for really complicated apps)