casid / jte

Secure and speedy templates for Java and Kotlin.
https://jte.gg
Apache License 2.0
819 stars 59 forks source link

Subtemplates? Especially for Tailwind CSS #263

Closed nedtwigg closed 1 year ago

nedtwigg commented 1 year ago

Tailwind CSS is a popular CSS framework. It uses a lot of verbose inline classes rather than custom CSS. Part of the motivation behind Tailwind is that since everyone is rendering HTML from templates, use those HTML templates to do reusable styles instead of CSS. Don't have a button class with a bunch of CSS, have a button HTML template with a bunch of inline styles.

Parts of this work great with JTE, but parts don't. The big thing missing is that it's too verbose to create an entire file for every template. It would help a ton if we could declare smaller, possibly private templates within the bigger JTE template. For example, here's a template we have

@import models.SalesState
@param SalesState state

<div class="mt-6">
    <h2 class="text-base font-semibold leading-7">Recent usage</h2>
    <dl>
        <div class="flex justify-start">
            <dt class="w-[105px] text-sm font-medium leading-6 text-gray-900">Past 7 days:</dt>
            <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 w-[200px] flex justify-between">
                <a href="${state.getUsage().getUrls().get(SalesState.RecentUsage.Period.WEEK)}" class="font-medium text-indigo-600 hover:text-indigo-500">${state.getUsage().getCount().get(SalesState.RecentUsage.Period.WEEK)}</a>
            </dd>
        </div>
        <div class="flex justify-start">
            <dt class="w-[105px] text-sm font-medium leading-6 text-gray-900">Past 28 days:</dt>
            <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 w-[200px] flex justify-between">
                <a href="${state.getUsage().getUrls().get(SalesState.RecentUsage.Period.MONTH)}" class="font-medium text-indigo-600 hover:text-indigo-500">${state.getUsage().getCount().get(SalesState.RecentUsage.Period.MONTH)}</a>
            </dd>
        </div>
        <div class="flex justify-start">
            <dt class="w-[105px] text-sm font-medium leading-6 text-gray-900">Past 365 days:</dt>
            <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 w-[200px] flex justify-between">
                <a href="${state.getUsage().getUrls().get(SalesState.RecentUsage.Period.YEAR)}" class="font-medium text-indigo-600 hover:text-indigo-500">${state.getUsage().getCount().get(SalesState.RecentUsage.Period.YEAR)}</a>
            </dd>
        </div>
    </dl>
</div>

It would be so nice if we could do something like this:

@import models.SalesState

@param SalesState state
<div class="mt-6">
    <h2 class="text-base font-semibold leading-7">Recent usage</h2>
    <dl>
        ${usagePeriod("Past 7 days:", SalesState.RecentUsage.Period.WEEK)}
        ${usagePeriod("Past 28 days:", SalesState.RecentUsage.Period.MONTH)}
        ${usagePeriod("Past 365 days:", SalesState.RecentUsage.Period.YEAR)}
    </dl>
</div>

@private usagePeriod(String header, SalesState.RecentUsage.Period period)
    <div class="flex justify-start">
        <dt class="w-[105px] text-sm font-medium leading-6 text-gray-900">${header}</dt>
        <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 w-[200px] flex justify-between">
            <a href="${state.getUsage().getUrls().get(SalesState.RecentUsage.Period.WEEK)}" class="font-medium text-indigo-600 hover:text-indigo-500">${state.getUsage().getCount().get(period)}</a>
        </dd>
    </div>
@endprivate

It doesn't really matter if it's private or not, and the syntax doesn't matter. But being able to easily add a little "subroutine" inside template files would really be a game changer.

Besides Tailwind, a lot of the new frontend tech (AlpineJS is another one) is perfect for a system like JTE, because they let you build great user experiences from "static" HTML. But they assume that the HTML is being written from e.g. TSX files, where you can easily whip up a quick function that spits out HTML. JTE is very close to being able to do that also...

edward3h commented 1 year ago

I've been using Tailwind with JTE. I use DaisyUI component library. So one thought is using a component library with Tailwind reduces the classes you add within the HTML

Otherwise I've been rendering repeated sections in a loop, so the markup isn't repeated, or else creating a separate template file. It doesn't feel that onerous to me.

nedtwigg commented 1 year ago

I definitely think JTE is the best game in town as far as JVM templating. But if I spend an afternoon in Typescript world, the extra friction in JTE becomes apparent.

There is a loop version of my example, maybe something like

@param SalesState state
<div class="mt-6">
    <h2 class="text-base font-semibold leading-7">Recent usage</h2>
    <dl>
        @for (entry : Map.of(
                "Past 7 days:", SalesState.RecentUsage.Period.WEEK,
                "Past 28 days:", SalesState.RecentUsage.Period.MONTH,
                "Past 365 days:", SalesState.RecentUsage.Period.YEAR).entrySet)
                <div class="flex justify-start">
                    <dt class="w-[105px] text-sm font-medium leading-6 text-gray-900">${entry.getKey()}</dt>
                    <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 w-[200px] flex justify-between">
                    <a href="${state.getUsage().getUrls().get(SalesState.RecentUsage.Period.WEEK)}" class="font-medium text-indigo-600 hover:text-indigo-500">${state.getUsage().getCount().get(entry.getPeriod())}</a>
                </dd>
        @endfor
    </dl>
</div>

but it feels like code golf, and it doesn't work for the general case. It's certainly not a bug that JTE is strictly one-file-per-tempate, but I think it would be a great added feature if you could easily break up a template file with inline subfunctions.

chkl commented 1 year ago

Here's an idea (I haven't use this approach much - so I don't know how convenient it is in practice): Using lambdas to define sub-templates.

Example:

 !{Function<String,Content> component = (s) -> @`<div>${s}</div>`;}

@for(entry: entries)
   ${component.apply("foo")}
@endfor

@nedtwigg : Does this help with your use-case?

nedtwigg commented 1 year ago

Oh, this is an awesome idea, thanks so much @chkl. We tried this and went through two evolutions:

v1: we happen to be using jOOQ, so we already have org.jooq.Function1 (and 2, 3, 4, 5, etc.) so we have a ready-made type for every reasonable function.

v2: we tried switching to kte, and Kotlin's lambda syntax makes it absolute butter

.jte
declare: !{org.jooq.Function2<String, String, gg.jte.Content> li_link = (String href, String content) -> @` ...
usage:   ${li_link.apply(Admin.URL_impersonate, "Stop impersonating")}

.kte
declare: !{val li_link = { href: String, content: String -> @`
usage:   ${li_link(Admin.URL_impersonate, "Stop impersonating")}

With the .jte syntax, it's onerous but doable. With the .kte syntax, I think this issue is totally solved. Thanks a ton!