Projects are bootstrapped with templates, and so before we can talk about evolving project boilerplate, we have to first talk about how templates work.
We can represent the rendering of a template T
into an artifact a
with the equation
a = T(v)
where v
is a data structure representing the input of T
, or in practical terms, the "template variables". For example, we could use the following values to represent the rendering of a simple handlebars template.
T = "Hello {{name}}"
v = { name: "Charles" }
a = "Hello Charles"
Or, in pseudo-function syntax
hbs`Hello {{name}}`({ name: "Charles" }) //=> "Hello Charles"
This is a trivial example, but if the artifact generated by a template T
is a pure function of v
(its variables), then this same simple formulation can be used to represent the rendering of a template of arbitrary size and an input of arbitrary complexity.
By the same token, when we generate the artifact of "boiler plate config" at a project's inception, we do it according to the rendering of a template, and so even though that template may touch many files and be composed of many sub-templates, if it always generates the same artifacts from the same inputs, then it will still follow our original conception of it as an instance of the equation
a = T(v)
At some point however, the template will change. For example, suppose that we want our hello artifact to greet rather quite vigorously instead of the bland salutation of the initial version. We can introduce a new template T'
(pronounced "T prime") to render a new artifact a'
(pronounced "a prime"). Using our template formula, we would represent it like so:
a' = T'(v)
In other words, we get a new artifact, by taking the new template, and using it to render with the old variables.
T' = "Hello {{name}}!!!"
v = { name: "Charles" }
a' = "Hello Charles!!!"
What's interesting about this is that if we persist v
, then that is all we need to generate the new artifact. We don't need the old template, and we don't need the old artifact. We just need the value of the original variables, v
.
Actually, that last statement is an oversimplification. It holds true in the cases where the type (or schema) of the value v
is constant, and so far this has been the case. If we annotate the type of the template variables in our equation, it points the way towards the complication.
a = T(v: V)
We rendered both of the artifacts a
and a'
using the same value, so naturally they had the same form. But what if the template T'
does not take an input as the same type as T
? To use pseudo code, what if the type of the original template function is
interface T {
(variables: V);
}
but the interface of the subsequent template function is
interface T' {
(variables: V');
}
In this case, we can't use our old value v
to render because our new template function takes variables of type V'
, not V
!
Let's make it concreate. What happens if we change our handlebars template to this:
Now, we can't use the same value { name: "Charles" }
which was shaped like
interface V {
name: string;
}
Because now our template needs variables shaped like this
interface V' {
to: string;
}
What we really need to do is to somehow convert v
which into
{ to: "Charles" }
if we can.
How can we convert { name: "World" }
into { to: "World" }?
Well, the simplest thing that could possibly work would be to just use a function.
If we have a function Migrate(v)
such that v' = Migrate(v)
, then anywhere we previously had a value v
of type V
, we can now use it to generate a value v'
of type V'
In our case we could define such a function (in TypeScript pseudo-code):
export function migrate(value: V): V' {
return {
to: value.name
};
}
And then armed with this function and the new template definition, we can render the new artifact with the equation
a' = T'(Migrate(v))
When we think about this migration function what are the tasks that it could be called upon to perform? As I see it, there are two cases. In the first case, there is no human intervention required at all, and that is when the new value can be wholly derived from the existing value. However, this feels as though it would be exceedingly rare. After all, if it can be easily derived from the existing value, then why change things around at all?
This is conjecture, but in all probability, it will be more often the case where new values are introduced. In this case, our migration operation will have to seek input from the user.
If we model the initial setup and generation of project configuration as a migration from zero (where all values must be captured from the user), then we can have the migration process elegantly exapnd the set of template variables in an incremental fashion.
The example above just shows a template function changing once from T
-> T'
. In reality however, template function will change many, many times over the course of its life, and so we don't just have a situation of two functions T
and T'
, but instead a series of functions T1
, T2
, T3
, ...., Tn
But, if we always have a migration function between Tn
and Tn+1
, then we can migrate from any template rendering Tj
-> Tk
(where k is greater than or equal to j) by running the migration functions between them in order on the starting value.
This means that inside the project, we need to store two things:
- the current template variables
- the integer version number of their schema
For example, in our case, we might have a .variables.json
file:
{
"schemaVersion": 1,
"value": {
"name": "World"
}
}
Then, inside the repository where the template is defined, we need not only the template, but how to get from schema version 1 to schema version 2. For example, maybe we have a directory for each version of the template:
templates/greeting/1
- template.hbs
- migrate.ts
/2
- template.hbs
- migrate.ts
This might not be the optimal layout, but the basic idea is that every advance in the variables that the template consumes
However we organize it on disk, this forumation provides an adequate model for updating an artifact based on a newer version of a template. And up until this point, we've been operating on the asumption that the artifact is a pure function of the variables. However, that isn't quite the full story. There is still one challenges remaining, and that is the fact that developers may modify the output artifact in-place and independent from any template rendering. After all, this is what boiler plate is for. You generate it once to get you started, and then you manually tweak it from that point forward?
An example of this is the package.json
file in npm init
. You start with a bunch of variables: name, version, url, author, etc... and then you manually upgrade it from that point forward with npm add --save
, etc.. . How would we say, upgrade the version of @frontside/typescript
but still maintain any changes that you had put into place since creating the project in the first place?
I don't know the answer off the top of my head, but it feels like there are two options:
- extract variables from the current artifact and merge them with other variables.
- take the diff of the current template version rendered and treat it as the input to a second render phase