mason
mason copied to clipboard
[proposal] feat: support brick template inheritance
Proposal: Brick Template Inheritance
Description
As a developer, I want to be able to compose bricks from other bricks so that I can simplify, reuse, and reduce the maintenance cost of bricks.
For example, suppose we are maintaining a Flutter app brick called my_flutter_app
which builds on the standard flutter create
template. Currently, we must duplicate and keep up-to-date the entire application (including iOS
, android
, web
, etc.) which are not specific to our brick. It would be a lot simpler and easier to maintain if it were possible to create a flutter_core
brick and specify that my_flutter_app
extends flutter_core
.
Proposal
I propose adding the ability to have a brick extend
another brick
.
name: my_flutter_app
description: My opinioned started Flutter application
extends: [email protected]
The above brick.yaml
specifies that my_flutter_app
builds on top of the v1.0.0
of the flutter_core
brick.
As a result, when generating my_flutter_app
, mason
will:
- Install
v1.0.0
offlutter_core
- Generate the
flutter_core
brick code - Generate the
my_flutter_app
brick on top offlutter_core
a. Any conflicting files will be overwritten
brick.yaml extends keyword
The extends
value must be of the format:
-
<brick>@v<version>
when referencing a brick hosted in a registry (coming soon).
version
can be one of the following:
-
v<major_version>
- signifying the latest minor version (e.g.v1
~>>= 1.0.0 <2.0.0
>) -
v<major_version>.<minor_version>
- signifying the latest patch version (e.g.v1.1
~>>= 1.1.0 < 1.2.0
>) -
v<major_version>.<minor_version>.<patch_version>
- the exact version (e.g.v1.2.3
~>1.2.3
) -
<git_url>:<path>@<ref>
when referencing a brick hosted in a git repository.
ref
can be one of the following:
- A branch name (e.g.
main
) - A commit hash (e.g.
d45751b
) - A tag name (e.g.
v1
)
Example Uses
# extends hosted brick `flutter_core` version: `1.0.0`
name: my_flutter_app
description: My opinioned started Flutter application
extends: [email protected]
# --------------------------------------------------- #
# extends hosted brick `flutter_core` version: `>=1.0.0 <2.0.0`
name: my_flutter_app
description: My opinioned started Flutter application
extends: flutter_core@v1
# --------------------------------------------------- #
# extends git brick `flutter_core` default branch
name: my_flutter_app
description: My opinioned started Flutter application
extends: https://github.com/felangel/flutter_core
# --------------------------------------------------- #
# extends git brick `flutter_core` release/v1 branch
name: my_flutter_app
description: My opinioned started Flutter application
extends: https://github.com/felangel/flutter_core@release/v1
# --------------------------------------------------- #
# extends git brick `flutter_core` at path `/templates/core` commit d45751b
name: my_flutter_app
description: My opinioned started Flutter application
extends: https://github.com/felangel/flutter_core:templates/core@d45751b
Brick Inheritance Conflict Resolution
Suppose we have:
name: sub_brick
extends: super_brick@v1
If both sub_brick
and super_brick
generate a file with path P
and contents C
and C’
respectively, the outcome of generating sub_brick
will be a file with path P
and contents: C'
.
For example, if super_brick
contains:
├── LICENSE
├── README.md
├── android
│ ├── app
│ │ ├── build.gradle
│ │ └── src
│ ├── app_android.iml
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── local.properties
│ └── settings.gradle
├── ios
│ ├── Flutter
│ │ ├── AppFrameworkInfo.plist
│ │ ├── Debug.xcconfig
│ │ ├── Generated.xcconfig
│ │ ├── Release.xcconfig
│ │ └── flutter_export_environment.sh
│ ├── Runner
│ │ ├── AppDelegate.swift
│ │ ├── Assets.xcassets
│ │ ├── Base.lproj
│ │ ├── GeneratedPluginRegistrant.h
│ │ ├── GeneratedPluginRegistrant.m
│ │ ├── Info.plist
│ │ └── Runner-Bridging-Header.h
│ ├── Runner.xcodeproj
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace
│ │ └── xcshareddata
│ └── Runner.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ └── xcuserdata
├── l10n.yaml
├── lib
│ └── main.dart
├── pubspec.yaml
└── web
├── favicon.png
├── icons
│ ├── Icon-192.png
│ ├── Icon-512.png
│ └── favicon.png
├── index.html
└── manifest.json
And if sub_brick
contains:
├── LICENSE
├── README.md
├── lib
│ ├── counter
│ │ ├── counter.dart
│ │ ├── cubit
│ │ └── view
│ └── main.dart
└── pubspec.yaml
The result of generating sub_brick
would be:
├── LICENSE **
├── README.md **
├── android
│ ├── app
│ │ ├── build.gradle
│ │ └── src
│ ├── app_android.iml
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── local.properties
│ └── settings.gradle
├── ios
│ ├── Flutter
│ │ ├── AppFrameworkInfo.plist
│ │ ├── Debug.xcconfig
│ │ ├── Generated.xcconfig
│ │ ├── Release.xcconfig
│ │ └── flutter_export_environment.sh
│ ├── Runner
│ │ ├── AppDelegate.swift
│ │ ├── Assets.xcassets
│ │ ├── Base.lproj
│ │ ├── GeneratedPluginRegistrant.h
│ │ ├── GeneratedPluginRegistrant.m
│ │ ├── Info.plist
│ │ └── Runner-Bridging-Header.h
│ ├── Runner.xcodeproj
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace
│ │ └── xcshareddata
│ └── Runner.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ └── xcuserdata
├── l10n.yaml
├── lib
│ ├── counter **
│ │ ├── counter.dart **
│ │ ├── cubit **
│ │ └── view **
│ └── main.dart **
├── pubspec.yaml **
└── web
├── favicon.png
├── icons
│ ├── Icon-192.png
│ ├── Icon-512.png
│ └── favicon.png
├── index.html
└── manifest.json
Where files marked with **
came from sub_brick
.
Replaceable Blocks
A file in sub_brick
can reference the contents of the conflicting file in the super_brick
via {{<super}}
.
In the above example if the contents of main.dart
in super_brick
are:
void main() {
{{$content}}print('Hello Default!');{{/content}}
print('goodbye');
}
And the contents of main.dart
in sub_brick
are:
{{<super}}
{{$content}}print('Hello Dash!');{{/content}}
{{/super}}
void foo() => return 'bar';
The resulting main.dart
would look like:
void main() {
print('Hello Dash!');
print('goodbye');
}
void foo() => return 'bar';
Variable Resolution
When a brick B
which requires variables {x, y}, extends brick A
which requires variables {x, z}, variables {x, y, z} are required when generating brick B
. The variables can still either be passed via args: mason make B --x <X> --y <Y> --z <Z>
, via a configuration file, via commandline prompt.
Hook Execution
When a brick B
which has a pre_gen
and post_gen
hook extends brick A
which also has a pre_gen
and post_gen
hook, the result of generating brick B
is:
- run
pre_gen
A - generate brick A
- run
post_gen
A - run
pre_gen
B - generate brick B
- run
post_gen
B
References / Inspiration
- https://docs.docker.com/engine/reference/builder/#from
- https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsuses
- https://groups.google.com/g/mustachejava/c/2z_73dJIAO4
- https://github.com/mustache/spec/issues/38
- https://gist.github.com/spullara/1854699
Hi @felangel
The proposal looks great! This definitely fits my needs for at_app. One thought would be on handling variable inheritance:
Let's say that super_brick
has variable foo
.
In my sub_brick
, is there a way to define a value for foo
without requiring it as an input variable when generating the sub_brick
template? As in, can I define a value for foo
within the sub_brick
template, rather than requiring input for it?
Like in the classic example:
class Shape {
int sides;
Shape(this.sides);
}
class Square extends Shape {
Square() : super(4); /// Don't need to pass [sides] to create a [Square]
}
Hi @XavierChanth 👋
The proposal looks great! This definitely fits my needs for at_app.
That's great to hear 🚀
One thought would be on handling variable inheritance:
Let's say that super_brick has variable foo. In my sub_brick, is there a way to define a value for foo without requiring it as an input variable when generating the sub_brick template? As in, can I define a value for foo within the sub_brick template, rather than requiring input for it?
One way to address that would be to use reusable blocks in the base template. For example if we had main.dart
in the super_brick:
void main() {
print('Hello {{name}}');
}
We could instead rewrite it to be configurable:
void main() {
print('Hello {{$content}}{{name}}{{/content}}');
}
Then in sub_brick's main.dart
we can do:
{{<super}}
{{$content}}Felix{{/content}}
{{/super}}
Which would result in:
void main() {
print('Hello Felix');
}
Another option would be to introduce a pre_prompt
hook which executes prior to prompting for variables which would allow us to inject a default value:
// pre_prompt.dart
import 'package:mason/mason.dart';
void run(HookContext context) {
context.vars = {'name': 'Felix'};
}
Let me know what you think and thanks again for the feedback!
Looking forward to this feature.
I am most excited about the brick registry as I have had to depend on github to source control my brick templates. The ability to choose a specific template version is GOLD! One can now advance a brick without breaking other bricks depending on it
One way to address that would be to use reusable blocks in the base template.
I would prefer this feature, but I think both are a candidate depending on the use case.
Hey @felangel 👋
So excited to see this feature! Just a few quick clarifying q's I thought of:
-
When a brick B which requires variables {x, y}, extends brick A which requires variables {x, z}, variables {x, y, z} are required when generating brick B.
What happens when two bricks share the same variable name (in this case x), but have different types associated with it (ie brick A it's a string, and brick B it's a boolean)? Imo, that should be an error at mason make
time, but could be persuaded otherwise. On a similar note, who's prompt is chosen if a custom prompt is defined? I would expect B's prompt since it's the most specific I guess, but could totally see other sides.
-
I love the idea of chaining hooks like that! I may make templates just for their hooks lol. My q is, when chaining those hooks, will they share a HookContext? Mainly I'm curious if the vars from the super brick will be passed into the context in the sub bricks. I think it'd be sweet if they could!
-
On the note of hook execution, if you had a brick
C
that extendsB
andB
extendsA
, would the execution be:
- A pre_hook
- Gen A
- A post_hook
- B pre_hook
- Gen B
- B post_hook
- C pre_hook
- Gen C
- C post_hook
That's what I'd expect, so I'm just curious.
Other than those qs, everything made sense to me and felt very natural. I do think some of the features using the {{super}} prop as well as hook execution should be well documented as they seem pretty precise, but they make sense if you think it through, and it really only affects the brick makers and not people using bricks. Especially when the repository is all set up, I suspect most people will use standard bricks from there and not have to worry about those specifics 🚀
Very exciting stuff! It feels like mason is turning into a very powerful tool for automating a ton of repetitive dev tasks, which is super cool. 💯 Thanks Felix!
This looks very promising! I really like the proposal!
I wonder if besides {{<super}}
perhaps having the concept of imports
and sections
could be a more flexible approach. Similar to include
in Blade Template, or also to template
and macros
in dart doc.
Besides that, I think the "Hook Execution" feels natural.
In addition, I think in some scenarios, it would also be nice to have the option on how to perform the Conflict Resolution.
Hope the feedback helps!
I love the idea of extending bricks!
one question:
will having a subbrick allow me to import code from the parent brick? the reason I ask is that I might want to have 1 meta brick as a parent that hosts some reusable code for the subbricks. i.e:
core
meta-brick
-- page
subbrick
-- useCase
subbrick
and both page
's and useCase
's post_gen.dart
hooks would need to replace text in the same file, but in different places, thus I'd have a method in core
:
Future<void> replaceContententsInRegistry(String from, String to) {
...
}
It would be awesome to be able to reference that method from the core brick in the page
and useCase
subbricks
I love the idea of extending bricks!
one question:
will having a subbrick allow me to import code from the parent brick? the reason I ask is that I might want to have 1 meta brick as a parent that hosts some reusable code for the subbricks. i.e:
core
meta-brick --page
subbrick --useCase
subbrickand both
page
's anduseCase
'spost_gen.dart
hooks would need to replace text in the same file, but in different places, thus I'd have a method incore
:Future<void> replaceContententsInRegistry(String from, String to) { ... }
It would be awesome to be able to reference that method from the core brick in the
page
anduseCase
subbricks
You should be able to achieve this by extracting the common code into a package and installing the package as a dependency for both hooks either via git or pub.dev