Skip to content

Commit

Permalink
feat: introduce JavaScript plugin API using V8 (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
w-henderson authored Jan 24, 2024
1 parent 52b9a73 commit 3886551
Show file tree
Hide file tree
Showing 25 changed files with 868 additions and 46 deletions.
186 changes: 169 additions & 17 deletions Cargo.lock

Large diffs are not rendered by default.

44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<hr><br>

Stuart is a very fast and flexible static site generator, with build times as low as 0.1ms per page. It is written in Rust, and is designed be easier to use than other SSGs, while still beating them in the benchmarks. Stuart's simple yet powerful templating system allows you to define complex logic for your site, sourcing data from Markdown and JSON files as well as the template files, before rendering it all to static HTML. For even more complex projects, you can augment Stuart with custom build scripts in any language that integrate with the core build system, as well as custom plugins.
Stuart is a very fast and flexible static site generator, with build times as low as 0.1ms per page. It is written in Rust, and is designed be easier to use than other SSGs, while still beating them in the benchmarks. Stuart's simple yet powerful templating system allows you to define complex logic for your site, sourcing data from Markdown and JSON files as well as the template files, before rendering it all to static HTML. For even more complex projects, you can augment Stuart with custom build scripts in any language that integrate with the core build system, as well as custom plugins in Rust or JavaScript.

**Note:** Stuart is an extremely new project, so functionality and documentation are still being added. For the time being, documentation is limited to this README.

Expand All @@ -44,7 +44,7 @@ Stuart is a very fast and flexible static site generator, with build times as lo

### Installation

Stuart is available as a pre-built binary for Windows and Linux. You can download the latest release from the [releases page](https://github.com/w-henderson/Stuart/releases). Alternatively, you can build the code from scratch using Rust's package manager, Cargo. To do this, clone the repository and run `cargo build --release`.
Stuart is available as a pre-built binary for Windows and Linux. You can download the latest release from the [releases page](https://github.com/w-henderson/Stuart/releases). Alternatively, you can build the code from scratch using Rust's package manager, Cargo. To do this, clone the repository and run `cargo build --release`. Support for JavaScript plugins is disabled by default, so to enable it, enable the `js` feature.

Stuart requires Git to be installed for many of its features to work.

Expand Down Expand Up @@ -74,11 +74,17 @@ You can declare plugin dependencies in the `[dependencies]` section using a simi
[dependencies]
my_plugin = "/path/to/plugin.so"
my_other_plugin = "/path/to/cargo/project/"
my_remote_plugin = "https://github.com/username/plugin"
my_git_plugin = "https://github.com/username/plugin"
my_remote_plugin = "https://example.com/plugin.so"
```

Stuart will automatically detect whether the plugin needs to be cloned from a Git repository and whether it needs to be compiled. If the plugin does require compilation, Stuart requires the Rust toolchain to be installed.

You can separate plugin sources with a semicolon to specify fallbacks, for example:
```toml
my_plugin = "/lib/plugin.so;https://example.com/plugin.so"
```

## Project Structure

A Stuart project contains a number of folders, each of which has a specific purpose. Additionally, some file names have special meanings too. All content should go in the `content` directory, as this is the only one that will be processed by the build system.
Expand Down Expand Up @@ -237,9 +243,11 @@ Stuart supports dynamically-loaded plugins, which are Rust libraries that provid
{{ my_plugin::my_function() }}
```

### Plugin API
If the function name doesn't clash with another function, the plugin name can be omitted. If it does, built-in functions take priority over plugin functions, but the order of plugin functions is undefined. So don't do that.

### Native Plugin API

Plugins are defined using the `define_plugin!` macro in the core crate. They can add functions to Stuart, which are implemented in exactly the same way as the built-in functions, and can also add parsers for new file types. Please refer to the [built-in functions](https://github.com/w-henderson/Stuart/tree/master/stuart-core/src/functions/parsers) for function implementation examples, and to the [image optimization plugin source code](https://github.com/w-henderson/Stuart/tree/master/plugins/imgopt) to see how parsers for new file types can be used. An example of calling the macro is as follows:
Native plugins are defined using the `define_plugin!` macro in the core crate. They can add functions to Stuart, which are implemented in exactly the same way as the built-in functions, and can also add parsers for new file types. Please refer to the [built-in functions](https://github.com/w-henderson/Stuart/tree/master/stuart-core/src/functions/parsers) for function implementation examples, and to the [image optimization plugin source code](https://github.com/w-henderson/Stuart/tree/master/plugins/imgopt) to see how parsers for new file types can be used. An example of calling the macro is as follows:

```rs
declare_plugin! {
Expand Down Expand Up @@ -268,3 +276,29 @@ The project must also have `stuart_core` as a dependency in order to use the `de
[dependencies]
stuart_core = { version = "*", default-features = false }
```

### JavaScript Plugin API

When compiled with the `js` feature, Stuart can also load plugins written in JavaScript using V8. These can only add functions to Stuart. A plugin is simply a JavaScript module which exports a default object containing the plugin metadata, analogous to the `declare_plugin!` macro in Rust:

```js
export default {
name: "my_plugin",
version: "1.0.0",
functions: [
{
name: "add",
fn: (a, b) => a + b
}
]
}
```

Stuart variables will automatically be converted to JavaScript objects when passed as arguments, but changes to them will not propagate back to the Rust end automatically. For that, you can use the `STUART` global object to access the plugin API.

```js
const self = STUART.get("self");
STUART.set("my_var", `Title: ${self.title}`);
```

For an example of a more complex JavaScript plugin, see [stuart-math](https://github.com/w-henderson/stuart-math), which uses MathJax to render LaTeX.
2 changes: 1 addition & 1 deletion stuart-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stuart_core"
version = "0.2.7"
version = "0.3.0"
edition = "2021"
license = "MIT"
homepage = "https://github.com/w-henderson/Stuart"
Expand Down
12 changes: 7 additions & 5 deletions stuart-core/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,18 @@ use std::fmt::Debug;
/// the inner workings of which are hidden from Stuart through the [`Function`] trait. The parser should also
/// define a name, which is used to identify the function when parsing a file. The name of the function parser
/// **must** be the same as that of the returned function.
pub trait FunctionParser: Send + Sync {
pub trait FunctionParser {
/// Returns the name of the function which the parser can parse.
///
/// This **must** return the same value as the `name` method of the returned function.
fn name(&self) -> &'static str;
fn name(&self) -> &str;

/// Attempts to parse the raw function into an executable function object.
fn parse(&self, raw: RawFunction) -> Result<Box<dyn Function>, ParseError>;

/// Returns `true` if the raw function can be parsed by this function parser.
///
/// Not used for plugin functions!
fn can_parse(&self, raw: &RawFunction) -> bool {
raw.name == self.name()
}
Expand All @@ -72,9 +74,9 @@ pub trait FunctionParser: Send + Sync {
///
/// When the function is executed, it is given a [`Scope`] object, which contains information about the current state
/// of the program, including variables, stack frames and more.
pub trait Function: Debug + Send + Sync {
pub trait Function: Debug {
/// Returns the name of the function.
fn name(&self) -> &'static str;
fn name(&self) -> &str;

/// Executes the function in the given scope.
fn execute(&self, scope: &mut Scope) -> Result<(), TracebackError<ProcessError>>;
Expand Down Expand Up @@ -151,7 +153,7 @@ macro_rules! define_functions {
const FUNCTION_COUNT: usize = count!($($name)*);

::lazy_static::lazy_static! {
static ref FUNCTION_PARSERS: [Box<dyn $crate::functions::FunctionParser>; FUNCTION_COUNT] = [
static ref FUNCTION_PARSERS: [Box<dyn $crate::functions::FunctionParser + Sync>; FUNCTION_COUNT] = [
$(Box::new($name)),*
];
}
Expand Down
1 change: 1 addition & 0 deletions stuart-core/src/parse/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct RawFunction {
}

/// Represents a raw argument.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RawArgument {
/// A variable name.
Variable(String),
Expand Down
2 changes: 1 addition & 1 deletion stuart-core/src/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ fn parse_function(
for plugin in plugins.plugins() {
for function in &plugin.functions {
if function_name == function.name()
|| function_name == format!("{}::{}", &plugin.name, function.name())
|| function_name == format!("{}::{}", plugin.name, function.name())
{
return Ok(Token::Function(Rc::new(
function
Expand Down
9 changes: 7 additions & 2 deletions stuart/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stuart"
version = "0.2.7"
version = "0.3.0"
edition = "2021"
license = "MIT"
homepage = "https://github.com/w-henderson/Stuart"
Expand All @@ -15,7 +15,7 @@ name = "stuart"
path = "src/main.rs"

[dependencies]
stuart_core = { version = "^0.2.6", path = "../stuart-core" }
stuart_core = { version = "^0.3.0", path = "../stuart-core" }

clap = "^3.2"
toml = "^0.5"
Expand All @@ -25,6 +25,11 @@ termcolor = "^1.1.0"
once_cell = "^1.13.1"
include_dir = "^0.7.2"
humphrey = { version = "^0.7.0", features = ["tls"] }
humphrey_json = { version = "^0.2.0", default-features = false }
humphrey_ws = "^0.5.1"
notify = "^4.0.17"
libloading = "^0.7.3"
v8 = { version = "^0.82.0", optional = true }

[features]
js = ["v8"]
3 changes: 0 additions & 3 deletions stuart/example/stuart.toml

This file was deleted.

13 changes: 10 additions & 3 deletions stuart/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ mod plugins;
mod scripts;
mod serve;

#[cfg(test)]
mod test;

use crate::build::StuartContext;
use crate::error::StuartError;
use crate::logger::{LogLevel, Logger, Progress, LOGGER};
Expand All @@ -24,8 +27,9 @@ use std::fs::{remove_dir_all, remove_file};
use std::path::PathBuf;
use std::sync::atomic::Ordering;

fn main() {
let matches = App::new("Stuart")
/// Returns the CLI application.
fn app() -> App<'static> {
App::new("Stuart")
.version(env!("CARGO_PKG_VERSION"))
.author("William Henderson <[email protected]>")
.about("A Blazingly-Fast Static Site Generator")
Expand Down Expand Up @@ -102,7 +106,10 @@ fn main() {
Command::new("clean").about("Removes the output directory and generated metadata"),
)
.subcommand_required(true)
.get_matches();
}

fn main() {
let matches = app().get_matches();

let log_level = if matches.is_present("quiet") {
LogLevel::Quiet
Expand Down
4 changes: 2 additions & 2 deletions stuart/src/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ use std::io::Write;
use std::path::{Path, PathBuf};

/// The directory containing the default site template, built into the binary when compiled.
static DEFAULT_PROJECT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/example");
static DEFAULT_PROJECT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/tests/basic");

/// Creates a new site with the given arguments.
pub fn new(args: &ArgMatches) -> Result<(), Box<dyn StuartError>> {
let name = args.value_of("name").unwrap();
let path = PathBuf::try_from(name).map_err(|_| FsError::Write)?;
let path = PathBuf::from(name);
let no_git = args.is_present("no-git");

let mut manifest: Vec<u8> = format!("[site]\nname = \"{}\"", name).as_bytes().to_vec();
Expand Down
87 changes: 87 additions & 0 deletions stuart/src/plugins/js/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! Enables access to Stuart's execution context from JavaScript running within V8.
use stuart_core::process::Scope;

/// Makes the Stuart scope accessible to `set_variable` and `get_variable` when they're called from JavaScript code.
pub fn set_stuart_context(scope: &mut v8::HandleScope, context: &mut Scope) {
let stuart_context = v8::Object::new(scope);

let k_context = v8::String::new(scope, "STUART").unwrap();
let k_set_variable = v8::String::new(scope, "set").unwrap();
let k_get_variable = v8::String::new(scope, "get").unwrap();
let k_external = v8::String::new(scope, "_ptr").unwrap();

let set_variable = v8::FunctionTemplate::new(scope, set_variable)
.get_function(scope)
.unwrap();
let get_variable = v8::FunctionTemplate::new(scope, get_variable)
.get_function(scope)
.unwrap();
let external = v8::External::new(scope, context as *mut _ as *mut std::ffi::c_void);

stuart_context.set(scope, k_set_variable.into(), set_variable.into());
stuart_context.set(scope, k_get_variable.into(), get_variable.into());
stuart_context.set(scope, k_external.into(), external.into());

scope
.get_current_context()
.global(scope)
.set(scope, k_context.into(), stuart_context.into());
}

/// Gets the Stuart context from V8.
///
/// # Safety
/// This function is only safe if `set_stuart_context` has previously been called with the same `scope`.
unsafe fn get_stuart_context<'s>(
scope: &mut v8::HandleScope,
obj: v8::Local<'_, v8::Object>,
) -> &'s mut Scope<'s> {
let k_external = v8::String::new(scope, "_ptr").unwrap();

(v8::Local::<v8::External>::try_from(obj.get(scope, k_external.into()).unwrap())
.unwrap()
.value() as *mut Scope)
.as_mut()
.unwrap()
}

/// Sets a variable in the current Stuart scope.
pub fn set_variable<'s>(
scope: &mut v8::HandleScope<'s>,
args: v8::FunctionCallbackArguments<'s>,
_ret: v8::ReturnValue,
) {
let stuart_scope = unsafe { get_stuart_context(scope, args.this()) };
let key = args.get(0).to_rust_string_lossy(scope);
let value = args.get(1);
let json_value = super::json::js_to_json(value, scope);

if json_value.is_none() {
println!(
"warning(js): attempted to set variable `{}` to `undefined`",
key
);
return;
}

stuart_scope
.stack
.last_mut()
.unwrap()
.add_variable(key, json_value.unwrap());
}

/// Gets a variable from the current Stuart scope.
pub fn get_variable<'s>(
scope: &mut v8::HandleScope<'s>,
args: v8::FunctionCallbackArguments<'s>,
mut ret: v8::ReturnValue,
) {
let stuart_scope = unsafe { get_stuart_context(scope, args.this()) };
let key = args.get(0).to_rust_string_lossy(scope);
let value = stuart_scope.get_variable(&key);
let v8_value = super::json::json_to_js(value, scope);

ret.set(v8_value);
}
Loading

0 comments on commit 3886551

Please sign in to comment.