@@ 93,20 93,20 @@ See `LFME_ALLOWLIST_REFRESH` for more info.
## Themes
### `LFME_THEME_DIR`
-If set, must be a valid path to a directory containing [Handlebars](https://handlebarsjs.com/guide/#language-features) files, ending in LFME_THEME_EXT.
+If set, must be a valid path to a directory containing a tree of [Handlebars](https://handlebarsjs.com/guide/#language-features) and [Lua](https://www.lua.org/) files, ending in `LFME_THEME_EXT_HBS` and `LFME_THEME_EXT_LUA`, respectively.
They will be registered as themes on top of the builtin ones, with each theme's name being their filename minus the extension.
Same-named themes will override builtin ones.
See [Theming](#theming) for details on writing custom themes.
-Theme names are the same as their path, minus the extension. Given an extension of .hbs, a directory like:
+Theme names are the same as their path, minus the extension. A directory like:
```
themes/
mytheme.hbs
- myothertheme.hbs
+ myothertheme.lua
myunrelatedfile.css
alices-themes/
- mytheme.hbs
+ mytheme.lua
mysuperawesometheme.hbs
```
results in the following themes:
@@ 119,16 119,21 @@ alices-themes/mysuperawesometheme
By default, these are loaded and compiled once, at startup.
-### `LFME_THEME_EXT` (Default: `.hbs`)
-The file extension for themes in `LFME_THEME_DIR`.
+### `LFME_THEME_EXT_HBS` (Default: `.hbs`)
+The file extension for handlebars themes in `LFME_THEME_DIR`.
+
+Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension.
+
+### `LFME_THEME_EXT_LUA` (Default: `.lua`)
+The file extension for lua themes in `LFME_THEME_DIR`.
Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension.
### `LFME_THEME_DEV` (Default: `0`)
-If set to `1`, existing themes will be reloaded on edit.
+If set to `1`, themes will be reloaded on edit.
-Note: Even with this mode, adding a new theme requires a full reload.
-Themes are only enumerated once, at startup. (This is a limitation of the [`handlebars`](https://docs.rs/handlebars/latest/handlebars) implementation in use, and may change.)
+Note: Even with this mode, adding a new handlebars theme requires a full reload.
+Handlebars hemes are only enumerated once, at startup. (This is a limitation of the [`handlebars`](https://docs.rs/handlebars/latest/handlebars) implementation in use, and may change.)
### `LFME_THEME_DEFAULT` (Default: `"plain"`)
The theme to use when no query string is present.
@@ 144,16 149,48 @@ LFME_ALLOWLIST_MODE=exclusive
LFME_ALLOWLIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345
```
-# Theming {#theming}
+# Theming
-Custom themes are, as stated above, [Handlebars](https://handlebarsjs.com/guide/#language-features) files.
+Custom themes are, as stated above, [Handlebars](https://handlebarsjs.com/guide/#language-features) and [Lua](https://www.lua.org/) files.
They are expected to produce a complete HTML doc, which should contain info about a user's scrobbles.
-See `src/themes` for examples of implementation.
+You should have a passing familiarity with either language before reading below.
+
+## Handlebars Themes
+
+Handlebars is a simple templating language, and great for themes that don't do much complicated.
+The builtin `plain` theme is written in handlebars, and any themes that want to just relay the basic scrobble info should be written in it.
+
+If you're looking to do more complicated effects, or need server-side computation, see below on writing [Lua Themes](#lua-themes).
+Handlebars should __not__ be used if you're looking to do advanced effects.
+
+All builtin handlebars themes are located at [`/src/theming/hbs`](https://git.aleteoryx.me/cgit/lfm_embed/tree/src/theming/hbs).
+
+The following [helpers](https://handlebarsjs.com/guide/expressions.html#helpers) are provided, on top of the handlebars [builtins](https://handlebarsjs.com/guide/builtin-helpers.html):
+
+- `(eq Object Object) => Boolean`: Check equality between its args.
+
+- `(ne Object Object) => Boolean`: Check inequality between its args.
+
+- `(gt Number|String Number|String) => Boolean`: Check if the first arg is greater than the second.
+
+- `(gte Number|String Number|String) => Boolean`: Check if the first arg is greater than or equal to the second.
+
+- `(lt Number|String Number|String) => Boolean`: Check if the first arg is less than the second.
-## Writing a Theme
+- `(lte Number|String Number|String) => Boolean`: Check if the first arg is less than or equal to the second.
-Themes should have, roughly, the structure below:
+- `(and Boolean Boolean) => Boolean`: Boolean AND gate.
+
+- `(or Boolean Boolean) => Boolean`: Boolean OR gate.
+
+- `(not Boolean) => Boolean`: Boolean NOT gate.
+
+- `(uri_encode String) => String`: URI-encode input text, making the output suitable to be included as part of a link or other URL.
+
+### Writing a Handlebars Theme
+
+Handlebars themes should have, roughly, the skeleton below:
```html
<!DOCTYPE html>
@@ 164,8 201,10 @@ Themes should have, roughly, the structure below:
{{#if font.css}}
{{{font.css}}}
{{/if}}
- {{#if (or font.name font.css)}}
- * { font-family: '{{#if font.name}}{{font.name}}{{/if}}{{#if font.url}}included_font{{/if}}' }
+ /* cursed hack to deal with weird rendering in chrome */
+ * { font-size: {{#if font}}17px{{else}}{{#if font.scale}}calc(20px * {{font.scale}}){{else}}20px{{/if}}{{/if}}; }
+ {{#if font.name }}
+ * { font-family: '{{font.name}}' }
{{/if}}
/* actual styles */
</style>
@@ 178,7 217,83 @@ Themes should have, roughly, the structure below:
</html>
```
-You will be passed one of 2 context objects, depending on if there was an error processing the request.
+See below for the [Context Object](#the-context-object)
+
+## Lua Themes
+
+Lua is a powerful scripting language, for themes that need to do a lot of math on the server.
+`lfm_embed` uses the [Luau](https://luau-lang.org/) implementation, specicially.
+
+You are provided with only the `table`, `string`, `utf8`, `bit`, and `math` stdlib modules.
+You are also provided with the `html` global.
+
+- `html(el: String, tbl: Table) => String`: Generates the HTML for `<el>`, with the sequential entries in `tbl` as concatenated text, and the named entries as attributes. Sequential entries wrapped in a table will be escaped.
+
+- `html.<el>(tbl: Table) => String` Functions like `html`, but with the index name as the element type. It is intended to be called like `html.a{"Homepage", href = "/"}`.
+
+- `html.root(tbl: Table) => String` Generates the root of an HTML document, including doctype, processing `tbl` as normal.
+
+Example:
+
+```lua
+print(html.root{html.body{html.p{"Check out my cool site!"}}})
+-- <!DOCTYPE html>
+-- <html><body><p>Check out my cool site!</p></body></html>
+
+print(html.p{ "This <b>doesn't</b> get escaped, ", {"but this <b>does</b>!"} })
+-- <p>This <b>doesn't</b> get escaped, but this <b>does</b>!</p>
+```
+
+### Writing a Lua Theme
+
+Lua theme scripts are expected to return a function which takes one argument, the [Context Object](#the-context-object), and return a fully formed html document.
+They may contain anything as far as locals go, but are not allowed to manipulate the global context.
+The returned function will be repeatedly called for any incoming request.
+
+Lua themes should have, roughly, the skeleton below:
+
+```lua
+local stylefmt = [[
+/* font css */
+%s
+* { font-size: %spx; }
+%s
+/* actual styles go here*/
+]]
+
+return function(ctx)
+ local contents
+ local style
+ if ctx.error then
+ contents = html.p{{ctx.error}}
+ style = ""
+ else
+ contents = ""
+ if not ctx.font then ctx.font = {} end
+ local font_setter = ""
+ if ctx.font.name then
+ font_setter = string.format("* { font-family: '%s'; }", ctx.font.name)
+ end
+ local font_px
+ -- hack to deal with chrome being weird
+ if ctx.font then font_px = 17
+ elseif ctx.font.scale then font_px = 20 * ctx.font.scale
+ else font_px = 20 end
+ style = string.format(stylefmt, ctx.font.css or "", font_px, font_setter)
+ end
+ return html.root{lang = "en",
+ html.head{
+ html.meta{charset = "UTF-8"},
+ html.style{style}
+ },
+ html.body{contents}
+ }
+end
+```
+
+## The Context Object
+
+You will be passed one of 2 context objects, depending on if there was an error dealing with the remote backends.
If there was an error, the object will just be `{ error: String }`, otherwise it will be the following:
```js
@@ 193,7 308,7 @@ If there was an error, the object will just be `{ error: String }`, otherwise it
image_url: String,
// Link to user's profile.
url: String,
-
+
// Total scrobbles.
scrobble_count: Number,
// Number of artists in library.
@@ 202,11 317,11 @@ If there was an error, the object will just be `{ error: String }`, otherwise it
track_count: Number,
// Number of albums in library.
album_count: Number,
-
+
// True if user subscribes to last.fm pro.
pro_subscriber: Boolean
},
-
+
// The user's most current, or most scrobble.
scrobble: {
// The name of the track.
@@ 228,19 343,21 @@ If there was an error, the object will just be `{ error: String }`, otherwise it
url: String,
// True if the user has loved the track.
- loved: Boolean
+ loved: Boolean,
// True if the user is currently scrobbling it, false if it's just
// the most recently played track.
- now_playing: Boolean,
+ now_playing: Boolean
},
// Custom font info.
font: {
// Set if the theme should replace the custom font with something.
- name: String?
+ name: String?,
// Will contain CSS which includes a custom font as 'included_font'.
- css: String?
- }
+ css: String?,
+ // Will contain a factor of the base font scale to scale the theme's font by, for custom font reasons.
+ scale: Number?
+ },
// A set of extraneous query parameters.
//
@@ 252,28 369,6 @@ If there was an error, the object will just be `{ error: String }`, otherwise it
}
```
-In addition, the following [helpers](https://handlebarsjs.com/guide/expressions.html#helpers) are provided, on top of the handlebars [builtins](https://handlebarsjs.com/guide/builtin-helpers.html):
-
-- `(eq Object Object) => Boolean`: Check equality between its args.
-
-- `(ne Object Object) => Boolean`: Check inequality between its args.
-
-- `(gt Number|String Number|String) => Boolean`: Check if the first arg is greater than the second.
-
-- `(gte Number|String Number|String) => Boolean`: Check if the first arg is greater than or equal to the second.
-
-- `(lt Number|String Number|String) => Boolean`: Check if the first arg is less than the second.
-
-- `(lte Number|String Number|String) => Boolean`: Check if the first arg is less than or equal to the second.
-
-- `(and Boolean Boolean) => Boolean`: Boolean AND gate.
-
-- `(or Boolean Boolean) => Boolean`: Boolean OR gate.
-
-- `(not Boolean) => Boolean`: Boolean NOT gate.
-
-- `(uri_encode String) => String`: URI-encode input text, making the output suitable to be included as part of a link or other URL.
-
# Contributing
[E-Mail me](mailto:alyx@aleteoryx.me) if you'd like to help out, submit a custom theme, or request a feature.
@@ 4,7 4,7 @@ use std::path::Path;
use std::fs;
use std::sync::Mutex;
-use mlua::{Lua, LuaSerdeExt, Compiler, StdLib, Value, LuaOptions, Table};
+use mlua::{Lua, LuaSerdeExt, Compiler, StdLib, Value, LuaOptions, Table, SerializeOptions};
use http::StatusCode;
use crate::CONFIG;
@@ 115,7 115,12 @@ pub fn render_theme(name: &str, ctx: &crate::ctx::Ctx) -> Option<Result<String,
Err(e) => { log::error!("Error loading `{name}`: {e}"); return Some(Err(StatusCode::INTERNAL_SERVER_ERROR)); }, //TODO: gate behind flag
}
- let ctx = match lua.to_value(ctx) {
+ let opts = SerializeOptions::new()
+ .serialize_none_to_null(false)
+ .serialize_unit_to_null(false)
+ .set_array_metatable(false);
+
+ let ctx = match lua.to_value_with(ctx, opts) {
Ok(ok) => ok,
Err(e) => {
log::error!("Lua context serialization error: {e}");