I believe I’ve come up with a useful solution to this problem, that’s granular, modular and yet reasonably user-friendly, that also avoids the issue of forcing people to fork to do more powerful customisation, based off of the idea of typed-tagless-final-form coding strategies (more specifically the idea of pluggable “interpreters” with an extensible “dsl”-ish thing of custom elements or pseudo-custom elements) and a little bit of looking at the source of the tidy
typst package.
I actually describe two solutions, one suitable for typst
in it’s current state, and one based off the same ideas but with careful usage of currently-hypothetical custom element types and probably custom fields (note that neither of these solutions can solve the recursion issue).
This post is split into 5, because of how long it is. (This is part 1 of 5, here’s hoping I don’t get flagged as spam…).
Hypothetical Future Solution
The hypothetical future solution is that packages, rather than merely defining custom types with basic fields, instead also define compositional steps in their final “show” function - to arbitrary degrees of nesting - as fields storing closures/lambdas, overwritable with set
rules (or perhaps some new custom behaviour
system if there was to be specific language support, which would in theory be able to actually replace show
rules entirely…).
Then, with clever use of defaults to compose more complex behaviours out of less complex behaviours, each level of behaviour can be modified mostly independently, all the way up to the final show
functionality. If other parameters (“value” parameters) were also modified to accept a closure returning their values, this could be extended all the way down to enable near-arbitrary calculation of the parameters also.
This sort of clever overriding - if combined with custom fields - would also allow users to modify and control the parametrisation of types too, if they modified the more granular behaviours in-sync to use their new mini-interfaces.
Basically, it enables precisely the degree of customisation that a user wants, with precisely the degree of modularity and precision needed, no more or no less. And it means that it’s possible to independently do modifications of individual behaviours as long as they don’t conflict (and even if they do it may be possible to write a third module to bridge over any modifications to the input or output parameters of modified functions). We could, for instance, imagine people distributing packages containing specific tweaks to other popular packages or templates, without having to fork them.
As for stuff like automatically generating content, you would probably want a form of @Mc-Zen’s solution, but with more granular content generator functions. Configuring basic parameters of your templates (e.g. title) becomes just another set
rule (or many of them, in one-or-more modular/composible template-providable functions, similar to how we currently do it). You could also provide the traditional single-function option, as now things like behaviour are controlled granularly by set-rules if so desired anyway.
Potential Future Example
This may make more sense if I write out some basic examples as an illustration. They are only examples, but may illustrate what I mean when I say “build up behaviours” (in some sense we would be building up a DSL) .
They will use the pseudo type syntax that has been used earlier, though I will also add behaviour
as an alias to field
to differentiate the ones that are primarily intended for closure storage (they work fine with normal fields though, and indeed adding separate syntax would likely be undesirable since it’s likely that you’d want to be able to potentially vary the types or do something with auto
and auto-replace it with the default). Also I use Rust a lot so some rusty syntax/styles might sneak in :p
#type axis {
...
// Behaviour that makes the tick graphics from e.g. some internal function
behaviour make_ticks: (self) => {... some content ...};
// Behaviour that rotates them (the ticks created by make_ticks) as needed
// e.g. rotate y ticks by tau/4 radians.
behaviour rotate_ticks: (self, ticks) => {... some content ...};
show it => { ... }; // `it` is `self`, essentially.
}
#type graph {
field page-title: error,
field title: none,
field x-bounds: (-1, 1),
field y-bounds: (-1, 1),
field resolutions: (100, 100),
// Meant to be basic closure (x: float, y: float) -> bool
field plot: (x, y) => false,
/* More fields here*/,
// Behaviour for "rendering" the plot into a resolutions[0] x resolutions[1]
// array of colours
behaviour render: (self) => {
let axis_diffs = (self.x-bounds, self.y-bounds).map(((start, end),) => end - start);
let (x_celldiffs, y_celldiffs) = self.resolutions.zip(axis_diffs).map(((res, delta),) => delta/res);
range(0, self.resolutions.at(0))
.map(xidx => range(0, self.resolutions.at(1))
.map(
yidx => ((xidx + 0.5) * x_celldiffs, (yidx + 0.5) * y_celldiffs)
).map(self.plot).map(write => if write { black } else { white })
)
},
// Get the "tick" coordinates for the axis (one of "x" or "y") to an array of the relevant coordinate
behaviour ticks: (self, coord_axis) => { ... };
// Make the two "axes" from the ticks -> (x_axis_content|none, y_axis_content|none)
behaviour make_axes: (self, x_ticks, y_ticks) => {
// more axis special behaviour or wrapping goes here.
let x_axis = [#axis(tick-coords: x_ticks)];
let y_axis = [#axis(tick-coords: y_ticks)];
(x_axis, y_axis)
},
// Embed graph content from output of render()
// (self, render_output) -> content
behaviour embed_graph_content: (self, render_output) => { ... },
// combo behaviour for the graphs.
// (self) -> content
behaviour create_graph_content: (self) => { self.embed_graph_content(self.render()) },
// Create the axes content in full.
// (self) -> {x_axis_content, y_axis_content}
behaviour create_axes(self) -> {
let x_ticks = self.ticks("x");
let y_ticks = self.ticks("y");
self.make_axes(x_ticks, y_ticks)
},
// in reality, you'd probably just use a basic parameter for this level of basic formatting but it's an example xD
behaviour add_background: (self, content) => { ... some content ...}
// fancy layout calc 1
behaviour fancy_layout_calc_1: (self, graph_content, x_axis_content, y_axis_content) => { ... },
// fancy layout calc 2
behaviour fancy_layout_calc_2: (self, graph_content, x_axis_content, y_axis_content) => { ... },
show self: self => {
let (x_axis, y_axis) = self.create_axes();
let graph_content = self.create_graph_content();
let layout_info_1 = self.fancy_layout_calc_1(graph_content, x_axis_content, y_axis_content);
let layout_info_2 = self.fancy_layout_calc_2(graph_content, x_axis_content, y_axis_content);
self.add_background({
... more title stuff here with self.title or a custom rendering/layout/align function if not just a param.
place(dx: layout_info_1.graph, dy: layout_info_2.graph, graph_content);
place(dx: layout_info_2.x-axis, dy: layout_info_1.x-axis, x_axis);
place(dx: layout_info_1.y-axis, dy: layout_info_2.y-axis, y_axis);
})
}
}
// Module itself provides a function that does a bunch of set rules including for #graph.
// May also provide sub-functions that do some subset, and the user can always manually override
// individual parts. Usable with the standard .with and `show`-rule mechanisms
#let mainmodule.conf(page-title, body) = {
#set graph(page-title: page-title)
};
// user wants to make graph have a higher resolution by default, and style text in the axes by default
#set graph(resolution: (200, 200))
#show axis: set text(weight: "bold")
// separate module wants to modify the graph to use png rendering,
// overriding render to output png data, and create_content to use it rather than the normal format.
// can be used by standard `show:` method, and/or could provide a function that the user can manually
// make a `set`-rule from.
// The module could also modify create_graph_content to make it ignore render() entirely.
let extension-module.make-graphs-png(body) = [
#set graph(
render: (self) => { ... fancy png generator ...},
embed_graph_content: (self, render_output) => { ... modified for png output ... }
)
#body
]
// now produces an output with all the modifications (at least the
// compatible ones without blatant conflict), including the formatting on
// the axes, the increased default resolution, and the png version of the output.
#graph( ... )
Here we note some important things:
- Almost all configuration - including of behavioural chunks - is done through
set
-rules, and/or direct specification (as they are fields). This makes it modular:- You could, for example, imagine custom “tweak” modules that apply highly precise set-rules, that could well be combined unless they change the actual interface of the functions (and even then they only affect other modules that do something similar).
- Advanced configuration knobs for the implementing module could also be done in this way, e.g. if there was a template for a journal, but that journal had slightly different needed layouting logic for different “sections” or versions, the template could tweak the logic with as little or as much granularity as needed.
- Simpler properties remain trivial via set-rules.
- You may have noticed that in my definitions for “behaviours”, I rarely called out to other behaviours and stored their results for processing within the same behaviour, other than to provide more computational structure. This is deliberate:
- By doing things this way, it makes it easier to - given the current behaviour - perform transformations on the inputs and outputs of that behaviour that preserve the interface, without needing to rewrite the internal computation structure. If there was or is a way to obtain the current “default/set” value of a
set
rule, then it would be fairly simple to write a new function that wraps it with some more custom logic, for simple-ish cases. - This way also helps separate behaviour from data (in a similar way to Rust traits) - in a conceptual sense, the “self” parameter is performing two functions:
- Provide the values of normal fields.
- Select the “implementation” of behaviours on request, enabling the indirection required to actually plug behaviours together even when they have been modified with
set
-rules. This should only be done for places where the rules logically combine into a larger chunk of computation that makes logical sense to replace at-once.- This is actually quite design-specific and it somewhat depends on degree of preference (and in the extreme case, you can modify the “last” value-producer and make it recalculate stuff from earlier using the embedded behaviours, the system is flexible enough to enable overrides producing almost any design).
- The actual tradeoffs of this are not something I’ve gone into too much detail of but some of it involves deduplicating effort, but then by making more “nested” computations you can sort of build up a more nested set of levels of granularity for overrides, and the memoizing system of
typst
may make the potential performance advantage totally irrelevant. - It may help to conceptually imagine this implementation as both a “custom element type” with normal “fields”, combined with a nested structure of rust-like traits-and-impls which can be replaced in whole or in part by set-rules, and where there is a sort of “layered” structure of each “implementation” depending on the more granular “implementations” (but the user can also replace the “less granular” impls in a chunk). All leading up to a final “implementation” of a
show-rule
, which is the pseudotrait implemented by custom types more generally for custom rendering.
- By doing things this way, it makes it easier to - given the current behaviour - perform transformations on the inputs and outputs of that behaviour that preserve the interface, without needing to rewrite the internal computation structure. If there was or is a way to obtain the current “default/set” value of a
One Potential Drawback
The main potential drawback (other than the risk of conflicting packages etc. and maybe performance depending on impl details) I see of this is that of the issue of mandatory parameters/fields for custom types - for instance, if you had a type that required a title
field, and no sensible default exists, you can currently only supply it as a positional argument and hence you can’t use set
rules to configure it.
This means that either Typst would need to add mandatory named arguments, or you’d need to set a default that produces an error if unset (with manual checks, unless e.g. we added a special let func(a: error("optional fail info"), b: (:))
sort of thing, which would not be unreasonable. This could also be done by setting mandatory fields to be behaviours with a default closure that panics.
In terms of language-server-wise stuff, it may also be worth considering a way to “hide” custom fields if this system were to become popular for modularity purposes, or otherwise separate out “behaviour-like” fields from other fields either syntactically or otherwise for documentation clarity, even if they are the same thing internally.