ceylon-sdk
ceylon-sdk copied to clipboard
change pattern for attributes in `ceylon.html`
Somewhere âfluentâ APIs win over Ceylonâs named argument syntax is when there is inheritance. With a class that specifies a bunch of arguments (with potentially a lot being defaulted), every subclass that wants to allow these arguments to be specified during construction must explicitly declare the same arguments and specify the same default values.
This can become a burden and is especially bothersome if the class is open for other modules to inherit from. A parameterâs default value might be an implementation detail (and might change from version to version). Requiring that it be specified in a subclass out of its module is unacceptable.
Additionally, it makes it hard to extend the API: if someone wants to add a new parameter at the end of the class, one must do so for every single subclass. If there is a trailing iterable parameter, it becomes impossible to do so without ruining backward compatibility.
With the builder pattern, fluent APIs allow similar (yet slightly different) instances to be produced very easily.
Together with (and even without) the builder pattern, fluent APIs win over the current named argument pattern in almost every aspect. The only case I can see in which fluent APIs lose is when a subclass doesnât want to allow a specific set of parameters of the superclass to be specified.
Now, differently from what you may have been thinking, I am not going to propose to use a fluent API on ceylon.html, I am actually going to propose an entirely new pattern. I call it âthe attributes patternâ.
The attributes pattern is pretty straightforward. You just write an interface to represent the parameters (here called Attributes). For ceylon.html, it ought to look something like the following.
shared interface Attributes
{
shared Attribute<String> id = null;
shared Attribute<[String+]> classes = null;
shared Attribute<String> accessKey = null;
shared Attribute<Boolean> contentEditable = null;
shared Attribute<String> contextMenu = null;
shared Attribute<Direction> dir = null;
shared Attribute<Boolean> draggable = null;
shared Attribute<DropZone> dropZone = null;
shared Attribute<Boolean> hidden = null;
shared Attribute<String> lang = null;
shared Attribute<Boolean> spellcheck = null;
shared Attribute<String> style = null;
shared Attribute<Integer> tabIndex = null;
shared Attribute<String> title = null;
shared Attribute<Boolean> translate = null;
}
Then, the Element class can look like:
shared abstract class Element(shared String tagName, Arttributes attributes = object satisfies Attributes {}, {Content<Node>*} children = [])
extends Node(...)
{
shared String? id = getAttribute(attributes.id);
shared [String+]? classes = getAttribute(attributes.classes);
shared String? accessKey = getAttribute(attributes.accessKey);
shared Boolean? contentEditable = getAttribute(attributes.contentEditable);
shared String? contextMenu = getAttribute(attributes.contextMenu);
shared Direction? dir = getAttribute(attributes.dir);
shared Boolean? draggable = getAttribute(attributes.draggable);
shared DropZone? dropZone = getAttribute(attributes.dropZone);
shared Boolean? hidden = getAttribute(attributes.hidden);
shared String? lang = getAttribute(attributes.lang);
shared Boolean? spellcheck = getAttribute(attributes.spellcheck);
shared String? style = getAttribute(attributes.style);
shared Integer? tabIndex = getAttribute(attributes.tabIndex);
shared String? title = getAttribute(attributes.title);
shared Boolean? translate = getAttribute(attributes.translate);
}
Subclasses can be much shorter. A subclass that doesnât introduce any new attributes can merely accept the same Attributes interface and pass it up to the superclass for it to use.
shared class Div(Attributes attributes = object satisfies Attributes {}, {Content<FlowCategory>*} children = [])
extends Element("div", attributes, children)
{
}
However, subclasses that do need to declare more attributes can define a subtype of Attributes and specify them there.
shared interface FormAttributes
satisfies Attributes
{
Attribute<String> acceptCharset = null;
Attribute<String> action = null;
Attribute<Boolean> autocomplete = null;
Attribute<String>|Attribute<FormEnctype> enctype = null;
Attribute<String>|Attribute<FormMethod> method = null;
Attribute<String> name = null;
Attribute<Boolean> novalidate = null;
Attribute<String>|Attribute<FormTarget> target = null;
}
shared class Form(FormAttributes attributes = object satisfies FormAttributes {}, {Content<FlowCategory>*} children = [])
extends Element("form", attributes, children)
{
String acceptCharset = getAttribute(attributes.acceptCharset);
String action = getAttribute(attributes.action);
Boolean autocomplete = getAttribute(attributes.autocomplete);
String|FormEnctype enctype = getAttribute(attributes.enctype);
String|FormMethod method = getAttribute(attributes.method);
String name = getAttribute(attributes.name);
Boolean novalidate = getAttribute(attributes.novalidate);
String|FormTarget target = getAttribute(attributes.target);
}
These classes can be instantiated very easily:
Div
{
object attributes
satisfies Attributes
{
classes = ["form-container", "foo-bar"];
lang = "en";
}
Form
{
object attributes
satisfies Attributes
{
classes = ["my-form"];
id = "main-form";
}
P
{
Label{ "Login", Input {}}
},
P
{
Label
{
"Password",
Input
{
object attributes
satisfies InputAttributes
{
type = password;
}
}
}
},
P
{
Button { "Submit" }
}
}
}
To instantiate lookâalike elements, one can refine the nonâdefaulted attributes, and make the changeable ones variable.
object fakeBuilder
satisfies Attributes
{
classes = ["foo-container", "pretty"];
dir = rtl;
shared actual variable Attribute<String> id = super.id;
}
fakeBuilder.id = "pretty1";
value div1 = Div {attributes = fakeBuilder;};
fakeBuilder.id = "pretty2";
value div2 = Div {attributes = fakeBuilder;};
A class can make an attribute unspecifiable by refining it as nonârefinable in its own Attributes interface.
Of course, I am not going to deny that writing Div { lang = "en"; "Hello" } looks much nicer than writing Div { object attributes satisfies Attributes { lang = "en"; } "Hello" }, however, as shown here, the later, while more verbose, is much more flexible for both the writer of the class Div and for its users.
writing
...looks much nicer than writing...
I think that's a deal breaker. Having to write object attributes satisfies XAttributes {...} for tags w/attributes is way too verbose and detracts from the content.
Normally in Ceylon, I try to use formal attribute in base classes, which among other benefits reduces the size of the extends clause. But I think one reason this is not done in ceylon.html is that attributes must be generically enumerated during serialization, so they are stored in a Sequential in the base class. Perhaps I missed it, but I don't see the enumeration concern being addressed by the Attributes classes.
A few other notes:
- For API evolution, I think you need to use
abstract classes. I don't think the current compiler supports introducing new defaultedinterfacemembers without recompiling satisfying classes. - Remember, interfaces attributes must use
=> - I'm not sure how efficient it would be to declare new classes for each use of a tag (OTOH I guess the compiler declares so many classes anyway for lazy iterables)
- Perhaps you could do something like
object extends Div { id="1"; ... }w/o separate attribute classes, but that only helps a little with the verbosity, and enumerability of attributes must still be addressed. And, I guess, tags would need no-arg constructors.
@jvasileff, to address a specifically your “serialization of attributes” concern:
I have started implementing my idea, and the way I’ve dealt with this is by:
- making element classes final,
- making the
Elementclass sealed, - adding a
serializedAttributesattribute to them, - adding unshared
serializeXxxAttributefunctions that can serialize different kinds of attributes (e.g.Boolean,Sequence,AttributeValueProvider), - for
XAttributess that have subtypes, adding aserializeXAttributesto allow the associatedXclass to serialize them, - for
Xclasses whoseXAttributess don’t have subtypes, merely usesuper.serializeAttributes.
Since I probably didn’t do a good job explaining, here is how I have the Element.ceylon unit:
"Represents base class for all HTML elements."
shared sealed abstract class Element(tagName, attributes, children)
extends Node() {
"The name of the tag for this element."
shared String tagName;
"The attributes associated with this element."
shared restricted Attributes attributes;
"The children of this element."
shared {Content<Node>*} children;
shared restricted default{<String->String>*} serializedAttributes => serializeAttributes(attributes);
}
{<String->String>*} serializeAttributes(Attributes attributes) => {
"id"->attributes.id,
"class"->serializeSequentialAttribute(attributes.classes),
"accesskey"->serializeSequentialCharacterAttribute(attributes.accessKey),
"contenteditable"->serializeInheritableBooleanAttribute(attributes.contentEditable),
"contextmenu"->attributes.contextMenu,
"dir"->serializeOptionalAttribute(attributes.dir),
"draggable"->serializeInheritableBooleanAttribute(attributes.draggable),
"dropzone"->serializeOptionalAttribute(attributes.dropZone),
"hidden"->serializeBooleanAttribute(attributes.hidden),
"lang"->attributes.lang,
"spellcheck"->serializeInheritableBooleanAttribute(attributes.spellcheck, "yes", "no"),
"style"->serializeNonemptyAttribute(attributes.style),
"tabindex"->serializeIntegerAttribute(attributes.tabIndex),
"title"->attributes.title,
"translate"->serializeInheritableBooleanAttribute(attributes.translate, "yes", "no"),
*attributes.more
}.map(coalesceItem).coalesced;
Where Attributes is (sans the documentation):
shared interface Attributes {
shared default String? id => null;
shared default [String*] classes => [];
shared default [Character*] accessKey => [];
shared default Boolean? contentEditable => null;
shared default String? contextMenu => null;
shared default Direction? dir => null;
shared default Boolean? draggable => null;
shared default DropZone? dropZone => null;
shared default Boolean hidden => false;
shared default String? lang => null;
shared default Boolean? spellcheck => null;
shared default String style => "";
shared default Integer? tabIndex => null;
shared default String? title => null;
shared default Boolean? translate => null;
shared default {<String->String>*} more => [];
}
An element that doesn’t define unique attribute doesn’t need to worry about refining attributes, as it’s the case of Abbr:
tagged("flow", "phrasing")
shared final class Abbr(attributes = object satisfies Attributes {}, children = [])
extends Element("abbr", attributes, children)
satisfies FlowCategory & PhrasingCategory {
"The attributes associated with this element."
Attributes attributes;
"The children of this element."
{Content<Node>*} children;
}
An element that has unique attributes like Audio needs to refine attributes:
tagged("flow", "phrasing", "embedded", "interactive")
shared final class Audio(attributes = object satisfies AudioAttributes {}, children = [])
extends Element("audio", attributes, children)
satisfies FlowCategory & PhrasingCategory & EmbeddedCategory & InteractiveCategory {
"The attributes associated with this element."
AudioAttributes attributes;
"The children of this element."
{Content<Source|Track|FlowCategory>*} children;
shared restricted actual {<String->String>*} serializedAttributes => {
"autoplay"->serializeBooleanAttribute(attributes.autoplay),
"controls"->serializeBooleanAttribute(attributes.controls),
"loop"-> serializeBooleanAttribute(attributes.loop),
"muted"->serializeBooleanAttribute(attributes.muted),
"preload"->serializeOptionalAttribute(attributes.preload),
"src"->attributes.src,
*super.serializedAttributes
}.map(coalesceItem).coalesced;
}
shared interface AudioAttributes
satisfies Attributes {
shared default Boolean autoplay => false;
shared default Boolean controls => false;
shared default Boolean loop => false;
shared default Boolean muted => false;
shared default Preload? preload => null;
shared default String? src => null;
}
If there is a hierarchy between two XAttributes interfaces, such as the case of AAttributes and AreaAttributes, then the serialization of AAttributes cannot be done inside A, because Area doesn’t inherit from A, and so it can’t use super.serializedAttributes to refer to A.
So, we need to separate it into its own declaration:
tagged("flow", "phrasing", "interactive")
shared final class A(attributes = object satisfies AAttributes {}, children = [])
extends Element("a", attributes, children)
satisfies FlowCategory & PhrasingCategory & InteractiveCategory {
"The attributes associated with this element."
AAttributes attributes;
"The children of this element."
{Content<FlowCategory>*} children;
shared restricted actual {<String->String>*} serializedAttributes => serializeAAttributes(attributes);
}
shared interface AAttributes
satisfies BaseAttributes {
shared default String|Boolean download => false;
shared default String? hreflang => null;
shared default [String*] rel => [];
shared default MimeType? type => null;
}
{<String->String>*} serializeAAttributes(AAttributes attributes) => {
"download"->(switch(attribute = attributes.download) case(is String) attribute case(is Boolean) serializeBooleanAttribute(attribute)),
"hreflang"->attributes.hreflang,
"rel"->serializeSequentialAttribute(attributes.rel),
"type"->serializeOptionalAttribute(attributes.type),
*serializeAttributes(attributes)
}.map(coalesceItem).coalesced;
Then, we can use it from Area:
tagged("flow", "phrasing")
shared final class Area(attributes = object satisfies AreaAttributes {}, children = [])
extends Element("area", attributes, children)
satisfies FlowCategory & PhrasingCategory {
"The attributes associated with this element."
AreaAttributes attributes;
"The children of this element."
{Content<PhrasingCategory>*} children;
shared restricted actual {<String->String>*} serializedAttributes => {
"alt"->attributes.alt,
"coords"->serializeSequentialIntegerAttribute(attributes.coords),
"media"->attributes.media,
"shape"->serializeOptionalAttribute(attributes.shape),
*serializeAAttributes(attributes)
}.map(coalesceItem).coalesced;
}
shared interface AreaAttributes
satisfies AAttributes {
shared default String alt => "";
shared default [Integer*] coords => [];
shared default String? media => null;
shared default Shape? shape => null;
}
By the way, the definition of serializeXxxAttribute should be clear. Here is an example to demonstrate:
String? serializeSequentialAttribute([String*] attribute) {
if(nonempty attribute) {
return " ".join(attribute);
}
else {
return null;
}
}
coalesceItem (probably a misnomer), is defined as:
<Key->Item&Object>? coalesceItem<out Key, out Item>(Key->Item element)
given Key satisfies Object {
if(exists item = element.item) {
return element.key->item;
}
else {
return null;
}
}
It’s also interesting to note that, since streams are lazy, I don’t need to worry about having an Attribute alias, since people who want their attributes to be only evaluated at template rendering time can write the attributes with => instead of with =:
value div = Div
{
object attributes
satisfies Attributes
{
classes => potentiallyHeavyComputation();
// or even:
// shared actual late [String*] classes = potentiallyHeavyComputation();
}
};
In case you are wondering why I have put serializedAttributes in X instead of XAttributes, it’s because I don’t think it’s a good idea that you can end up with href="https://whatever/" in, say, a Div.
For API evolution, I think you need to use abstract classes. I don't think the current compiler supports introducing new defaulted interface members without recompiling satisfying classes.
That’s a bummer, considering that one might want to instantiate, say, an A and an Input with the same global attributes set.
Perhaps you could do something like […]
That’s an interesting idea, but I feel like it’s overall worse because it requires verbose boilerplate for every element, and not only elements with attributes. Additionally, I’d say that an element’s tag is more important semantically than its attributes, so having noise in there makes the templates harder to read than in the attributes.
So, this is library developer ease of use versus end-user ease of use question.
The way I see this. ceylon.html biggest strength so far is that it is a fairly close to 1:1 parity between HTML format and Ceylon declarative code style.
@Zambonifofex, I see your pain and I understand it, but forcing library users to write their declarative HTML like this is unacceptable:
Div {
object attributes satisfies Attributes {
classes = ["form-container", "foo-bar"];
lang = "en";
},
Form {
object attributes satisfies Attributes {
classes = ["my-form"];
id = "main-form";
},
P {
Label{ "Login", Input {}}
},
P {
Label {
"Password",
Input {
object attributes satisfies InputAttributes {
type = password;
}
}
}
},
P {
Button { "Submit" }
}
}
}```
When currently we can do this (I hope I got it right):
```ceylon
Div {
classes = ["form-container", "foo-bar"];
lang = "en";
Form {
classes = ["my-form"];
id = "main-form";
P {
Label{ "Login", Input() }
},
P {
Label{ "Password", Input(type = password) }
},
P {
Button { "Submit" }
}
}
}
}```
So, something occurred to me. @gavinking’s “inline object satisfies clause inferrence” idea (eclipse/ceylon#5739) could help us out here.
You’ll see me arguing against the feature there, but it seems it would actually be more generally useful than what I could first see. (It wouldn’t be only useful for this particular idea.)
Then, you could shorten it down to:
value section = Section
{
object attributes {id = "how-it-works"; classes = ["foo-bar"];}
H1 {"How it works"},
P {"..."}
};
Or even:
value section = Section
{
object {id = "how-it-works"; classes = ["foo-bar"];};
H1 {"How it works"},
P {"..."}
};
Of course, ideally {} wouldn’t have been taken by streams, so that the object keyword could have been omitted (just like function can be omitted in lambdas), but that’s just not how the language has evolved to be.