This is an automated email from the ASF dual-hosted git repository.

dashorst pushed a commit to branch spike/builder-api
in repository https://gitbox.apache.org/repos/asf/wicket.git

commit e572bc085a27c7b9ce2070b5ca04ba7c3156138c
Author: Martijn Dashorst <[email protected]>
AuthorDate: Fri Oct 23 16:16:46 2026 +0200

    Add a builder API spike to the examples
    
    - Add a throwaway, marked SPIKE in the sources, builder vocabulary for 
components (ComponentBuilder, Builders, CoreUi, ExtensionsUi), with event 
handlers given as lambdas.
    - Rewrite the guestbook, todo list and editable label examples on that 
vocabulary as BuilderApplication pages, mounted at /guestbook, /todo and 
/editable-label.
    - Add BuilderSpikeTest.
    - Port build-versions.py to Python 3 and derive the module list from the 
module poms instead of hardcoding it.
---
 build-versions.py                                  |  39 +-
 .../apache/wicket/examples/builder/BasePage.java   |  33 ++
 .../examples/builder/BuilderApplication.java       |  42 +++
 .../wicket/examples/builder/EditableLabelPage.html |  31 ++
 .../wicket/examples/builder/EditableLabelPage.java |  57 +++
 .../examples/builder/EditableLabelPage.properties  |  16 +
 .../apache/wicket/examples/builder/GuestBook.html  |  29 ++
 .../apache/wicket/examples/builder/GuestBook.java  |  71 ++++
 .../wicket/examples/builder/GuestBook.properties   |  16 +
 .../org/apache/wicket/examples/builder/Index.html  |   6 +
 .../org/apache/wicket/examples/builder/Index.java  |  32 ++
 .../wicket/examples/builder/Index.properties       |  15 +
 .../apache/wicket/examples/builder/TodoList.html   |  28 ++
 .../apache/wicket/examples/builder/TodoList.java   |  93 +++++
 .../wicket/examples/builder/TodoList.properties    |  16 +
 .../org/apache/wicket/examples/builder/Ui.java     |  27 ++
 .../wicket/examples/builder/spike/Builders.java    | 400 +++++++++++++++++++++
 .../examples/builder/spike/ComponentBuilder.java   | 112 ++++++
 .../wicket/examples/builder/spike/CoreUi.java      | 130 +++++++
 .../wicket/examples/builder/spike/Scope.java       |  85 +++++
 .../spike/extensions/EditableLabelBuilder.java     | 208 +++++++++++
 .../builder/spike/extensions/ExtensionsUi.java     |  44 +++
 .../apache/wicket/examples/homepage/HomePage.html  |   1 +
 wicket-examples/src/main/webapp/WEB-INF/web.xml    |  16 +
 .../wicket/examples/builder/BuilderSpikeTest.java  | 140 ++++++++
 25 files changed, 1667 insertions(+), 20 deletions(-)

diff --git a/build-versions.py b/build-versions.py
index 3f6ae085d2..d5a5e56df3 100755
--- a/build-versions.py
+++ b/build-versions.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python3
 #
 # prints a release.properties file for instructing the Maven Release Plugin
 # to generate the proper release artefacts without having to manually version
@@ -13,16 +13,17 @@
 #
 # Example:
 #
-#    build-milestone.py 7.0.0-M1 7.0.0-SNAPSHOT
+#    build-versions.py 7.0.0-M1 7.0.0-SNAPSHOT
 #
 
+import os
 import sys
-from xml.dom.minidom import parse
+import xml.etree.ElementTree as ET
 
-groupId = "org.apache.wicket"
+NS = {"pom": "http://maven.apache.org/POM/4.0.0"}
 
 if len(sys.argv) != 3:
-    print "Usage: %s <release-version> <dev-version>" % sys.argv[0]
+    print("Usage: %s <release-version> <dev-version>" % sys.argv[0])
     sys.exit(1)
 
 relVersion = sys.argv[1]
@@ -31,27 +32,25 @@ devVersion = sys.argv[2]
 relVersions = []
 devVersions = []
 
-def addVersions(groupId, module):
-    relVersions.append("project.rel." + groupId + "\\:" + module + "=" + 
relVersion)
-    devVersions.append("project.dev." + groupId + "\\:" + module + "=" + 
devVersion)
+def addVersions(groupId, artifactId):
+    relVersions.append("project.rel." + groupId + "\\:" + artifactId + "=" + 
relVersion)
+    devVersions.append("project.dev." + groupId + "\\:" + artifactId + "=" + 
devVersion)
 
-def getModulesFromParent(parentPomFile):
-    pom = parse(parentPomFile)
+def addModule(directory):
+    pom = ET.parse(os.path.join(directory, "pom.xml")).getroot()
 
-    for moduleTag in pom.getElementsByTagName('module'):
-        module = moduleTag.childNodes[0].nodeValue.replace("testing/", 
"").replace("archetypes/quickstart", "wicket-archetype-quickstart")
-        addVersions(groupId, module)
+    groupId = pom.findtext("pom:groupId", namespaces=NS) or 
pom.findtext("pom:parent/pom:groupId", namespaces=NS)
+    addVersions(groupId, pom.findtext("pom:artifactId", namespaces=NS))
 
-addVersions(groupId, "wicket-parent")
-addVersions("org.apache.wicket.experimental.wicket8", "wicket-experimental")
+    for module in pom.iterfind(".//pom:modules/pom:module", NS):
+        addModule(os.path.join(directory, module.text))
 
-getModulesFromParent("pom.xml")
-getModulesFromParent("wicket-native-websocket/pom.xml")
+addModule(".")
 
 for version in relVersions:
-    print version
+    print(version)
 
-print
+print()
 
 for version in devVersions:
-    print version
+    print(version)
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BasePage.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BasePage.java
new file mode 100644
index 0000000000..16586c3515
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BasePage.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import org.apache.wicket.examples.WicketExamplePage;
+
+/**
+ * Base page of the builder spike.
+ */
+public abstract class BasePage extends WicketExamplePage implements Ui
+{
+       private static final long serialVersionUID = 1L;
+
+       @Override
+       protected Class<? extends WicketExamplePage> getIndexPage()
+       {
+               return Index.class;
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BuilderApplication.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BuilderApplication.java
new file mode 100644
index 0000000000..fd88332dba
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/BuilderApplication.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import org.apache.wicket.Page;
+import org.apache.wicket.examples.WicketExampleApplication;
+
+/**
+ * SPIKE - throwaway. Application for the builder API spike.
+ */
+public class BuilderApplication extends WicketExampleApplication
+{
+       @Override
+       protected void init()
+       {
+               super.init();
+
+               mountPage("guestbook", GuestBook.class);
+               mountPage("todo", TodoList.class);
+               mountPage("editable-label", EditableLabelPage.class);
+       }
+
+       @Override
+       public Class<? extends Page> getHomePage()
+       {
+               return Index.class;
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.html
new file mode 100644
index 0000000000..2c17aa79e2
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.html
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<wicket:extend xmlns:wicket="http://wicket.apache.org";>
+<form wicket:id="form">
+<div wicket:id="feedback"></div>
+
+<p>
+Click on the area with a green outline to begin an inplace edit. Press enter 
or outside the area to save, pless esc to cancel.
+</p>
+
+<p>
+The quick brown <span wicket:id="text1" class="inline-edit">[[editable label 
1]]</span> jumped over the lazy <span wicket:id="text2" 
class="inline-edit">[[editable label 2]]</span>.
+</p>
+
+<p>
+click to edit multiple lines: <div wicket:id="text3" 
class="inline-edit">[[multiple lines]]</div>
+</p>
+
+<p>
+click to select another site: <span wicket:id="site" 
class="inline-edit">[[site selection]]</span>
+</p>
+
+<p>
+click to enter another number: <span wicket:id="number" 
class="inline-edit">[[number selection]]</span>
+</p>
+
+<br/><br/>
+This page has been refreshed: <span wicket:id="refresh-counter">[[refresh 
count]]</span> times.
+<a href="#" wicket:id="refresh-link">refresh</a>
+</form>
+
+</wicket:extend>
\ No newline at end of file
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.java
new file mode 100644
index 0000000000..dd4f4bf358
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import java.util.List;
+
+import org.apache.wicket.markup.html.panel.FeedbackPanel;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+/**
+ * The in-place editors of {@link 
org.apache.wicket.examples.ajax.builtin.EditableLabelPage},
+ * built.
+ */
+public class EditableLabelPage extends BasePage
+{
+       private static final long serialVersionUID = 1L;
+
+       private static final List<String> SITES = List.of("The Server Side", 
"Java Lobby",
+               "Java.Net");
+
+       public EditableLabelPage()
+       {
+               IModel<Integer> refreshes = Model.of(0);
+               FeedbackPanel feedback = 
feedback("feedback").outputMarkupId().build();
+
+               add(form("form").body(form -> form
+                       .add(feedback)
+                       .add(editableLabel("text1", Model.of("fox")),
+                               editableLabel("text2", Model.of("dog")),
+                               editableMultiLineLabel("text3", 
Model.of("multiple\nlines of\ntextual content")),
+                               editableChoiceLabel("site", 
Model.of(SITES.get(0)), SITES),
+                               editableLabel("number", Model.of(42))
+                                       .converter(converter(Integer::parseInt, 
number -> String.format("%010d", number)))
+                                       .apply(number -> 
number.setType(Integer.class)
+                                               .setRequired(true)
+                                               .setLabel(Model.of("Number")))
+                                       .onSubmit(target -> 
target.add(feedback))
+                                       .onError(target -> 
target.add(feedback)),
+                               label("refresh-counter", refreshes),
+                               link("refresh-link").onClick(() -> 
refreshes.setObject(refreshes.getObject() + 1)))));
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.properties
new file mode 100644
index 0000000000..ba20ce7b9e
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/EditableLabelPage.properties
@@ -0,0 +1,16 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with
+#  the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+title=Editable labels, built
+description=the in-place editors from wicket-extensions, reached through the 
extensions vocabulary next to the core one.
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.html
new file mode 100644
index 0000000000..79813fdcca
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.html
@@ -0,0 +1,29 @@
+<html xmlns:wicket="http://wicket.apache.org";>
+<body>
+  <wicket:extend>
+
+  <form wicket:id = "commentForm" id = "commentForm">
+    Add your comment here:
+    <p>
+    <textarea wicket:id = "text">This is a comment</textarea>
+    </p>
+    <input type="text" wicket:id="comment" class="nospam" 
onfocus="getElementById('formsubmit').focus();"/>
+    <br />
+    <input type = "submit" value = "Submit" id="formsubmit"/>
+  </form>
+  <p/>
+  <span wicket:id = "comments" id="comments">
+    <p>
+           <span wicket:id="date">1/1/2004</span><br />
+           <span wicket:id="text">Comment text goes here.</span>
+       </p>
+  </span>
+  <wicket:remove>
+    <p>
+           1/2/2004<br/>
+           More comment text here.
+    </p>
+  </wicket:remove>
+</wicket:extend>
+</body>
+</html>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.java
new file mode 100644
index 0000000000..95a708aff6
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import org.apache.wicket.examples.guestbook.Comment;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.util.string.Strings;
+import org.apache.wicket.validation.IValidatable;
+import org.apache.wicket.validation.ValidationError;
+
+/**
+ * The guestbook of {@link org.apache.wicket.examples.guestbook.GuestBook}, 
built.
+ */
+public class GuestBook extends BasePage
+{
+       private static final long serialVersionUID = 1L;
+
+       private static final List<Comment> comments = new ArrayList<>();
+
+       public GuestBook()
+       {
+               IModel<Comment> draft = Model.of(new Comment());
+
+               add(form("commentForm", draft)
+                               .body(form -> form.add(
+                                       textArea("text", 
form.bind(Comment::getText, Comment::setText)),
+                                       textField("comment", Model.<String> 
of())
+                                               .apply(honeypot -> 
honeypot.add(GuestBook::rejectSpam))))
+                               .onSubmit(GuestBook::save),
+                       list("comments", () -> comments)
+                               .each(row -> row.add(
+                                       label("date", 
row.bind(Comment::getDate)),
+                                       multiLineLabel("text", 
row.bind(Comment::getText))))
+                               .apply(list -> list.setVersioned(false)));
+       }
+
+       private static void save(Comment draft)
+       {
+               Comment comment = new Comment(draft);
+               comment.setDate(new Date());
+               comments.add(0, comment);
+               draft.setText("");
+       }
+
+       private static void rejectSpam(IValidatable<String> honeypot)
+       {
+               if (!Strings.isEmpty(honeypot.getValue()))
+               {
+                       honeypot.error(new ValidationError("Caught a 
spammer!!!"));
+               }
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.properties
new file mode 100644
index 0000000000..b9c6cb65c3
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/GuestBook.properties
@@ -0,0 +1,16 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with
+#  the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+title=Guestbook, built
+description=the guestbook example rewritten with the builder vocabulary, 
binding form fields and list rows through method references.
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.html 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.html
new file mode 100644
index 0000000000..b300a7aff9
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.html
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<wicket:extend xmlns:wicket="http://wicket.apache.org";>
+
+<div wicket:id="examples">[examples]</div>
+
+</wicket:extend>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.java 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.java
new file mode 100644
index 0000000000..f8e179ba3a
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import org.apache.wicket.examples.ExampleIndexPanel;
+
+/**
+ * Lists the examples of the builder spike.
+ */
+public class Index extends BasePage
+{
+       private static final long serialVersionUID = 1L;
+
+       public Index()
+       {
+               add(new ExampleIndexPanel("examples", Index.class));
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.properties
new file mode 100644
index 0000000000..917104b88d
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Index.properties
@@ -0,0 +1,15 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with
+#  the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+title=Builder API Spike
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.html
new file mode 100644
index 0000000000..b2b6f737fe
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.html
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<html xmlns:wicket="http://wicket.apache.org";>
+<head>
+</head>
+<body>
+  <wicket:extend>
+    <a href="#" wicket:id="ajaxback">Ajax Back</a>
+       <div wicket:id="showItems">
+               <div class="item" wicket:id="item">
+                       <div class="itemCheck"><input type="checkbox" 
wicket:id="check" /></div>
+                       <div class="itemText"><span wicket:id="text">item 
text</span></div>
+               </div>
+       </div>
+
+       <div class="item">
+               <div class="itemCheck">&#160;</div>
+               <div class="itemText" wicket:id="addItems">
+                       <a href="#" wicket:id="link">New 
item</a>&#160;&#160;&#160;&#160;<a href="#" wicket:id="remove">Remove 
completed</a>
+                       <form wicket:id="form">
+                               <input type="text" wicket:id="text" /> 
+                               <input type="button" wicket:id="add" value="Add 
item" /> or 
+                               <input type="button" wicket:id="cancel" 
value="Cancel" />
+                       </form>
+               </div>
+       </div>
+  </wicket:extend>
+</body>
+</html>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.java
new file mode 100644
index 0000000000..6f50009acd
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.wicket.examples.ajax.builtin.TodoList.TodoItem;
+import org.apache.wicket.markup.head.CssHeaderItem;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.request.resource.CssResourceReference;
+
+/**
+ * The todo list of {@link org.apache.wicket.examples.ajax.builtin.TodoList}, 
built.
+ */
+public class TodoList extends BasePage
+{
+       private static final long serialVersionUID = 1L;
+
+       private final List<TodoItem> items = new ArrayList<>();
+
+       public TodoList()
+       {
+               IModel<Boolean> adding = Model.of(false);
+               IModel<Boolean> browsing = adding.map(a -> !a);
+               IModel<TodoItem> draft = Model.of(new TodoItem());
+
+               WebMarkupContainer showItems = container("showItems")
+                       .outputMarkupId()
+                       .body(container -> container.add(list("item", () -> 
items)
+                               .each(row -> row.add(
+                                       ajaxCheckBox("check", 
row.bind(TodoItem::isChecked, TodoItem::setChecked)),
+                                       label("text", 
row.bind(TodoItem::getText))))))
+                       .build();
+               add(showItems);
+
+               add(ajaxLink("ajaxback").onClick(target -> 
setResponsePage(getPage())),
+                       container("addItems")
+                               .outputMarkupId()
+                               .body(addItems -> addItems.add(
+                                       ajaxLink("link")
+                                               .visibleWhen(browsing)
+                                               .onClick(target -> {
+                                                       adding.setObject(true);
+                                                       
target.add(addItems.self());
+                                               }),
+                                       ajaxLink("remove")
+                                               .visibleWhen(browsing)
+                                               .onClick(target -> {
+                                                       
items.removeIf(TodoItem::isChecked);
+                                                       
target.add(addItems.self(), showItems);
+                                               }),
+                                       form("form", draft)
+                                               .visibleWhen(adding)
+                                               .body(form -> form.add(
+                                                       textField("text", 
form.bind(TodoItem::getText, TodoItem::setText)),
+                                                       
ajaxButton("add").onSubmit(target -> {
+                                                               items.add(new 
TodoItem(draft.getObject()));
+                                                               
draft.setObject(new TodoItem());
+                                                               
adding.setObject(false);
+                                                               
target.add(addItems.self(), showItems);
+                                                       }),
+                                                       
ajaxButton("cancel").onSubmit(target -> {
+                                                               
adding.setObject(false);
+                                                               
target.add(addItems.self());
+                                                       }))))));
+       }
+
+       @Override
+       public void renderHead(IHeaderResponse response)
+       {
+               super.renderHead(response);
+               response.render(CssHeaderItem.forReference(new 
CssResourceReference(
+                       org.apache.wicket.examples.ajax.builtin.TodoList.class, 
"todo.css")));
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.properties
new file mode 100644
index 0000000000..6078453043
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/TodoList.properties
@@ -0,0 +1,16 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with
+#  the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+title=Todo list, built
+description=the Ajax todo list rewritten with the builder vocabulary, with 
visibility driven by a model instead of overridden isVisible methods.
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Ui.java 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Ui.java
new file mode 100644
index 0000000000..f0478954e2
--- /dev/null
+++ b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/Ui.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import org.apache.wicket.examples.builder.spike.CoreUi;
+import org.apache.wicket.examples.builder.spike.extensions.ExtensionsUi;
+
+/**
+ * The application's single entry point: every module it uses, combined.
+ */
+public interface Ui extends CoreUi, ExtensionsUi
+{
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Builders.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Builders.java
new file mode 100644
index 0000000000..087760a02d
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Builders.java
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.ajax.markup.html.form.AjaxButton;
+import org.apache.wicket.ajax.markup.html.form.AjaxCheckBox;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.link.Link;
+import org.apache.wicket.markup.html.list.ListItem;
+import org.apache.wicket.markup.html.list.ListView;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.util.convert.ConversionException;
+import org.apache.wicket.util.convert.IConverter;
+import org.danekja.java.misc.serializable.SerializableRunnable;
+import org.danekja.java.util.function.serializable.SerializableConsumer;
+import org.danekja.java.util.function.serializable.SerializableFunction;
+
+/**
+ * SPIKE - throwaway. The builders behind {@link CoreUi}.
+ * <p>
+ * Every anonymous component subclass is created in a static method. An 
anonymous class created
+ * in an instance method keeps a reference to the builder, which would then be 
serialized with the
+ * page.
+ */
+public final class Builders
+{
+       private Builders()
+       {
+       }
+
+       public static final class Simple<C extends org.apache.wicket.Component>
+               extends ComponentBuilder<C, Simple<C>>
+       {
+               private final Function<String, C> factory;
+
+               public Simple(String id, Function<String, C> factory)
+               {
+                       super(id);
+                       this.factory = factory;
+               }
+
+               @Override
+               protected C create(String id)
+               {
+                       return factory.apply(id);
+               }
+       }
+
+       public abstract static class AbstractContainerBuilder<C extends 
MarkupContainer, T, B extends AbstractContainerBuilder<C, T, B>>
+               extends ComponentBuilder<C, B>
+       {
+               private Consumer<Scope<T>> body = scope -> {
+               };
+
+               protected AbstractContainerBuilder(String id)
+               {
+                       super(id);
+               }
+
+               public B body(Consumer<Scope<T>> body)
+               {
+                       this.body = body;
+                       return self();
+               }
+
+               @Override
+               @SuppressWarnings("unchecked")
+               public C build()
+               {
+                       C container = super.build();
+                       body.accept(new Scope<>(container, 
(IModel<T>)container.getDefaultModel()));
+                       return container;
+               }
+       }
+
+       public static final class ContainerBuilder<T>
+               extends AbstractContainerBuilder<WebMarkupContainer, T, 
ContainerBuilder<T>>
+       {
+               private final IModel<T> model;
+
+               ContainerBuilder(String id, IModel<T> model)
+               {
+                       super(id);
+                       this.model = model;
+               }
+
+               @Override
+               protected WebMarkupContainer create(String id)
+               {
+                       return new WebMarkupContainer(id, model);
+               }
+       }
+
+       public static final class FormBuilder<T>
+               extends AbstractContainerBuilder<Form<T>, T, FormBuilder<T>>
+       {
+               private final IModel<T> model;
+
+               private SerializableConsumer<T> onSubmit = object -> {
+               };
+
+               FormBuilder(String id, IModel<T> model)
+               {
+                       super(id);
+                       this.model = model;
+               }
+
+               public FormBuilder<T> onSubmit(SerializableConsumer<T> onSubmit)
+               {
+                       this.onSubmit = onSubmit;
+                       return this;
+               }
+
+               @Override
+               protected Form<T> create(String id)
+               {
+                       return newForm(id, model, onSubmit);
+               }
+
+               private static <T> Form<T> newForm(String id, IModel<T> model,
+                       SerializableConsumer<T> onSubmit)
+               {
+                       return new Form<>(id, model)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               protected void onSubmit()
+                               {
+                                       onSubmit.accept(getModelObject());
+                               }
+                       };
+               }
+       }
+
+       public static final class ListBuilder<T> extends 
ComponentBuilder<ListView<T>, ListBuilder<T>>
+       {
+               private final IModel<? extends List<T>> items;
+
+               private SerializableConsumer<Scope<T>> each = row -> {
+               };
+
+               ListBuilder(String id, IModel<? extends List<T>> items)
+               {
+                       super(id);
+                       this.items = items;
+               }
+
+               public ListBuilder<T> each(SerializableConsumer<Scope<T>> each)
+               {
+                       this.each = each;
+                       return this;
+               }
+
+               @Override
+               protected ListView<T> create(String id)
+               {
+                       return newListView(id, items, each);
+               }
+
+               private static <T> ListView<T> newListView(String id, IModel<? 
extends List<T>> items,
+                       SerializableConsumer<Scope<T>> each)
+               {
+                       return new ListView<>(id, items)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               protected void populateItem(ListItem<T> item)
+                               {
+                                       each.accept(new Scope<>(item, 
item.getModel()));
+                               }
+                       };
+               }
+       }
+
+       public static final class LinkBuilder extends 
ComponentBuilder<Link<Void>, LinkBuilder>
+       {
+               private SerializableRunnable onClick = () -> {
+               };
+
+               LinkBuilder(String id)
+               {
+                       super(id);
+               }
+
+               public LinkBuilder onClick(SerializableRunnable onClick)
+               {
+                       this.onClick = onClick;
+                       return this;
+               }
+
+               @Override
+               protected Link<Void> create(String id)
+               {
+                       return newLink(id, onClick);
+               }
+
+               private static Link<Void> newLink(String id, 
SerializableRunnable onClick)
+               {
+                       return new Link<>(id)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               public void onClick()
+                               {
+                                       onClick.run();
+                               }
+                       };
+               }
+       }
+
+       public static final class AjaxLinkBuilder extends 
ComponentBuilder<AjaxLink<Void>, AjaxLinkBuilder>
+       {
+               private SerializableConsumer<AjaxRequestTarget> onClick = 
target -> {
+               };
+
+               AjaxLinkBuilder(String id)
+               {
+                       super(id);
+               }
+
+               public AjaxLinkBuilder 
onClick(SerializableConsumer<AjaxRequestTarget> onClick)
+               {
+                       this.onClick = onClick;
+                       return this;
+               }
+
+               @Override
+               protected AjaxLink<Void> create(String id)
+               {
+                       return newAjaxLink(id, onClick);
+               }
+
+               private static AjaxLink<Void> newAjaxLink(String id,
+                       SerializableConsumer<AjaxRequestTarget> onClick)
+               {
+                       return new AjaxLink<>(id)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               public void onClick(AjaxRequestTarget target)
+                               {
+                                       onClick.accept(target);
+                               }
+                       };
+               }
+       }
+
+       public static final class AjaxButtonBuilder extends 
ComponentBuilder<AjaxButton, AjaxButtonBuilder>
+       {
+               private SerializableConsumer<AjaxRequestTarget> onSubmit = 
target -> {
+               };
+
+               private SerializableConsumer<AjaxRequestTarget> onError = 
target -> {
+               };
+
+               AjaxButtonBuilder(String id)
+               {
+                       super(id);
+               }
+
+               public AjaxButtonBuilder 
onSubmit(SerializableConsumer<AjaxRequestTarget> onSubmit)
+               {
+                       this.onSubmit = onSubmit;
+                       return this;
+               }
+
+               public AjaxButtonBuilder 
onError(SerializableConsumer<AjaxRequestTarget> onError)
+               {
+                       this.onError = onError;
+                       return this;
+               }
+
+               @Override
+               protected AjaxButton create(String id)
+               {
+                       return newAjaxButton(id, onSubmit, onError);
+               }
+
+               private static AjaxButton newAjaxButton(String id,
+                       SerializableConsumer<AjaxRequestTarget> onSubmit,
+                       SerializableConsumer<AjaxRequestTarget> onError)
+               {
+                       return new AjaxButton(id)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               protected void onSubmit(AjaxRequestTarget 
target)
+                               {
+                                       onSubmit.accept(target);
+                               }
+
+                               @Override
+                               protected void onError(AjaxRequestTarget target)
+                               {
+                                       onError.accept(target);
+                               }
+                       };
+               }
+       }
+
+       public static final class AjaxCheckBoxBuilder
+               extends ComponentBuilder<AjaxCheckBox, AjaxCheckBoxBuilder>
+       {
+               private final IModel<Boolean> model;
+
+               private SerializableConsumer<AjaxRequestTarget> onUpdate = 
target -> {
+               };
+
+               AjaxCheckBoxBuilder(String id, IModel<Boolean> model)
+               {
+                       super(id);
+                       this.model = model;
+               }
+
+               public AjaxCheckBoxBuilder 
onUpdate(SerializableConsumer<AjaxRequestTarget> onUpdate)
+               {
+                       this.onUpdate = onUpdate;
+                       return this;
+               }
+
+               @Override
+               protected AjaxCheckBox create(String id)
+               {
+                       return newAjaxCheckBox(id, model, onUpdate);
+               }
+
+               private static AjaxCheckBox newAjaxCheckBox(String id, 
IModel<Boolean> model,
+                       SerializableConsumer<AjaxRequestTarget> onUpdate)
+               {
+                       return new AjaxCheckBox(id, model)
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               protected void onUpdate(AjaxRequestTarget 
target)
+                               {
+                                       onUpdate.accept(target);
+                               }
+                       };
+               }
+       }
+
+       static <T> IConverter<T> converter(SerializableFunction<String, T> 
parse,
+               SerializableFunction<T, String> format)
+       {
+               return new IConverter<>()
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public T convertToObject(String value, Locale locale) 
throws ConversionException
+                       {
+                               try
+                               {
+                                       return parse.apply(value);
+                               }
+                               catch (RuntimeException e)
+                               {
+                                       throw new 
ConversionException(e).setSourceValue(value)
+                                               .setConverter(this)
+                                               .setLocale(locale);
+                               }
+                       }
+
+                       @Override
+                       public String convertToString(T value, Locale locale)
+                       {
+                               return format.apply(value);
+                       }
+               };
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/ComponentBuilder.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/ComponentBuilder.java
new file mode 100644
index 0000000000..f803362af6
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/ComponentBuilder.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.behavior.Behavior;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.util.lang.Args;
+
+/**
+ * SPIKE - throwaway. Base of every builder: creates the component when it is 
added and then
+ * applies the configured steps in order.
+ * <p>
+ * A builder is used once, while the page is being constructed, and is never 
stored in the
+ * component tree. The steps are therefore plain, non-serializable consumers 
and may capture
+ * anything. Lambdas a builder hands to the component it creates, such as 
event handlers, are
+ * serializable and become part of the page.
+ *
+ * @param <C>
+ *            the type of the component built
+ * @param <B>
+ *            the type of the builder itself
+ */
+public abstract class ComponentBuilder<C extends Component, B extends 
ComponentBuilder<C, B>>
+{
+       private final String id;
+
+       private final List<Consumer<? super C>> steps = new ArrayList<>();
+
+       protected ComponentBuilder(String id)
+       {
+               this.id = Args.notNull(id, "id");
+       }
+
+       protected abstract C create(String id);
+
+       public B with(Behavior... behaviors)
+       {
+               return apply(component -> component.add(behaviors));
+       }
+
+       public B apply(Consumer<? super C> customizer)
+       {
+               steps.add(customizer);
+               return self();
+       }
+
+       public B outputMarkupId()
+       {
+               return apply(component -> component.setOutputMarkupId(true));
+       }
+
+       public B visibleWhen(IModel<Boolean> condition)
+       {
+               return with(new VisibleWhen(condition));
+       }
+
+       public C build()
+       {
+               C component = create(id);
+               steps.forEach(step -> step.accept(component));
+               return component;
+       }
+
+       @SuppressWarnings("unchecked")
+       protected final B self()
+       {
+               return (B)this;
+       }
+
+       private static final class VisibleWhen extends Behavior
+       {
+               private static final long serialVersionUID = 1L;
+
+               private final IModel<Boolean> condition;
+
+               private VisibleWhen(IModel<Boolean> condition)
+               {
+                       this.condition = Args.notNull(condition, "condition");
+               }
+
+               @Override
+               public void onConfigure(Component component)
+               {
+                       
component.setVisible(Boolean.TRUE.equals(condition.getObject()));
+               }
+
+               @Override
+               public void detach(Component component)
+               {
+                       condition.detach();
+               }
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/CoreUi.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/CoreUi.java
new file mode 100644
index 0000000000..a23dff3688
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/CoreUi.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike;
+
+import java.util.List;
+
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.examples.builder.spike.Builders.AjaxButtonBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.AjaxCheckBoxBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.AjaxLinkBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.ContainerBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.FormBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.LinkBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.ListBuilder;
+import org.apache.wicket.examples.builder.spike.Builders.Simple;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.basic.MultiLineLabel;
+import org.apache.wicket.markup.html.form.TextArea;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.markup.html.panel.FeedbackPanel;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.util.convert.IConverter;
+import org.danekja.java.util.function.serializable.SerializableFunction;
+
+/**
+ * SPIKE - throwaway. The vocabulary wicket-core would ship: one entry point 
to its components,
+ * for a page or panel to implement, alone or combined with the vocabularies 
of other modules.
+ */
+public interface CoreUi
+{
+       default MarkupContainer add(ComponentBuilder<?, ?>... builders)
+       {
+               MarkupContainer container = (MarkupContainer)this;
+               for (ComponentBuilder<?, ?> builder : builders)
+               {
+                       container.add(builder.build());
+               }
+               return container;
+       }
+
+       default Simple<Label> label(String id, IModel<?> model)
+       {
+               return new Simple<>(id, i -> new Label(i, model));
+       }
+
+       default Simple<MultiLineLabel> multiLineLabel(String id, IModel<?> 
model)
+       {
+               return new Simple<>(id, i -> new MultiLineLabel(i, model));
+       }
+
+       default <T> Simple<TextField<T>> textField(String id, IModel<T> model)
+       {
+               return new Simple<>(id, i -> new TextField<>(i, model));
+       }
+
+       default <T> Simple<TextArea<T>> textArea(String id, IModel<T> model)
+       {
+               return new Simple<>(id, i -> new TextArea<>(i, model));
+       }
+
+       default Simple<FeedbackPanel> feedback(String id)
+       {
+               return new Simple<>(id, FeedbackPanel::new);
+       }
+
+       default ContainerBuilder<Void> container(String id)
+       {
+               return new ContainerBuilder<>(id, null);
+       }
+
+       default <T> ContainerBuilder<T> container(String id, IModel<T> model)
+       {
+               return new ContainerBuilder<>(id, model);
+       }
+
+       default FormBuilder<Void> form(String id)
+       {
+               return new FormBuilder<>(id, null);
+       }
+
+       default <T> FormBuilder<T> form(String id, IModel<T> model)
+       {
+               return new FormBuilder<>(id, model);
+       }
+
+       default <T> ListBuilder<T> list(String id, IModel<? extends List<T>> 
items)
+       {
+               return new ListBuilder<>(id, items);
+       }
+
+       default LinkBuilder link(String id)
+       {
+               return new LinkBuilder(id);
+       }
+
+       default AjaxLinkBuilder ajaxLink(String id)
+       {
+               return new AjaxLinkBuilder(id);
+       }
+
+       default AjaxButtonBuilder ajaxButton(String id)
+       {
+               return new AjaxButtonBuilder(id);
+       }
+
+       default AjaxCheckBoxBuilder ajaxCheckBox(String id, IModel<Boolean> 
model)
+       {
+               return new AjaxCheckBoxBuilder(id, model);
+       }
+
+       default <T> IConverter<T> converter(SerializableFunction<String, T> 
parse,
+               SerializableFunction<T, String> format)
+       {
+               return Builders.converter(parse, format);
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Scope.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Scope.java
new file mode 100644
index 0000000000..1a8f4f176a
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/Scope.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.LambdaModel;
+import org.apache.wicket.util.io.IClusterable;
+import org.danekja.java.util.function.serializable.SerializableBiConsumer;
+import org.danekja.java.util.function.serializable.SerializableFunction;
+
+/**
+ * SPIKE - throwaway. A container being filled, together with its model.
+ * <p>
+ * Children bind to the container's model with {@link #bind}: the getter and 
setter receive the
+ * model object as a parameter, so a method reference such as {@code 
Person::getName} captures
+ * nothing and the object itself stays in the model, where it is detached.
+ *
+ * @param <T>
+ *            the type of the container's model object
+ */
+public final class Scope<T> implements IClusterable
+{
+       private static final long serialVersionUID = 1L;
+
+       private final MarkupContainer container;
+
+       private final IModel<T> model;
+
+       Scope(MarkupContainer container, IModel<T> model)
+       {
+               this.container = container;
+               this.model = model;
+       }
+
+       public MarkupContainer self()
+       {
+               return container;
+       }
+
+       public IModel<T> model()
+       {
+               return model;
+       }
+
+       public <R> IModel<R> bind(SerializableFunction<T, R> getter)
+       {
+               return model.map(getter);
+       }
+
+       public <R> IModel<R> bind(SerializableFunction<T, R> getter, 
SerializableBiConsumer<T, R> setter)
+       {
+               return LambdaModel.of(model, getter, setter);
+       }
+
+       public Scope<T> add(ComponentBuilder<?, ?>... builders)
+       {
+               for (ComponentBuilder<?, ?> builder : builders)
+               {
+                       container.add(builder.build());
+               }
+               return this;
+       }
+
+       public Scope<T> add(Component... components)
+       {
+               container.add(components);
+               return this;
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/EditableLabelBuilder.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/EditableLabelBuilder.java
new file mode 100644
index 0000000000..1dc818e4d7
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/EditableLabelBuilder.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike.extensions;
+
+import java.util.List;
+
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.examples.builder.spike.ComponentBuilder;
+import org.apache.wicket.extensions.ajax.markup.html.AjaxEditableChoiceLabel;
+import org.apache.wicket.extensions.ajax.markup.html.AjaxEditableLabel;
+import 
org.apache.wicket.extensions.ajax.markup.html.AjaxEditableMultiLineLabel;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.util.convert.IConverter;
+import org.apache.wicket.util.io.IClusterable;
+import org.danekja.java.util.function.serializable.SerializableConsumer;
+
+/**
+ * SPIKE - throwaway. Builds the three in-place editors of wicket-extensions.
+ * <p>
+ * Each editor needs an anonymous subclass to hook into submit, error and 
conversion, so the hooks
+ * travel in a serializable {@link Hooks} object that the subclass consults.
+ *
+ * @param <T>
+ *            the type of the edited value
+ */
+public final class EditableLabelBuilder<T>
+       extends ComponentBuilder<AjaxEditableLabel<T>, EditableLabelBuilder<T>>
+{
+       private enum Kind
+       {
+               SINGLE_LINE, MULTI_LINE, CHOICE
+       }
+
+       private static final class Hooks<T> implements IClusterable
+       {
+               private static final long serialVersionUID = 1L;
+
+               private SerializableConsumer<AjaxRequestTarget> onSubmit = 
target -> {
+               };
+
+               private SerializableConsumer<AjaxRequestTarget> onError = 
target -> {
+               };
+
+               private IConverter<T> converter;
+       }
+
+       private final Kind kind;
+
+       private final IModel<T> model;
+
+       private final List<? extends T> choices;
+
+       private final Hooks<T> hooks = new Hooks<>();
+
+       private EditableLabelBuilder(String id, Kind kind, IModel<T> model, 
List<? extends T> choices)
+       {
+               super(id);
+               this.kind = kind;
+               this.model = model;
+               this.choices = choices;
+       }
+
+       static <T> EditableLabelBuilder<T> singleLine(String id, IModel<T> 
model)
+       {
+               return new EditableLabelBuilder<>(id, Kind.SINGLE_LINE, model, 
null);
+       }
+
+       static <T> EditableLabelBuilder<T> multiLine(String id, IModel<T> model)
+       {
+               return new EditableLabelBuilder<>(id, Kind.MULTI_LINE, model, 
null);
+       }
+
+       static <T> EditableLabelBuilder<T> choice(String id, IModel<T> model,
+               List<? extends T> choices)
+       {
+               return new EditableLabelBuilder<>(id, Kind.CHOICE, model, 
choices);
+       }
+
+       public EditableLabelBuilder<T> 
onSubmit(SerializableConsumer<AjaxRequestTarget> onSubmit)
+       {
+               hooks.onSubmit = onSubmit;
+               return this;
+       }
+
+       public EditableLabelBuilder<T> 
onError(SerializableConsumer<AjaxRequestTarget> onError)
+       {
+               hooks.onError = onError;
+               return this;
+       }
+
+       public EditableLabelBuilder<T> converter(IConverter<T> converter)
+       {
+               hooks.converter = converter;
+               return this;
+       }
+
+       @Override
+       protected AjaxEditableLabel<T> create(String id)
+       {
+               return switch (kind)
+               {
+                       case SINGLE_LINE -> newSingleLine(id, model, hooks);
+                       case MULTI_LINE -> newMultiLine(id, model, hooks);
+                       case CHOICE -> newChoice(id, model, choices, hooks);
+               };
+       }
+
+       private static <T> AjaxEditableLabel<T> newSingleLine(String id, 
IModel<T> model,
+               Hooks<T> hooks)
+       {
+               return new AjaxEditableLabel<>(id, model)
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onSubmit(AjaxRequestTarget target)
+                       {
+                               super.onSubmit(target);
+                               hooks.onSubmit.accept(target);
+                       }
+
+                       @Override
+                       protected void onError(AjaxRequestTarget target)
+                       {
+                               super.onError(target);
+                               hooks.onError.accept(target);
+                       }
+
+                       @Override
+                       protected IConverter<?> createConverter(Class<?> type)
+                       {
+                               return hooks.converter != null ? 
hooks.converter : super.createConverter(type);
+                       }
+               };
+       }
+
+       private static <T> AjaxEditableLabel<T> newMultiLine(String id, 
IModel<T> model,
+               Hooks<T> hooks)
+       {
+               return new AjaxEditableMultiLineLabel<>(id, model)
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onSubmit(AjaxRequestTarget target)
+                       {
+                               super.onSubmit(target);
+                               hooks.onSubmit.accept(target);
+                       }
+
+                       @Override
+                       protected void onError(AjaxRequestTarget target)
+                       {
+                               super.onError(target);
+                               hooks.onError.accept(target);
+                       }
+
+                       @Override
+                       protected IConverter<?> createConverter(Class<?> type)
+                       {
+                               return hooks.converter != null ? 
hooks.converter : super.createConverter(type);
+                       }
+               };
+       }
+
+       private static <T> AjaxEditableLabel<T> newChoice(String id, IModel<T> 
model,
+               List<? extends T> choices, Hooks<T> hooks)
+       {
+               return new AjaxEditableChoiceLabel<>(id, model, choices)
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onSubmit(AjaxRequestTarget target)
+                       {
+                               super.onSubmit(target);
+                               hooks.onSubmit.accept(target);
+                       }
+
+                       @Override
+                       protected void onError(AjaxRequestTarget target)
+                       {
+                               super.onError(target);
+                               hooks.onError.accept(target);
+                       }
+
+                       @Override
+                       protected IConverter<?> createConverter(Class<?> type)
+                       {
+                               return hooks.converter != null ? 
hooks.converter : super.createConverter(type);
+                       }
+               };
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/ExtensionsUi.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/ExtensionsUi.java
new file mode 100644
index 0000000000..9620c5d4a4
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/builder/spike/extensions/ExtensionsUi.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder.spike.extensions;
+
+import java.util.List;
+
+import org.apache.wicket.model.IModel;
+
+/**
+ * SPIKE - throwaway. The vocabulary wicket-extensions would ship. Core does 
not know it exists; an
+ * application combines the two in an interface of its own.
+ */
+public interface ExtensionsUi
+{
+       default <T> EditableLabelBuilder<T> editableLabel(String id, IModel<T> 
model)
+       {
+               return EditableLabelBuilder.singleLine(id, model);
+       }
+
+       default <T> EditableLabelBuilder<T> editableMultiLineLabel(String id, 
IModel<T> model)
+       {
+               return EditableLabelBuilder.multiLine(id, model);
+       }
+
+       default <T> EditableLabelBuilder<T> editableChoiceLabel(String id, 
IModel<T> model,
+               List<? extends T> choices)
+       {
+               return EditableLabelBuilder.choice(id, model, choices);
+       }
+}
diff --git 
a/wicket-examples/src/main/resources/org/apache/wicket/examples/homepage/HomePage.html
 
b/wicket-examples/src/main/resources/org/apache/wicket/examples/homepage/HomePage.html
index f5fe22d008..17224ff845 100644
--- 
a/wicket-examples/src/main/resources/org/apache/wicket/examples/homepage/HomePage.html
+++ 
b/wicket-examples/src/main/resources/org/apache/wicket/examples/homepage/HomePage.html
@@ -30,6 +30,7 @@
        <tr><td align="right"><a href="captchas">Captcha</a></td><td> - Telling 
humans from spammers.</td></tr>
        <tr><td align="right"><a href="datetime">Dates and times</a></td><td> - 
Date components example.</td></tr>
 
+       <tr class="section"><td align="right"><a href="builder">Builder API 
spike</a></td><td> - Guestbook, todo list and editable labels rewritten with a 
fluent builder vocabulary.</td></tr>
        <tr class="section"><td align="right"><a 
href="guestbook">Guestbook</a></td><td> - A blog-like multi-user 
guestbook.</td></tr>
        <tr><td align="right"><a href="hangman">Hangman</a></td><td> - The game 
of hangman.</td></tr>
        <tr><td align="right"><a href="library">Library</a></td><td> - A simple 
application.</td></tr>
diff --git a/wicket-examples/src/main/webapp/WEB-INF/web.xml 
b/wicket-examples/src/main/webapp/WEB-INF/web.xml
index c11a872f60..9d1a34d176 100644
--- a/wicket-examples/src/main/webapp/WEB-INF/web.xml
+++ b/wicket-examples/src/main/webapp/WEB-INF/web.xml
@@ -149,6 +149,15 @@
                </init-param>
        </filter>
 
+       <filter>
+               <filter-name>BuilderApplication</filter-name>
+               
<filter-class>org.apache.wicket.protocol.http.WicketFilter</filter-class>
+               <init-param>
+                 <param-name>applicationClassName</param-name>
+                 
<param-value>org.apache.wicket.examples.builder.BuilderApplication</param-value>
+               </init-param>
+       </filter>
+
        <filter>
                <filter-name>GuestBookApplication</filter-name>
                
<filter-class>org.apache.wicket.protocol.http.WicketFilter</filter-class>
@@ -627,6 +636,13 @@
                <dispatcher>INCLUDE</dispatcher>
        </filter-mapping>
 
+       <filter-mapping>
+               <filter-name>BuilderApplication</filter-name>
+               <url-pattern>/builder/*</url-pattern>
+               <dispatcher>REQUEST</dispatcher>
+               <dispatcher>INCLUDE</dispatcher>
+       </filter-mapping>
+
        <filter-mapping>
                <filter-name>GuestBookApplication</filter-name>
                <url-pattern>/guestbook/*</url-pattern>
diff --git 
a/wicket-examples/src/test/java/org/apache/wicket/examples/builder/BuilderSpikeTest.java
 
b/wicket-examples/src/test/java/org/apache/wicket/examples/builder/BuilderSpikeTest.java
new file mode 100644
index 0000000000..f149d123cf
--- /dev/null
+++ 
b/wicket-examples/src/test/java/org/apache/wicket/examples/builder/BuilderSpikeTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.examples.builder;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+import org.apache.wicket.Page;
+import org.apache.wicket.ajax.markup.html.form.AjaxCheckBox;
+import org.apache.wicket.core.util.objects.checker.AbstractObjectChecker;
+import org.apache.wicket.core.util.objects.checker.CheckingObjectOutputStream;
+import org.apache.wicket.core.util.objects.checker.NotDetachedModelChecker;
+import org.apache.wicket.core.util.objects.checker.ObjectSerializationChecker;
+import org.apache.wicket.examples.builder.spike.ComponentBuilder;
+import org.apache.wicket.util.tester.FormTester;
+import org.apache.wicket.util.tester.WicketTestCase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * SPIKE - throwaway. Drives the built pages and reports how they serialize 
next to the originals.
+ */
+class BuilderSpikeTest extends WicketTestCase
+{
+       @Test
+       void guestBook() throws IOException
+       {
+               tester.startPage(GuestBook.class);
+               FormTester form = tester.newFormTester("commentForm");
+               form.setValue("text", "first");
+               form.submit();
+               tester.assertModelValue("comments:0:text", "first");
+
+               form = tester.newFormTester("commentForm");
+               form.setValue("text", "spam");
+               form.setValue("comment", "filled");
+               form.submit();
+               tester.assertErrorMessages("Caught a spammer!!!");
+               tester.assertModelValue("comments:0:text", "first");
+
+               check(tester.getLastRenderedPage());
+               
tester.startPage(org.apache.wicket.examples.guestbook.GuestBook.class);
+               report("GuestBook", tester.getLastRenderedPage(), 
GuestBook.class);
+       }
+
+       @Test
+       void todoList() throws IOException
+       {
+               tester.startPage(TodoList.class);
+               tester.assertInvisible("addItems:form");
+
+               tester.clickLink("addItems:link", true);
+               tester.assertVisible("addItems:form");
+               tester.assertInvisible("addItems:link");
+
+               FormTester form = tester.newFormTester("addItems:form");
+               form.setValue("text", "write the spike");
+               tester.executeAjaxEvent("addItems:form:add", "click");
+               tester.assertModelValue("showItems:item:0:text", "write the 
spike");
+               tester.assertInvisible("addItems:form");
+
+               AjaxCheckBox check = 
(AjaxCheckBox)tester.getComponentFromLastRenderedPage(
+                       "showItems:item:0:check");
+               check.setModelObject(true);
+               tester.clickLink("addItems:remove", true);
+               tester.assertModelValue("showItems:item", List.of());
+
+               check(tester.getLastRenderedPage());
+               
tester.startPage(org.apache.wicket.examples.ajax.builtin.TodoList.class);
+               report("TodoList", tester.getLastRenderedPage(), 
TodoList.class);
+       }
+
+       @Test
+       void editableLabels() throws IOException
+       {
+               tester.startPage(EditableLabelPage.class);
+               tester.assertContains("0000000042");
+
+               tester.clickLink("form:refresh-link");
+               tester.assertLabel("form:refresh-counter", "1");
+
+               check(tester.getLastRenderedPage());
+               
tester.startPage(org.apache.wicket.examples.ajax.builtin.EditableLabelPage.class);
+               report("EditableLabelPage", tester.getLastRenderedPage(), 
EditableLabelPage.class);
+       }
+
+       private void check(Page page) throws IOException
+       {
+               page.detach();
+               try (CheckingObjectOutputStream out = new 
CheckingObjectOutputStream(
+                       new ByteArrayOutputStream(), new 
ObjectSerializationChecker(),
+                       new NotDetachedModelChecker(), new NoBuilderChecker()))
+               {
+                       out.writeObject(page);
+               }
+       }
+
+       private void report(String name, Page original, Class<? extends Page> 
built)
+       {
+               Page builtPage = tester.startPage(built);
+               byte[] originalBytes = serialize(original);
+               byte[] builtBytes = serialize(builtPage);
+               System.out.printf("SPIKE %-18s original %6d bytes, built %6d 
bytes%n", name,
+                       originalBytes.length, builtBytes.length);
+               assertTrue(builtBytes.length > 0);
+       }
+
+       private static final class NoBuilderChecker extends 
AbstractObjectChecker
+       {
+               @Override
+               protected Result doCheck(Object object)
+               {
+                       return object instanceof ComponentBuilder
+                               ? new Result(Result.Status.FAILURE, "a builder 
ended up in the page")
+                               : Result.SUCCESS;
+               }
+       }
+
+       private byte[] serialize(Page page)
+       {
+               page.detach();
+               return 
tester.getApplication().getFrameworkSettings().getSerializer().serialize(page);
+       }
+}

Reply via email to