Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

change pattern for attributes in ceylon.html #697

Open
ghost opened this issue Jan 4, 2018 · 4 comments
Open

change pattern for attributes in ceylon.html #697

ghost opened this issue Jan 4, 2018 · 4 comments

Comments

@ghost
Copy link

ghost commented Jan 4, 2018

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.

@jvasileff
Copy link
Contributor

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 defaulted interface members 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.

@ghost
Copy link
Author

ghost commented Jan 9, 2018

@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 Element class sealed,
  • adding a serializedAttributes attribute to them,
  • adding unshared serializeXxxAttribute functions that can serialize different kinds of attributes (e.g. Boolean, Sequence, AttributeValueProvider),
  • for XAttributess that have subtypes, adding a serializeXAttributes to allow the associated X class to serialize them,
  • for X classes whose XAttributess don’t have subtypes, merely use super.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.

@luolong
Copy link
Contributor

luolong commented Jan 11, 2018

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" }
        }
    }
}
}```

@ghost
Copy link
Author

ghost commented Jan 11, 2018

So, something occurred to me. @gavinking’s “inline object satisfies clause inferrence” idea (eclipse-archived/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.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants