twostraws / Ignite

A static site generator for Swift developers.
MIT License
986 stars 34 forks source link

Feature Request: `.environment(…)` modifier or similar hierarchical information passing #2

Open fbartho opened 1 month ago

fbartho commented 1 month ago

Please let me know if there's a way to do this already and I just missed it!

Purpose

I'd like to build components that render differently depending on their position in the Element Hierarchy.

Some concrete examples:

Example 1: Dynamic Header (Rank)

(Virtual HTML-style DOM tree)

<SectionWithHeader title="Outer" >
  <SectionWithHeader title="1.1" />
  <SectionWithHeader title="1.2">
    <SectionWithHeader title="1.2.1" />
    <SectionWithHeader title="1.2.2" />
   </SectionWithHeader> 
</SectionWithHeader>

In this example, the hypothetical SectionWithHeader component would use the .environment()-context to help generate the following html:

<div>
  <h1>Outer</h1>
  <div>
    <h2>1.1</h2>
  </div>
  <div>
    <h2>1.2</h2>
    <div>
      <h3>1.2.1</h3>
    </div>
    <div>
      <h3>1.2.2</h3>
    </div>
</div>

… but only if it can keep track of how deep the component is within the tree.

Notice: you could implement this component to automatically provide data for a statically generated Outline, with automatic anchor-links to each heading

Example 2: Dynamic Environmental Overrides

In various past projects, I've found it useful to be able to preview a component, but rendered side-by-side with different contexts. One example is simultaneously viewing light/dark themes of the same component.

<ThemeTester>
 …my content here.
</ThemeTester>

Output:

<div>
 <b>Light Mode:</b> 
  …my content here.
</div>
<div>
 <b>Dark Mode:</b>
  …my content here.
</div>

Related use-cases:

Some browsers have some of these as a tools at the browser/debugger level, but I've found those to be pretty inconvenient.

Suggested Solution

If we implement something like the SwiftUI's Environment modifier or React's Context then we can provide necessary data for implementing these use cases above, and plenty of others not listed.

Alternatives Considered

twostraws commented 1 month ago

This is a big proposal, so I'd be keen to see it split up. If we could start with something like an empty environment that developers could place things into, that would be a big step forward. A logical next step (for me) would be to read publishing data from that environment, e.g. site details, article details, etc – like using @Query with SwiftData, for example. The next step (again, in my eyes!) would be to add in there environment modifiers, such as letter font() be applied anywhere and it have flow down the environment. And so on… lots of smaller steps working towards a bigger goal, but each one landing separately so we have time to evaluate them individually.

How does that sound?

fbartho commented 1 month ago

That definitely makes sense. I’ll proceed with attempting just the first step for now, and then other PRs can discuss/bikeshed SiteContext, PageContext, ContentContext or other “Default Environmental Info”

fbartho commented 1 month ago

Okay, after sleeping on it, I have an idea how this could be implemented:

@UsesEnvironment
struct SomeComponent: BaseElement {
  @Environment(\.someValueKey) var value: MyType

  func render(context: RenderContext) -> String {
    …
  }
}
  1. @Environment would be a class-based property wrapper. (Class-based so the element as a whole still is a struct, but the property can still be mutated)
  2. @UsesEnvironment would be a macro that wraps render with a call to self.willRender(context: RenderContext)
  3. willRender is to be implemented in an extension on BaseElement. It uses reflection to find all properties on the element tagged with @Environment and injects the RenderContext
  4. RenderContext should be a struct containing PublishingContext, and something like environmentValues: EnvironmentValues where struct EnvironmentValues has an internal var storage: [WritableKeyPath: Any]. This is the only breaking change, but it affects all Elements
  5. When used, the .environment(…) modifier creates a mutated copy of EnvironmentValues that it passes down to any nested renders.

All names are placeholders please suggest alternates! I don’t love introducing a macro, but that’s the only way I could see to provide an ambient, but non-global value to the property-wrappers.

What do you think @twostraws?

twostraws commented 1 month ago

I think I need to investigate this further. Bringing macros into the equation is something I'd rather avoid if possible.

fbartho commented 1 month ago

We don’t need a macro.

If we make the change for RenderContext, then we have the right location for a hierarchical store of data. The problem is that the @Environment property-wrapper doesn’t have a clean way to access the render-context without injecting that render-context explicitly at some point so the macro was one way to solve that. We could alternatively say that in Ignite, Environment fields are accessed by doing something like context.environment.myField but that’s obviously clunkier (+open questions about strong typing).


Because each Element is responsible for calling the render method of its children there’s no place for the Framework to hook behavior in between render layers. — This is actually a difference between Ignite & SwiftUI — SwiftUI’s body property is of type some View while Ignite returns string.

If Ignite’s tree was instead returning Element and it got turned into string inside the framework then the framework could do lifecycle things in between each render. Potentially this would be helpful for allowing some components to be async-rendered #3 while others are sync.

Mcrich23 commented 1 month ago

I am very excited to see how this plays out and is implemented because I think this is critical for proper mobile optimization (#7). Right now I am adding custom inline css with ternary operators based on the width of the page. It's really an ugly work around.

twostraws commented 4 weeks ago

This is an unrelated issue – this is a build-time environment rather than a run-time environment. The problem with the logo forcing a navigation bar wrap is a CSS issue, and should be resolved with a flexible size. I've updated the IgniteSamples repository with an example of how this is done; it should now adjust smoothly smaller than even the smallest iPhone in portrait.

Mcrich23 commented 4 weeks ago

Ah, I must have misread the conversation then. My bad.

piotrekjeremicz commented 3 weeks ago

Hey everybody!

I have prepared an implementation of @Environment under the #28 Pull Request . Take a look to see if it is what you are looking for. Tomorrow I will conduct tests and submit PR for review.

This implementation provides EnvironmentValues that handle global environment variables. This can be extended the same way we do in SwiftUI. Following the implementation of the original, @Environment you can only read the value. There is no possibility to set an environment value outside the package. All value setters take place in the package internally.