robb / Swim

A DSL for writing HTML in Swift
310 stars 9 forks source link

[WIP] Preferences #29

Open chriseidhof opened 3 years ago

chriseidhof commented 3 years ago

I'm not sure if this is a good idea. But this is one way you could implement preferences. I would love to have this supported, but it also complicates everything (it's easy to add a feature like this, but hard to remove).

I'm also not sure if this is the best way to implement this, the visitor could also return an optional and then readPreference could do nil-coalescing.

robb commented 3 years ago

Yeah, I think this is a worth-wile problem to solve.

In my website, I used a dependency filter that adds a custom tag.

Basically, you have markup that read like this:

<html>
    <head></head>
    <body>
        <custom-script-dependency src="/foo.js" async="true">
    </body>
</html>

that get's rewritten using two Visitors to

<html>
    <head>
        <script src="/foo.js" async></script>
    </head>
    <body></body>
</html>

I like the simplicity of keeping everything in Node-space. Missing the preprocessing step is also relatively simple since there's always an inspectable output and no "invisible nodes" that don't effect the outcome.

I wonder if we could mix these approaches where preferences on a Node get merged into the attributes property but are all prefixed with swim-, so maybe something like

let body = article {
    "Lorem ipsum dolor sit amet."
}
.preference(ScriptDependencyKey.self, ScriptDependency(src: "/foo.js", async: true))

would, assuming it's not filtered out by default in a pre-processing-step, turn into

<article swim-script-dependency="{src: &quot:foo.js&quot:, async: &quot:true&quot:}">
    Lorem ipsum dolor sit amet.
</article>

Not sure if purging these attributes by rewriting the notes is better than suppressing them in rendering.

In #28 I played around with relaxing the requirements for attribute values to AnyHashable and it seems to be pretty smooth sailing, only requiring updates to visitor implementations.

That said, I'm also open to saying we need a Sail library that adds a View-like protocol (that Node could also conform to) that introduces Preferences and an Environment and that this is beyond the scope of Swim.

chriseidhof commented 3 years ago

Yes, if we want to keep things separate we'd probably also need a separate result builder. Also not sure how it would work with things like the built-in HTML tags. What would be the type of the child nodes?

robb commented 3 years ago

I was thinking something like this:

protocol Component {
    @ComponentBuilder
    var body: some Component

    func render() -> Node
}

extension Component {
    func render() -> Node {
        body.render()
    }
}

extension Node: Component {
    @ComponentBuilder
    var body: Node { self }

    func render() -> Node {
        self
    }
}

struct TabBar: Component {
    var tabs: [Tab]

    @ComponentBuilder
    var body: some Component {
        ul {
            tabs.map { tab in
                li {
                    tab
                }
            }
        }
        // Hypothetical Component-modifier wrapper around adding a dependency
        // using `DependencyPreferenceKey` that reduces by adding to a set.
        .dependency(src: "/tabs.js", async: false)
    }
}

struct Page<Content>: Component where Content: Component {
    var content: Content

    @ComponentBuilder
    var body: some Component {
        html {
            head {
                // Hypothetical wrapper around reading all dependencies using
                // `DependencyPreferenceKey`.
                content.dependencies.map { dependency in
                    script(src: dependency.src, async: dependency.async)
                }
            }
            body {
                content
            }
        }
    }
}

Might be missing something obvious that makes this unworkable tho?

chriseidhof commented 3 years ago

I think this could work! I'll also play around with this approach, hopefully I'll find some time tomorrow.

robb commented 3 years ago

I guess I handwaived the preferences, but I think they could work something like this?

struct PreferenceWriter<Content>: Component where Content: Component {
    var content: Content

    var preferences: [String: AnyHashable]

    @ComponentBuilder
    var body: some Component {
        content
    }

    func render() -> Node {
        // Produces something like <sail-preference for="bar" /> if rendered by
        // mistake but should usually be stripped.
        //
        // Putting prefixed attributes on `content.rendered()` would be nicer
        // but it could be a `Node.text` or `Node.trim` where that wouldn't
        // work.
        PreferenceTag(preferences: preferences)

        content.rendered()
    }
}

extension Component {
    var preferences: [String: AnyHashable] {
        let rendered = render()

        // Finds every `PreferenceTag`, merges preferences
        let visitor = PreferenceVisitor(content: rendered)

        return visitor.preferences
    }
}

This is a bit inefficient since we'd be visiting every Node but it avoids introducing a parallel parent-child relationship between Components, hmm.