felangel / mason

Tools which allow developers to create and consume reusable templates called bricks.
https://docs.brickhub.dev
931 stars 92 forks source link

[proposal] feat: support brick template inheritance #215

Open felangel opened 2 years ago

felangel commented 2 years ago

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: flutter_core@v1.0.0

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:

  1. Install v1.0.0 of flutter_core
  2. Generate the flutter_core brick code
  3. Generate the my_flutter_app brick on top of flutter_core a. Any conflicting files will be overwritten

brick.yaml extends keyword

The extends value must be of the format:

version can be one of the following:

ref can be one of the following:

Example Uses

# extends hosted brick `flutter_core` version: `1.0.0`
name: my_flutter_app
description: My opinioned started Flutter application
extends: flutter_core@v1.0.0

# --------------------------------------------------- #

# 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:

  1. run pre_gen A
  2. generate brick A
  3. run post_gen A
  4. run pre_gen B
  5. generate brick B
  6. run post_gen B

References / Inspiration

XavierChanth commented 2 years ago

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]
}
felangel commented 2 years ago

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!

ignertic commented 2 years ago

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

XavierChanth commented 2 years ago

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.

mtwichel commented 2 years ago

Hey @felangel 👋

So excited to see this feature! Just a few quick clarifying q's I thought of:

  1. 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.

  1. 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!

  2. On the note of hook execution, if you had a brick C that extends B and B extends A, 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!

alestiago commented 2 years ago

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!

andrzejchm commented 1 year ago

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

felangel commented 1 year ago

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

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

SAGARSURI commented 1 month ago

Looking forward to this feature!