A tiny (~2kb), writer-first, minimally-invasive DSL for token substitution in long-form text. You can think of it as a presentation layer for writers working with randomly-generated data. subwriter strictly separates the text from the content generation process. By way of constrast, Tracery hybridizes the two.
subwriter is designed to be as minimal as possible, and reserves only 5 characters ([ { | } ]
), with 2 additional characters (=
, "
) reserved within filter declarations. Anything else is fair game as content or as an identifier.
...including emojis, because why not.
- unreadable text is hard to write and even harder to proofread
- because for large amounts of text content, content-as-content is better than content-as-code
- sometimes
sprintf-js
just doesn't cut it and pulling in a real templating language is overkill
- an environment with support for
Intl.Segmenter
,
const data = {
'🦸♀️': { name: 'Sally' }
};
const ctx = {
// filters are functions that take source text and an optional param.
PRP: (glyph, caps = true) => {
const res = [...glyph].includes('♀') ? 'she' : 'he';
return caps ? res.toLocaleString() : res;
}
};
sub("Isn't that {🦸♀️.name}? Is [🦸♀️|PRP=false] a superhero?", data, ctx);
// Isn't that Sally? Is she a superhero?
Property access, filters, &c., are evaluated left to right.
Feature | Example | Output |
---|---|---|
Interpolation | {name} |
World |
Property access | {data.name} |
Bob |
Filters | {data.name|cap} |
BOB |
Filters (literal params) | {data.name|pre=123} |
123Bob |
Filters (reference params) | {🍾} [bottle|s=🍾] of beer |
1 bottle of beer |
Chained filters | {data.name|cap|pre=123} |
123BOB |
Filter expressions | [bobbbbbb!|cap|pre=123] |
123BOBBBBBB! |
Nested expressions | [Hello {name}!|cap] |
HELLO WORLD! |
While most filters a) operate on a scalar value and an optional argument and b) return text, filters can technically operate on arbitrary data and return anything—even content to be passed through additional filters. However, this increases your filters' dependency on data structure and introduces more room for runtime errors. get()
-style accessors on your data
can be used to the same effect.
const ctx = {
ageText: person => numberToText(person.age),
text: num => numberToText(num)
};
const data = {
name: 'Bob',
age: 2,
get ageText() {
return numberToText(this.age);
}
};
// Bob is two years old; good solution
sub('{person.name} is {person.age|text} years old.', data, ctx);
// Bob is two years old; passable solution
sub('{person.name} is {person.ageText} years old.', data, ctx);
// Bob is two years old; worst solution
sub('{person.name} is {person|ageText} years old.', data, ctx);
Because subwrite has no understanding of a filter's input, param or return types, it's easy to shoot yourself in the face using a filter that expects an object with a specific structure instead of a scalar value.
So as a general rule: if you need a param or are applying a general-purpose text operation, use a filter. If you don't, an accessor in your data is probably better.
When passed as filter args, numbers and booleans are coerced from strings. Double-quoted strings are passed as literal text.
Unquoted strings are passed as their corresponding value in data
.
Both null
and undefined
values given as params are passed as undefined
to make default params more useful.
Example | Argument value |
---|---|
[foo|bar=1] |
1 |
[foo|bar=true] |
true |
[foo|bar] |
undefined |
[foo|bar=null] |
undefined |
[foo|bar="key"] |
'key' |
[foo|bar=key] |
data[key] |
- control flow, conditional logic
- transclusion
- RNG
- anything else
- Be readable.
- Be writeable.
- Minimize conflicts between syntax and real-world prose.
- Minimize conflicts between syntax and Markdown.
- When in doubt, use a filter.
import sub from 'subwriter';
// properties -> "His name is Bob."
sub(`His name is {name}.`, { name: 'Bob' });
// complex objects
const data = {
PRP$: 'His',
name: {
first: 'Bob',
last: 'Bobbertson',
get full(): string {
return `${this.first} ${this.last}`;
}
}
};
// nested properties, accessors -> "his name is Bob Bobbertson."
sub('{PRP$} name is {name.full}.', data);
// filters!
const filters = {
max: (str, chars) => str.slice(0, chars),
case: (str, method = 'toUpperCase') => str[method]?.()
};
// filters -> "his name is Bo."
sub(`{PRP$} name is {name.first|max=2}.`, data, filters);
// filters with default params -> "His name is Bob."
sub(`{PRP$|case} name is {name.first}`, data, filters);
// chained filters -> "His name is BO."
sub(`{PRP$} name is {name.first|case|max=2}`, data, filters);
class Person {
public glyph = '👨🎨';
public first_name = 'Art';
public last_name = 'Artman';
public gender = 'M';
public get name() {
return `${this.first_name} ${this.last_name}`;
}
}
// "His name is Art Artman. He's the real deal."
sub(
`{👨🎨|PRP$} name is {👨🎨.name}. {👨🎨|PRP}'s the real deal.`,
{ '👨🎨': new Person() },
{
PRP$: person => ({ M: 'His', F: 'Her' }[person.gender] ?? 'Its'),
PRP: person => ({ M: 'He', F: 'She' }[person.gender] ?? 'It')
}
);
A good chunk of my inspiration here came from reading the tutorials on creating new content for the game Wildermyth, though it could be more accurately described as "counter-inspiration."
Some sample text using Wildermyth's text interpolation DSL.
<leader> takes a long, appraising look at <hothead>.
<leader.mf:He/She> wipes a fleck of bluish ooze off <leader.mf:his/her> nose.
And the equivalent using subwriter and emojis.
{🫡} takes a long, appraising look at {😡}.
{🫡|He} wipes a fleck of bluish ooze off {🫡|his} nose.
A more complex example using Wildermyth's "splits":
<leader.goofball/bookish:
Surprise everyone! It's fightin' time!
/Ahem. Our foes appear to have arrived.>
Which resolves to:
Ahem. Our foes appear to have arrived.
And the corresponding subwriter source, which resolves to the same text (after trimming).
[Surprise, everyone! It's fightin' time!|leader="🤪"]
[Ahem. Our foes appear to have arrived.|leader="🤓"]