~aleteoryx/lfm_embed

ff0d799cf0e5aaa23596cb27153738ce7add16ec — alyx 8 months ago b2e4abd
Document lua, fix small theme bugs
3 files changed, 151 insertions(+), 51 deletions(-)

M README.md
M src/theming/hbs/plain.hbs
M src/theming/lua.rs
M README.md => README.md +142 -47
@@ 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 &lt;b&gt;does&lt;/b&gt;!</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.

M src/theming/hbs/plain.hbs => src/theming/hbs/plain.hbs +2 -2
@@ 13,8 13,8 @@
        a { color: cyan; }
      {{/if}}
      * { font-size: {{#if font}}17px{{else}}{{#if font.scale}}calc(20px * {{font.scale}}){{else}}20px{{/if}}{{/if}}; }
      {{#if (or font.name font.css)}}
        * { font-family: '{{#if font.name}}{{font.name}}{{/if}}' }
      {{#if font.name}}
        * { font-family: '{{font.name}}' }
      {{/if}}
      p { margin: 0px; padding: 0px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
    </style>

M src/theming/lua.rs => src/theming/lua.rs +7 -2
@@ 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}");