- Kotlin 86.7%
- JavaScript 9.1%
- CSS 4%
- HTML 0.2%
|
All checks were successful
Publish Package (quill) TeamCity build finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| gradle | ||
| quill-gradle-plugin | ||
| quill-preview | ||
| quill-preview-server | ||
| quill-runtime | ||
| quill-tailwind | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
Quill
Quill turns KMX templates into mails. Each template is one .kmx file per locale that renders a Kotlin payload class; kotlinc checks it like any other code, so a misspelled property or an unchecked nullable fails the build. Quill adds what a mail needs around the template engine: metadata (sender, subject), locales, environments, an XHTML body and a plain-text body from the same template, and helpers for formatting and mail-safe markup.
class InvoicePaidParam(
val handle: String,
val lines: List<InvoiceLineParam>,
val total: Long,
val discount: String?,
)
class InvoiceLineParam(val description: String, val amount: Long)
// src/main/quill/templates/invoice_paid/en_us.kmx
import com.example.mails.InvoicePaidParam
props: InvoicePaidParam
<EmailDocument title="Receipt" preheader="Thanks, {handle}">
<p>Hi {handle},</p>
<ul>
@for (line in lines) {
<li>{line.description}: {formatMoney(line.amount, "USD")}</li>
}
</ul>
@if (discount != null) { <p>Discount: -{discount}</p> }
<p>Total: {formatMoney(total, "USD")}</p>
<Button href="https://{env.SITE}/invoices">See invoices</Button>
</EmailDocument>
val renderer = QuillRenderer.load(EmailTemplates, environment = "prod")
val mail = renderer.render(EmailTemplates.INVOICE_PAID, "en_us", param, ZoneId.of("UTC"))
mail.metadata.subject
mail.html // XHTML
mail.text // plain text, derived from the same tree
Setup
Quill and KMX publish to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to the plugin and dependency repositories, then apply the plugin:
plugins {
kotlin("jvm") version "2.4.10"
id("xyz.nillpoe.quill") version "2.2.0"
}
quill {
registry = "com.example.mails.EmailTemplates"
locales = listOf("ko_kr", "en_us")
}
The plugin adds quill-runtime (which brings kmx-runtime) to api, or implementation without java-library. It also applies the KMX plugin, so components shared by several templates can live in src/main/kmx.
| Setting | Meaning | Default |
|---|---|---|
registry |
Fully qualified name of the generated object | required |
locales |
Locales every template exists in, as in the file names | required |
directory |
Root of templates/ and environments/ |
src/main/quill |
addRuntimeDependency |
Adds quill-runtime |
true |
previews |
Fully qualified name of the previews object in the test sources. Adds quill-preview to testImplementation, quillCheck to check, and enables quillGallery and quillServe |
none |
Files
src/main/quill/
├── tailwind.properties optional: turns `class` into inline styles
├── environments/
│ ├── dev.properties SITE=example.dev
│ └── prod.properties SITE=example.com
└── templates/
└── invoice_paid/
├── en_us.kmx template, required
└── en_us.json metadata, required
src/main/kmx/
└── com/example/mails/ui/footer.kmx shared components, compiled by the KMX plugin
<id>/<locale>.kmx starts with props: PayloadClass. Every locale of an id must name the same class, written the same way. The file has no package line: Quill compiles it into the registry's package as an internal object.
<id>/<locale>.json holds the sender, optional reply-to and subject:
{"sender": {"name": "Example", "address": "noreply@example.com"}, "subject": "Your receipt from ${SITE}"}
In templates
Quill templates import xyz.nillpoe.quill.template.* automatically. Everything else is KMX; see its README for the syntax.
| Name | What it gives |
|---|---|
env.KEY |
The value of KEY in the environment being rendered |
locale, zone |
The template's Locale (from the file name) and the ZoneId passed to render |
formatNumber(value, pattern?) |
12,345.5, or with a DecimalFormat pattern |
formatMoney(amount, "KRW") |
₩12,000, $12.00: the locale's currency format |
formatDate(value, "yyyy.MM.dd"), formatDate(value, FormatStyle.LONG), formatDateTime(value) |
Dates in the locale and zone |
<EmailDocument title preheader lang> |
<html> with the XHTML namespace, head, and a hidden preheader |
<Button href background color> |
A table-based button that renders in Outlook |
<Table class width> |
A layout <table>: role="presentation", no border, padding or spacing, collapsed borders |
cx("px-4", extra) |
The classes that are not null or blank, separated by spaces |
<Mso> |
<!--[if mso]>...<![endif]--> |
<HtmlOnly>, <TextOnly> |
Content for one body only |
Values a render needs besides the payload go through the KMX render scope: declare a RenderKey, pass RenderScope.of(key, value) to render, and read scope[key] in the template.
Tailwind
Mail clients read styles only from style attributes, reliably. With a tailwind.properties file in the Quill directory, templates and components write Tailwind classes instead, and every render turns them into inline styles:
<td class="px-4 py-3.5 border border-line">
<div class="text-xs/[18px] text-muted">Capacity</div>
<div class="pt-0.5 text-xl font-semibold">{capacity} h</div>
</td>
<td style="border:1px solid #eeeeee;padding:14px 16px;">
<div style="font-size:12px;line-height:18px;color:#898884;">Capacity</div>
<div style="font-size:20px;line-height:28px;font-weight:600;padding-top:2px;">12 h</div>
</td>
The file adds to Tailwind's default theme, or replaces its entries; it can be empty:
color.muted = #898884
color.line = #eeeeee
fontSize.xs = 12px/18px
fontFamily.sans = -apple-system, 'Segoe UI', Roboto, Arial, sans-serif
spacing.gutter = 24px
screen.sm = 600px
Quill knows the Tailwind utilities mail clients support: padding and margin, width and height, font size, weight, family, line height and letter spacing, text color, alignment, decoration and transform, background color, borders and radii, display, vertical alignment, white space, overflow, object fit, opacity and table layout. Each takes arbitrary values (p-[14px_16px], text-[#898884], text-[13px]/5), colors take an opacity (text-black/50), and ! makes a class !important. Lengths are pixels and colors hex or rgba(), because many clients ignore rem and CSS variables. Classes resolve as Tailwind's stylesheet orders them, so p-4 pt-2 and pt-2 p-4 both give padding:8px 16px 16px.
The element's own style comes after the classes and wins over them.
Classes with a variant need a stylesheet: sm: and xs: apply to screens up to 600px and 430px wide (desktop first, as clients without media queries show the inline styles), dark: in dark mode, and hover: under the pointer. Such an element keeps a class for each, and their rules go into a <style> at the end of <head>, !important to win over the inline styles.
A component takes classes as a class parameter, which KMX declares in backticks:
component Row(class: String? = null, children: Node)
<tr><td class={cx("pt-[18px]", `class`)}>{children}</td></tr>
A class Quill does not know fails the build when a template or a component writes it (generateQuillSources names the file, line and column), and the render when an expression builds it, which quillCheck reports. Without tailwind.properties, classes are written as they are.
Environments
Each environments/<name>.properties is one environment, and every environment must define the same keys. The plugin generates EmailTemplates.Environment with one String property per key, so templates read {env.SITE} and a misspelled key fails the build. Metadata files use ${SITE}, replaced when the renderer loads.
QuillRenderer.load(registry, "<name>") reads that environment and the metadata of every template, and throws one exception listing every missing or broken file.
Output
html is XHTML written as XML: well-formed, escaped, with the XHTML 1.0 Transitional doctype when the root is <html>. Only void elements such as <br/> and <img/> self-close, so HTML parsers in mail clients read it correctly.
text comes from the same tree: paragraphs, headings and tables become blocks, list items get - or numbers, links become label (href), images their alt, and whitespace collapses. <HtmlOnly> parts are left out, <TextOnly> parts added.
mail.node is the rendered tree with the classes inlined, for other outputs. renderer.inline(node) inlines the classes of a component rendered in renderer.scope(...).
The registry
For registry = "com.example.mails.EmailTemplates" the plugin generates:
object EmailTemplates : TemplateRegistry<EmailTemplates.Environment> {
val INVOICE_PAID: TemplateKey<InvoicePaidParam>
// one property per template directory, the id in upper case
override val entries: List<TemplateKey<*>> // sorted by id
override val locales: List<String>
override val environmentKey: RenderKey<Environment>
class Environment(val SITE: String)
}
What the build checks
generateQuillSources fails with every problem at once, each with its file and, for syntax errors, line and column:
- Every id has
<locale>.kmxand<locale>.jsonfor each locale, and no files for other locales. - Ids are lowercase letters, digits and
_, starting with a letter. - Each template parses as KMX and declares
props: PayloadClass, the same in every locale. - Every environment defines the same keys, usable as Kotlin names.
- With
tailwind.properties, its entries are valid, and every class the templates and the components insrc/main/kmxwrite is one Quill knows. .ftland.ftlhfiles are reported, to finish a migration from Quill 1.
Then kotlinc checks the generated code: unknown properties, missing null checks ({note} for a String? does not compile; write {note ?: ""} or @if (note != null)), wrong types, and a locale rendering a different payload class.
Previews
Previews are sample payloads of the templates and sample calls of the components, declared in an object in the test sources:
// src/test/kotlin/com/example/mails/MailPreviews.kt
object MailPreviews : Previews<EmailTemplates.Environment>(EmailTemplates, environment = "dev") {
val paid = preview(EmailTemplates.INVOICE_PAID, "Paid") {
InvoicePaidParam("ada", listOf(InvoiceLineParam("Pro plan", 12_000)), total = 12_000, discount = null)
}
val manyLines = paid.pin("lines = 200 items")
val button = component("Button", "Primary") {
Button(href = "https://example.com", children = markup { text("Pay") })
}
}
quill {
previews = "com.example.mails.MailPreviews"
}
A template preview renders in every locale unless locales names some. A component preview runs in the scope a template of its locale renders in. zone, lint and fuzz can be overridden in the object.
./gradlew quillCheck, which check runs, renders every preview, lints it and fuzzes its payload, and fails on errors. ./gradlew quillGallery writes them all to build/quill/gallery, and ./gradlew quillServe serves them live. All three run on the test runtime classpath. PreviewCheck(MailPreviews).run() runs the same checks from a test.
Lint
Lint walks the rendered tree, not the HTML text. Every element knows the template line that wrote it, or the line that called the component that built it, so findings point at the template:
e: src/main/quill/templates/invoice_paid/en_us.kmx:22:9 img-alt: `<img>` has no `alt`. Describe the image, or write `alt=""` if it is decoration. (invoice_paid "Paid" en_us)
w: src/main/quill/templates/invoice_paid/en_us.kmx:14:5 css-support: display:flex is not supported in Outlook for Windows. https://www.caniemail.com/features/css-display-flex/ (invoice_paid "Paid" en_us)
| Rule | Reports | Severity |
|---|---|---|
css-support |
CSS properties and values, elements, attributes and image formats that a client of clients does not support, from caniemail.com data bundled with quill-preview |
warning |
img-alt |
<img> without alt |
error |
contrast |
Text below the WCAG AA contrast ratio against its background: 4.5:1, or 3:1 for large text | warning |
meta-length |
A subject longer than subjectLength (60) or empty, a preheader longer than preheaderLength (100) |
warning |
size-budget |
HTML larger than sizeBudget (90 KB). Gmail clips bodies above 102 KB |
warning |
url |
Links and images with relative, empty, malformed or javascript: addresses |
error |
render |
The preview or the template threw | error |
override val lint = Lint(
clients = setOf(Client.GMAIL, Client.OUTLOOK_WINDOWS, Client.APPLE_MAIL), // the default
sizeBudget = 90 * 1024,
severities = mapOf(Rule.CONTRAST to Severity.ERROR, Rule.META_LENGTH to Severity.OFF),
)
An element is exempt from the rules its quill:suppress attribute names, which neither body writes: <div style="display:flex" quill:suppress="css-support">. <Button> and the preheader of <EmailDocument> use it for their deliberate fallbacks. Size and subject rules are turned off through severities.
To update the support data, replace quill-preview/src/main/resources/xyz/nillpoe/quill/preview/caniemail.json with https://www.caniemail.com/api/data.json.
Fuzzing
quillCheck renders each preview again with its payload changed, one property at a time, and reports what the changes break that the preview as written does not:
| Property | Variants |
|---|---|
| Nullable | null, or a value when the preview has null |
List |
empty, one item, 200 items |
String |
empty, long text, a long word, CJK, emoji, XML special characters |
| Numbers | zero, negative, large |
Boolean, enums, sealed objects |
every other value |
| Classes with a primary constructor | their properties, three levels deep, and the first item of lists |
Fuzz invoice_paid "Paid" en_us: 38 variants, 2 failed
lines = 200 items
w: src/main/quill/templates/invoice_paid/en_us.kmx size-budget: HTML body is 143 KB, over the budget of 90 KB. Gmail clips bodies above 102 KB.
Keep it as a preview: .pin("lines = 200 items")
Fuzzing rebuilds payloads through their primary constructor and skips variants the constructor rejects. pin keeps a variant as a preview, so it stays in the checks and the gallery. variants(Money::class) { mapOf("zero" to Money(0, it.currency)) } declares variants for a type, and override val fuzz = Fuzz(mode = FuzzMode.PAIRWISE, depth = 2) changes several properties at once so that every two changes meet in some variant. fuzz = null turns fuzzing off.
Gallery
build/quill/gallery/index.html is one self-contained page that lists every template and component. A template shows its previews and locales, the subject and sender, the render at desktop or mobile width, the HTML (formatted), the text body and the template source, and a problems panel with the findings and the failing fuzz variants. A component shows its signature, its KDoc, every preview and its source. The address keeps the view, so a link opens the same preview, locale and tab. Open it from disk, or publish it from CI next to each pull request.
Keys: / searches, d, m and c switch to desktop, mobile and code.
Live preview
./gradlew :mails:quillServe # http://localhost:7070
./gradlew :mails:quillServe --port 8080
./gradlew :mails:quillServe --host 0.0.0.0 # also reachable from other devices on the network
quillServe serves the gallery and keeps it current while you edit. The page does not reload: renders that changed are swapped in place, and the selected preview, locale, view and scroll position stay. Two paths lead there:
- A saved
.kmxtemplate or component is compiled by the server itself, with KMX and the Kotlin compiler running in its JVM, into classes that shadow the ones Gradle built. Only the previews it affects render again, usually within a second once the compiler has warmed up. This path needs the file to declare the samepropsor component signature as before. - Everything else (preview objects, payload classes, helpers, metadata, environments, new templates, new signatures) goes through a continuous Gradle build of
testClasses, which the server runs with the project's Gradle wrapper. When it succeeds the project loads again. It also rebuilds.kmxchanges, so what you see always ends up as Gradle builds it.
A template that does not compile, a failed build or previews that throw show an overlay with each problem at its file, line and column, a few lines of source around it, and the build output. Errors in Kotlin generated from a template point at the .kmx line. Fix the file and save: the overlay closes by itself.
Classes the IDE builds into the same output directories are picked up too. IntelliJ saves files when it loses focus; to update as you type, set Settings | Appearance & Behavior | System Settings | Autosave | Save files if the IDE is idle for 1 second.
The server binds to localhost unless --host names another address; 0.0.0.0 serves on every interface and prints the LAN addresses to open, for checking mails on a phone. It has no authentication, so do that on trusted networks only. It runs on the project's Java toolchain and uses the Kotlin compiler of the project's Kotlin plugin version, in a class loader of its own, so its dependencies do not reach the project's classes. The continuous build gets the init scripts and --offline of the build that started it, and stops when the server stops, even when that one is killed.
Migrating from Quill 1
- Rewrite each
<id>/<locale>.ftland.ftlhpair as one<id>/<locale>.kmxwithprops: PayloadClass. The text body is derived from it; use<TextOnly>and<HtmlOnly>where the two differ. - Remove
@Quillfrom payload classes; the directory name is the id. - Replace
${KEY}placeholders in templates with{env.KEY}. Metadata keeps${KEY}. - Replace
globalswithRenderKeys passed in the render scope. rendertakes aZoneIdinstead of aTimeZone, andhtmlis always present.- Payload properties that no template reads are no longer reported.
Building
./gradlew build
quill-gradle-plugin's tests publish Quill to build/testing-repository and build sample projects against it. They resolve KMX from Maven local first, so run ./gradlew publishToMavenLocal in the KMX repository to test against an unreleased KMX.
Publishing
./gradlew publishAllPublicationsToForgejoRepository -PforgejoUsername=... -PforgejoPassword=...