Render an Object as a String in Final Template but Pass the Full Object to Tags
ErikTheBerik opened this issue · 4 comments
I’m currently using LiquidJS and I’m trying to implement the following behavior:
- I want to pass an object to a custom tag and receive the full object in the tag.
- If the same object is rendered in the final template (i.e., without being passed to a tag), I want to retrieve a string by looking up data from a database and returning a specific value.
Example Scenario:
Let’s say I have an object structured like this:
{
"Value": "12345",
"Type": "user"
}
- If this object is passed to a custom tag, I expect the tag to receive the entire object so that I can use the Value and Type inside the tag function.
- However, if this object is directly rendered in the template (i.e., via
{{ object }}
), I want it to trigger a lookup in the database (in this case, using the Value field to find the firstName and lastName of the user) and return a string with the user's name.
Specific Questions:
Is it possible to achieve this behaviour using Drops and toLiquid() and/or valueOf() in LiquidJS?
- I tried returning an object in the toLiquid() function, but the only way that worked was by defining a key. So if I defined name to be the function that does the db lookup, then when rendering
{{ object.name }}
it worked. But I want to have this behaviour without having to add ".name" and simply have{{ object }}
. - What happens when an object is set using the
{% capture %}
tag? If I use the{% capture %}
tag to store this object in a variable, will the captured variable hold the entire object (with its Value and Type fields), or will it contain the rendered string (the user's name after the database lookup)?
{% capture userVar %}
{{ object }}
{% endcapture %}
Would userVar hold the object or the rendered string (e.g., "John Doe")?
A drop with a valueOf()
method should do what you want. For example.
import { Liquid, Tag, Drop, evalToken, Tokenizer } from "liquidjs";
class MyDrop extends Drop {
constructor(obj) {
super();
this.value = obj.Value;
this.type = obj.Type;
this.name = undefined;
}
lookupName() {
if (this.name === undefined) {
// TODO: database lookup here
let data = { firstName: "foo", lastName: "bar" };
this.name = `${data.firstName} ${data.lastName}`;
}
return this.name;
}
valueOf() {
return this.lookupName();
}
}
class MyTag extends Tag {
constructor(token, remainTokens, liquid) {
super(token, remainTokens, liquid);
const tokenizer = new Tokenizer(token.args);
// In this example, arguments could be a mix of literals and variables.
this.arguments = [];
while (!tokenizer.end()) {
this.arguments.push(tokenizer.readValue());
}
}
*render(ctx, emitter) {
for (const arg of this.arguments) {
let obj = yield evalToken(arg, ctx);
if (obj instanceof MyDrop) {
// Access to drop properties here.
// Outputting as a JSON string for demonstration purposes.
emitter.write(JSON.stringify({ value: obj.value, type: obj.type }));
}
}
}
}
const liquid = new Liquid();
liquid.registerTag("mytag", MyTag);
const exampleTemplate = `\
Hello, {{ someDrop }}!
{% mytag someDrop %}`;
const someObject = {
Value: "12345",
Type: "user",
};
// Wrap plain object in a drop
const someDrop = new MyDrop(someObject);
liquid.parseAndRender(exampleTemplate, { someDrop }).then(console.log);
Output
Hello, Foo Bar!
{"value":"12345","type":"user"}
And captured values are always rendered strings..
// continued from above
const captureExampleTemplate = `\
{% capture foo %}
Hello, {{ someDrop }}!
{% mytag someDrop %}
{% endcapture %}
{{foo}}`;
liquid.parseAndRender(captureExampleTemplate, { someDrop }).then(console.log);
Output
Hello, Foo Bar!
{"value":"12345","type":"user"}
I confused valueOf and toLiquid when testing this in my code before posting the issue. Now it works as expected.
Since captured values are always rendered as strings (which makes sense) how could I set a variable to be what a tag function returns?
The only way I can think of doing this is to automatically set the variable in the tag function. I can't really do:
{% assign my_variable = some_function param1 param2 %}
Instead I'm thinking about doing something like:
{% some_function param1 param2 set:my_variable %}
And internally either set the variable or return the value depending on wether "set" is there or not. Is there a better way of doing this? (Can I even do that?)
Instead I'm thinking about doing something like:
{% some_function param1 param2 set:my_variable %}
That seems quite reasonable to me. The standard {% cycle %}
does something similar. You'd probably need to use Tokenizer.readValue()
for the first two parameters. Then Tokenizer.peek()
and Tokenizer.readNonEmptyIdentifier()
to parse and validate the optional named parameter. Like this ..
class MyTag extends Tag {
constructor(token, remainTokens, liquid) {
super(token, remainTokens, liquid);
const tokenizer = new Tokenizer(token.args);
// NOTE: param1 and param2 could be undefined here
this.param1 = tokenizer.readValue();
tokenizer.skipBlank();
this.param2 = tokenizer.readValue();
tokenizer.skipBlank();
const optionName = tokenizer.readNonEmptyIdentifier()?.content;
tokenizer.assert(
optionName === undefined || optionName === "set",
`expected option 'set', found '${optionName}'`
);
if (optionName !== undefined) {
tokenizer.skipBlank();
tokenizer.assert(tokenizer.peek() === ":");
++tokenizer.p; // Move past colon
tokenizer.skipBlank();
this.option = tokenizer.readNonEmptyIdentifier();
tokenizer.assert(
this.option !== undefined,
"expected option 'set' to be passed an identifier"
);
} else {
this.option = undefined;
}
}
*render(ctx, emitter) {
const obj1 = yield evalToken(this.param1, ctx);
const obj2 = yield evalToken(this.param2, ctx);
// TODO: something with obj1 and obj2
if (this.option !== undefined) {
const variableName = yield evalToken(this.option, ctx);
ctx.environments[variableName] = "some value";
}
}
}
That is perfect! Thank you both for the help, this also lets me see how things are done in a more standard way.
Things like tokenizer.assert(xxx)
or tokenizer.skipBlank()
I will close the issue now