Hey everyone, I’m glad to announce that elembic v1.0.0 is out now on the package manager!
Elembic is a framework for custom elements and types in Typst. It implements this long-awaited feature in pure Typst, so you can use it for your packages and templates right now!
If you’ve used lilaq before, you’ve already been using it, but now it’s your turn
Custom elements: Reusable and customizable document components, with support for typechecked fields, show and set rules (without state by default, so it is reasonably performant!), reference and outline support, as well as features not yet in Typst such as revokable rules and child element selectors.
Custom types: Data structures with support for typechecked fields but also custom type casting. They can be used in element fields, but also for your own needs through e.types.cast(value, type).
It is well-suited for:
Packages: elembic’s elements allows creating reusable components which can be freely customized by your package’s end users. With typechecking and other features, elembic’s got you covered in terms of flexibility.
Templates: elembic’s elements can be used for fine-grained configuration of parts of your template. See e.g. the “Simple Thesis” example.
Note that it has a few limitations - check docs for info!
Here’s a quick example (outputs a purple box):
#import "@preview/elembic:1.0.0" as e: field, types
#let fbox = e.element.declare(
"fbox",
prefix: "@preview/my-package,v1",
doc: "My filled box",
display: it => block(fill: it.fill, inset: 5pt, it.body),
fields: (
field("body", content, doc: "Goes inside the box", required: true),
field("fill", types.option(types.paint), doc: "Fills the box"),
)
)
#show: e.set_(fbox, fill: purple)
#fbox[elembic 1.0]
As a fun fact, elembic’s name comes from “element” + “alembic”, to indicate how elembic aims to experiment with and gather feedback for this feature before it eventually becomes built-in.
How does this work internally? I couldn’t find an explanation in the docs (sorry if I just missed it). I tried taking a quick look at element.typ but that didn’t help at all.
If you can find a way to explain the implementation in a reasonably long response, I would really appreciate it.
Apologies @janekfleper, you’re totally right that I should add a dedicated page for this on the docs (you can consider the docs to be a bit of a “work in progress” right now). I’ll do this in the next few days.
For a quick summary while that isn’t there, it will store your set rules in a set bibliography(title: metadata(...)) rule which only applies to elements with a special label. Those elements can then use context to read the bibliography.title property to retrieve set rules. This way, we hijack one built-in set rule that can hold unlimited data to create and manage our own set rules, with nearly the same semantics as usual set rules. Something like this:
#let element(..args) = [#context {
let previous-rules = if (
[#bibliography.title].func() == metadata
and bibliography.title.at("label", default: none) == <custom-styles>
) {
bibliography.title.value
} else {
() // default
}
// For elements: just need to read, e.g. join all set rules + args
let fields = previous-rules.sum(default: (:)) + args.named()
display(fields)
}<lbl-get>]
#let set(element, ..args) = doc => [#context {
let previous-rules = if (
[#bibliography.title].func() == metadata
and bibliography.title.at("label", default: none) == <custom-styles>
) {
bibliography.title.value
} else {
() // default
}
// For set rules: need to write too
// e.g. set(element, field: 5)
// Suppose there is only one element so just one array
let new-rules = previous-rules
new-rules.push((field: 5))
show <lbl-get>: set bibliography(title: [#metadata(new-rules)<custom-styles>])
// Apply that show-set to any "getters" in the rest of the document
doc
}<lbl-get>]
Everything else builds up from this concept. Notably, we need to set bibliography(title: ...) again for hygiene so we don’t erase your bibliography title, which is why we always use two nested context blocks (one to get the previous value to reset later, one to get the styles). To separate set rules between elements, we use a dictionary with one entry per eid (element id). And so on… there’s lots to say which I will probably write down in the docs soon enough.
If the bibliography.title set is restricted to a specific label, why do you need to do anything here, shouldn’t the original scope property be untouched?
It’s because the set rule, in order to apply the next show <lbl-get>: set bibliography(title: ...), needs to insert the entire rest of the scope / document inside its own <lbl-get>, so the previous bibliography title set rule would also affect the rest of the document, unless we use this construction:
#context {
let previous-bib-title = bibliography.title
[#context {
let rules = ... // read bib.title here
// ...
// update bib title for the next getter...
show <lbl-get>: set bibliography(title: [#metadata(rules)<styles>])
// ... but keep it unchanged for everything else
set bibliography(title: previous-bib-title)
// 'doc' is inside the getter so its bibliography title would
// be changed without the set bibliography above
doc
}<lbl-get>]
}