Packages

LiveView-style runtime for Gleam.

Current section

Files

Jump to
lightspeed src lightspeed component template_ergonomics.gleam
Raw

src/lightspeed/component/template_ergonomics.gleam

//// Template ergonomics helpers and migration-hint diagnostics for M39.
import gleam/list
import gleam/option.{type Option, None, Some}
import lightspeed/component
import lightspeed/component/template
import lightspeed/component/template_compiler
/// Attribute alias with migration guidance.
pub type AttrAlias {
AttrAlias(alias: String, canonical: String, migration_hint: String)
}
/// Slot default used to reduce repetitive template composition boilerplate.
pub type SlotDefault {
SlotDefault(name: String, rendered: component.Rendered)
}
/// Ergonomic schema wrapper around the template compiler schema.
pub type ErgonomicSchema(assigns, slots) {
ErgonomicSchema(
schema: template_compiler.Schema(assigns, slots),
attr_aliases: List(AttrAlias),
slot_defaults: List(SlotDefault),
)
}
/// One deterministic compile diagnostic with actionable migration hint.
pub type Diagnostic {
Diagnostic(code: String, label: String, hint: String)
}
/// Ergonomic compile artifact with metadata.
pub type Compiled(assigns, slots) {
Compiled(
compiled: template_compiler.CompiledTemplate(assigns, slots),
applied_aliases: List(#(String, String)),
applied_slot_defaults: List(String),
signature: String,
)
}
/// Build one attr alias.
pub fn attr_alias(
alias: String,
canonical: String,
migration_hint: String,
) -> AttrAlias {
AttrAlias(alias: alias, canonical: canonical, migration_hint: migration_hint)
}
/// Build one slot default from rendered content.
pub fn slot_default(name: String, rendered: component.Rendered) -> SlotDefault {
SlotDefault(name: name, rendered: rendered)
}
/// Build one slot default from raw HTML.
pub fn slot_default_html(name: String, html: String) -> SlotDefault {
SlotDefault(name: name, rendered: component.html(html))
}
/// Build ergonomic schema wrapper.
pub fn ergonomic_schema(
schema: template_compiler.Schema(assigns, slots),
attr_aliases: List(AttrAlias),
slot_defaults: List(SlotDefault),
) -> ErgonomicSchema(assigns, slots) {
ErgonomicSchema(
schema: schema,
attr_aliases: attr_aliases,
slot_defaults: slot_defaults,
)
}
/// Build one routine schema that uses automatic slot passthrough.
pub fn routine_schema(
name: String,
attrs: List(template_compiler.AttrSchema),
slots: List(template_compiler.SlotSchema),
build_assigns: fn(List(#(String, template_compiler.AttrValue))) ->
Result(assigns, String),
render: fn(assigns, List(#(String, component.Rendered))) -> component.Rendered,
) -> template_compiler.Schema(assigns, List(#(String, component.Rendered))) {
template_compiler.schema(
name,
attrs,
slots,
build_assigns,
fn(value) { Ok(value) },
template.function(fn(assigns, slots) { render(assigns, slots) }),
)
}
/// Convenience constructor for string attr input.
pub fn string_attr(
name: String,
value: String,
) -> #(String, template_compiler.AttrValue) {
#(name, template_compiler.StringAttr(value))
}
/// Convenience constructor for int attr input.
pub fn int_attr(
name: String,
value: Int,
) -> #(String, template_compiler.AttrValue) {
#(name, template_compiler.IntAttr(value))
}
/// Convenience constructor for bool attr input.
pub fn bool_attr(
name: String,
value: Bool,
) -> #(String, template_compiler.AttrValue) {
#(name, template_compiler.BoolAttr(value))
}
/// Convenience constructor for slot input.
pub fn slot(
name: String,
rendered: component.Rendered,
) -> #(String, component.Rendered) {
#(name, rendered)
}
/// Convenience constructor for slot input from raw HTML.
pub fn slot_html(name: String, html: String) -> #(String, component.Rendered) {
#(name, component.html(html))
}
/// Optional string accessor with fallback default.
pub fn optional_string(
attrs: List(#(String, template_compiler.AttrValue)),
name: String,
default: String,
) -> Result(String, String) {
case find_attr(attrs, name) {
None -> Ok(default)
Some(template_compiler.StringAttr(value)) -> Ok(value)
Some(other) ->
Error(
"invalid_attr_type:"
<> name
<> ":expected=string:actual="
<> value_kind_label(other),
)
}
}
/// Optional int accessor with fallback default.
pub fn optional_int(
attrs: List(#(String, template_compiler.AttrValue)),
name: String,
default: Int,
) -> Result(Int, String) {
case find_attr(attrs, name) {
None -> Ok(default)
Some(template_compiler.IntAttr(value)) -> Ok(value)
Some(other) ->
Error(
"invalid_attr_type:"
<> name
<> ":expected=int:actual="
<> value_kind_label(other),
)
}
}
/// Optional bool accessor with fallback default.
pub fn optional_bool(
attrs: List(#(String, template_compiler.AttrValue)),
name: String,
default: Bool,
) -> Result(Bool, String) {
case find_attr(attrs, name) {
None -> Ok(default)
Some(template_compiler.BoolAttr(value)) -> Ok(value)
Some(other) ->
Error(
"invalid_attr_type:"
<> name
<> ":expected=bool:actual="
<> value_kind_label(other),
)
}
}
/// Return one required slot HTML as string.
pub fn required_slot_html(
slots: List(#(String, component.Rendered)),
name: String,
) -> Result(String, String) {
case template_compiler.require_slot(slots, name) {
Ok(rendered) -> Ok(component.to_html(rendered))
Error(reason) -> Error(reason)
}
}
/// Return one optional slot HTML as string, defaulting to empty.
pub fn slot_html_or_empty(
slots: List(#(String, component.Rendered)),
name: String,
) -> String {
case template_compiler.slot_rendered(slots, name) {
Some(rendered) -> component.to_html(rendered)
None -> ""
}
}
/// Compile one ergonomic template invocation.
pub fn compile(
schema: ErgonomicSchema(assigns, slots),
attrs: List(#(String, template_compiler.AttrValue)),
slots: List(#(String, component.Rendered)),
) -> Result(Compiled(assigns, slots), List(Diagnostic)) {
let #(normalized_attrs, applied_aliases) =
apply_aliases(attrs, schema.attr_aliases, [], [])
let #(normalized_slots, applied_defaults) =
apply_slot_defaults(slots, schema.slot_defaults, [])
case
template_compiler.compile(schema.schema, normalized_attrs, normalized_slots)
{
Ok(compiled_template) ->
Ok(Compiled(
compiled: compiled_template,
applied_aliases: list.reverse(applied_aliases),
applied_slot_defaults: list.reverse(applied_defaults),
signature: compile_signature(
compiled_template,
list.reverse(applied_aliases),
list.reverse(applied_defaults),
),
))
Error(diagnostics) ->
Error(
enhance_diagnostics(
diagnostics,
schema.schema,
schema.attr_aliases,
schema.slot_defaults,
[],
),
)
}
}
/// Stable diagnostic label.
pub fn diagnostic_label(diagnostic: Diagnostic) -> String {
diagnostic.code <> ":" <> diagnostic.label <> ":hint=" <> diagnostic.hint
}
/// Stable diagnostics signature.
pub fn diagnostics_signature(diagnostics: List(Diagnostic)) -> String {
join_with(";", list.map(diagnostics, diagnostic_label))
}
/// Compiled artifact signature.
pub fn signature(compiled: Compiled(assigns, slots)) -> String {
compiled.signature
}
/// Compiled HTML.
pub fn html(compiled: Compiled(assigns, slots)) -> String {
template_compiler.html(compiled.compiled)
}
/// Compiled fingerprint.
pub fn fingerprint(compiled: Compiled(assigns, slots)) -> String {
template_compiler.fingerprint(compiled.compiled)
}
/// Underlying compiled template accessor.
pub fn compiled_template(
compiled: Compiled(assigns, slots),
) -> template_compiler.CompiledTemplate(assigns, slots) {
compiled.compiled
}
/// Applied alias migrations.
pub fn applied_aliases(
compiled: Compiled(assigns, slots),
) -> List(#(String, String)) {
compiled.applied_aliases
}
/// Applied slot defaults.
pub fn applied_slot_defaults(
compiled: Compiled(assigns, slots),
) -> List(String) {
compiled.applied_slot_defaults
}
fn apply_aliases(
attrs: List(#(String, template_compiler.AttrValue)),
aliases: List(AttrAlias),
normalized_rev: List(#(String, template_compiler.AttrValue)),
applied_rev: List(#(String, String)),
) -> #(List(#(String, template_compiler.AttrValue)), List(#(String, String))) {
case attrs {
[] -> #(list.reverse(normalized_rev), applied_rev)
[#(name, value), ..rest] ->
case find_alias(aliases, name) {
Some(alias_rule) ->
apply_aliases(
rest,
aliases,
[#(alias_rule.canonical, value), ..normalized_rev],
[#(alias_rule.alias, alias_rule.canonical), ..applied_rev],
)
None ->
apply_aliases(
rest,
aliases,
[#(name, value), ..normalized_rev],
applied_rev,
)
}
}
}
fn apply_slot_defaults(
slots: List(#(String, component.Rendered)),
defaults: List(SlotDefault),
applied_rev: List(String),
) -> #(List(#(String, component.Rendered)), List(String)) {
apply_slot_defaults_loop(slots, defaults, applied_rev)
}
fn apply_slot_defaults_loop(
slots: List(#(String, component.Rendered)),
defaults: List(SlotDefault),
applied_rev: List(String),
) -> #(List(#(String, component.Rendered)), List(String)) {
case defaults {
[] -> #(slots, applied_rev)
[default, ..rest] ->
case slot_exists(slots, default.name) {
True -> apply_slot_defaults_loop(slots, rest, applied_rev)
False ->
apply_slot_defaults_loop(
[#(default.name, default.rendered), ..slots],
rest,
[default.name, ..applied_rev],
)
}
}
}
fn enhance_diagnostics(
diagnostics: List(template_compiler.Diagnostic),
schema: template_compiler.Schema(assigns, slots),
aliases: List(AttrAlias),
slot_defaults: List(SlotDefault),
acc_rev: List(Diagnostic),
) -> List(Diagnostic) {
case diagnostics {
[] -> list.reverse(acc_rev)
[diagnostic, ..rest] ->
enhance_diagnostics(rest, schema, aliases, slot_defaults, [
diagnostic_with_hint(diagnostic, schema, aliases, slot_defaults),
..acc_rev
])
}
}
fn diagnostic_with_hint(
diagnostic: template_compiler.Diagnostic,
schema: template_compiler.Schema(assigns, slots),
aliases: List(AttrAlias),
slot_defaults: List(SlotDefault),
) -> Diagnostic {
let label = template_compiler.diagnostic_label(diagnostic)
case diagnostic {
template_compiler.MissingAttribute(_, attribute) ->
Diagnostic(
code: "missing_attribute",
label: label,
hint: missing_attribute_hint(attribute, aliases, schema),
)
template_compiler.DuplicateAttribute(_, attribute) ->
Diagnostic(
code: "duplicate_attribute",
label: label,
hint: "keep a single `" <> attribute <> "` value",
)
template_compiler.UnknownAttribute(_, attribute) ->
Diagnostic(
code: "unknown_attribute",
label: label,
hint: unknown_attribute_hint(attribute, aliases, schema),
)
template_compiler.InvalidAttributeType(_, _, expected, _) ->
Diagnostic(
code: "invalid_attribute_type",
label: label,
hint: "use the `" <> expected_helper_label(expected) <> "` attr helper",
)
template_compiler.MissingSlot(_, slot) ->
Diagnostic(
code: "missing_slot",
label: label,
hint: missing_slot_hint(slot, slot_defaults),
)
template_compiler.DuplicateSlot(_, slot) ->
Diagnostic(
code: "duplicate_slot",
label: label,
hint: "keep a single `" <> slot <> "` slot entry",
)
template_compiler.UnknownSlot(_, slot) ->
Diagnostic(
code: "unknown_slot",
label: label,
hint: unknown_slot_hint(slot, schema),
)
template_compiler.AssignBuildFailed(_, _) ->
Diagnostic(
code: "assign_build_failed",
label: label,
hint: "verify typed assign mapping and migration adapters",
)
template_compiler.SlotBuildFailed(_, _) ->
Diagnostic(
code: "slot_build_failed",
label: label,
hint: "verify slot mapping and optional-slot fallback handling",
)
}
}
fn missing_attribute_hint(
attribute: String,
aliases: List(AttrAlias),
schema: template_compiler.Schema(assigns, slots),
) -> String {
case aliases_for_canonical(aliases, attribute) {
[] ->
"provide required attr `"
<> attribute
<> "` (known attrs: "
<> join_with(",", schema_attr_names(schema.attrs))
<> ")"
alias_names ->
"provide required attr `"
<> attribute
<> "` or migrate from alias(es): "
<> join_with(",", alias_names)
}
}
fn unknown_attribute_hint(
attribute: String,
aliases: List(AttrAlias),
schema: template_compiler.Schema(assigns, slots),
) -> String {
case find_alias(aliases, attribute) {
Some(alias_rule) ->
"rename `"
<> attribute
<> "` to `"
<> alias_rule.canonical
<> "` ("
<> alias_rule.migration_hint
<> ")"
None ->
case first_similar_attr(attribute, schema.attrs) {
Some(name) ->
"unknown attr `" <> attribute <> "`, did you mean `" <> name <> "`?"
None ->
"unknown attr `"
<> attribute
<> "`, supported attrs: "
<> join_with(",", schema_attr_names(schema.attrs))
}
}
}
fn missing_slot_hint(slot: String, defaults: List(SlotDefault)) -> String {
case slot_default_exists(defaults, slot) {
True ->
"slot `"
<> slot
<> "` is required; add it or keep default through `slot_default`"
False -> "add required slot `" <> slot <> "`"
}
}
fn unknown_slot_hint(
slot: String,
schema: template_compiler.Schema(assigns, slots),
) -> String {
"unknown slot `"
<> slot
<> "`, supported slots: "
<> join_with(",", schema_slot_names(schema.slots))
}
fn expected_helper_label(kind: template_compiler.AttrKind) -> String {
case kind {
template_compiler.AttrString -> "string_attr"
template_compiler.AttrInt -> "int_attr"
template_compiler.AttrBool -> "bool_attr"
}
}
fn compile_signature(
compiled: template_compiler.CompiledTemplate(assigns, slots),
applied_aliases: List(#(String, String)),
applied_defaults: List(String),
) -> String {
let alias_signature = case applied_aliases {
[] -> "none"
_ ->
join_with(
",",
list.map(applied_aliases, fn(value) {
let #(alias, canonical) = value
alias <> "->" <> canonical
}),
)
}
let defaults_signature = case applied_defaults {
[] -> "none"
_ -> join_with(",", applied_defaults)
}
template_compiler.signature(compiled)
<> "|aliases="
<> alias_signature
<> "|defaults="
<> defaults_signature
}
fn find_alias(aliases: List(AttrAlias), name: String) -> Option(AttrAlias) {
case aliases {
[] -> None
[alias_rule, ..rest] ->
case alias_rule.alias == name {
True -> Some(alias_rule)
False -> find_alias(rest, name)
}
}
}
fn aliases_for_canonical(
aliases: List(AttrAlias),
canonical: String,
) -> List(String) {
aliases_for_canonical_loop(aliases, canonical, [])
}
fn aliases_for_canonical_loop(
aliases: List(AttrAlias),
canonical: String,
acc_rev: List(String),
) -> List(String) {
case aliases {
[] -> list.reverse(acc_rev)
[alias_rule, ..rest] ->
case alias_rule.canonical == canonical {
True ->
aliases_for_canonical_loop(rest, canonical, [
alias_rule.alias,
..acc_rev
])
False -> aliases_for_canonical_loop(rest, canonical, acc_rev)
}
}
}
fn find_attr(
attrs: List(#(String, template_compiler.AttrValue)),
name: String,
) -> Option(template_compiler.AttrValue) {
case attrs {
[] -> None
[#(entry_name, entry_value), ..rest] ->
case entry_name == name {
True -> Some(entry_value)
False -> find_attr(rest, name)
}
}
}
fn first_similar_attr(
_name: String,
attrs: List(template_compiler.AttrSchema),
) -> Option(String) {
case attrs {
[] -> None
[attr, ..] -> Some(attr.name)
}
}
fn slot_default_exists(defaults: List(SlotDefault), name: String) -> Bool {
case defaults {
[] -> False
[default, ..rest] ->
case default.name == name {
True -> True
False -> slot_default_exists(rest, name)
}
}
}
fn slot_exists(
slots: List(#(String, component.Rendered)),
expected: String,
) -> Bool {
case slots {
[] -> False
[#(name, _), ..rest] ->
case name == expected {
True -> True
False -> slot_exists(rest, expected)
}
}
}
fn schema_attr_names(
attrs: List(template_compiler.AttrSchema),
) -> List(String) {
attrs |> list.map(fn(attr) { attr.name })
}
fn schema_slot_names(
slots: List(template_compiler.SlotSchema),
) -> List(String) {
slots |> list.map(fn(slot) { slot.name })
}
fn value_kind_label(value: template_compiler.AttrValue) -> String {
case value {
template_compiler.StringAttr(_) -> "string"
template_compiler.IntAttr(_) -> "int"
template_compiler.BoolAttr(_) -> "bool"
}
}
fn join_with(separator: String, values: List(String)) -> String {
case values {
[] -> ""
[value] -> value
[value, ..rest] -> value <> separator <> join_with(separator, rest)
}
}