Add the basic support for writing components in node.js
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 7 Jun 2018 18:25:35 +0000 (18:25 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 7 Jun 2018 18:25:35 +0000 (18:25 +0000)
https://bugs.webkit.org/show_bug.cgi?id=186299

Reviewed by Antti Koivisto.

Add the basic support for writing components in node.js for generating rich email notifications.

To do this, this patch introduces MarkupComponentBase and MarkupPage which implement similar API
to ComponentBase and Page classes of v3 UI code. This enables us to share code between frontend
and the backend in the future. Because there is no support for declarative custom elements or
shadow root in HTML, MarkupComponentBase uses a similar but distinct concept of "content" tree
to represent the "DOM" tree for a component. When generating the HTML, MarkupComponentBase and
MarkupPage collectively transforms stylesheets and flattens the tree into a single HTML. In order
to keep this flatteneing logic simple, MarkupComponentBase only supports a very small subset of
CSS selectors to select elements by their local names and class names.

Specifically, each class name and element name based selectors are replaced by a globally unique
class name based selector, and each element which matches the selector is applied of the same
globally unique class name. The transformation is applied when constructing the "content" tree
as well as calls to renderReplace.

Because much of v3 frontend code relies on DOM API, this patch also implements the simplest form
of a fake DOM API as MarkupNode, MarkupParentNode, MarkupElement, and MarkupText. In order to avoid
reimplementing HTML & CSS parsers, this patch introduces the concept of content and style templates
to ComponentBase which are JSON alternatives to HTML & CSS template strings which can be used in
both frontend & backend.

* browser-tests/close-button-tests.js: Include CommonComponentBase.
* browser-tests/commit-log-viewer-tests.js: Ditto.
* browser-tests/component-base-tests.js: Ditto. Added a test cases for content & style templates.
(async.importComponentBase): Added.
* browser-tests/editable-text-tests.js: Include CommonComponentBase.
* browser-tests/index.html:
* browser-tests/markup-page-tests.js: Added.
* browser-tests/page-router-tests.js: Include CommonComponentBase.
* browser-tests/page-tests.js: Ditto.
* browser-tests/test-group-form-tests.js: Ditto.
* public/shared/common-component-base.js: Added.
(CommonComponentBase): Extracted out of ComponentBase.
(CommonComponentBase.prototype.renderReplace): Added.
(CommonComponentBase.renderReplace): Moved from ComponentBase.
(CommonComponentBase.prototype._recursivelyUpgradeUnknownElements): Moved and renamed from
ComponentBase's _recursivelyReplaceUnknownElementsByComponents.
(CommonComponentBase.prototype._upgradeUnknownElement): Extracted out of the same function.
(CommonComponentBase._constructStylesheetFromTemplate): Added.
(CommonComponentBase._constructNodeTreeFromTemplate): Added.
(CommonComponentBase.prototype.createElement): Added.
(CommonComponentBase.createElement): Moved from ComponentBase.
(CommonComponentBase._addContentToElement): Moved from ComponentBase.
(CommonComponentBase.prototype.createLink): Added.
(CommonComponentBase.createLink): Moved from ComponentBase.
(CommonComponentBase._context): Added. Set to document in a browser and MarkupDocument in node.js.
(CommonComponentBase._isNode): Added. Set to a function which does instanceof Node/MarkupNode check.
(CommonComponentBase._baseClass): Added. Set to ComponentBase or MarkupComponentBase.
* public/v3/components/base.js:
(ComponentBase):
(ComponentBase.prototype._ensureShadowTree): Added the support for the content and style templates.
Also avoid parsing the html template each time a component is instantiated by caching the result.
* public/v3/index.html:
* tools/js/markup-component.js: Added.
(MarkupDocument): Added. A fake Document.
(MarkupDocument.prototype.createContentRoot): A substitude for attachShadow.
(MarkupDocument.prototype.createElement):
(MarkupDocument.prototype.createTextNode):
(MarkupDocument.prototype._idForClone):
(MarkupDocument.prototype.reset):
(MarkupDocument.prototype.markup):
(MarkupDocument.prototype.escapeAttributeValue):
(MarkupDocument.prototype.escapeNodeData):
(MarkupNode): Added. A fake Node. Each node gets an unique ID.
(MarkupNode.prototype._markup):
(MarkupNode.prototype.clone): Implemented by the leave class.
(MarkupNode.prototype._cloneNodeData):
(MarkupNode.prototype.remove):
(MarkupParentNode): Added. An equivalent of ContainerNode in WebCore.
(MarkupParentNode.prototype.get childNodes):
(MarkupParentNode.prototype._cloneNodeData):
(MarkupParentNode.prototype.appendChild):
(MarkupParentNode.prototype.removeChild):
(MarkupParentNode.prototype.removeAllChildren):
(MarkupParentNode.prototype.replaceChild):
(MarkupContentRoot): Added. Used like a shadow tree.
(MarkupContentRoot.prototype._markup): Added.
(MarkupElement): Added. A fake Element. It also implements a subset of IDL attributes implemented by
subclasses such as HTMLInputElement for simplicity.
(MarkupElement.prototype.get id): Added.
(MarkupElement.prototype.get localName): Added.
(MarkupElement.prototype.clone): Added.
(MarkupElement.prototype.appendChild): Added.
(MarkupElement.prototype.addEventListener): Added.
(MarkupElement.prototype.setAttribute): Added.
(MarkupElement.prototype.getAttribute): Added.
(MarkupElement.prototype.get attributes): Added.
(MarkupElement.prototype.get textContent): Added.
(MarkupElement.prototype.set textContent): Added.
(MarkupElement.prototype._serializeStyle): Added.
(MarkupElement.prototype._markup): Added. Flattens the tree with content tree like copy & paste so
this can't be used to implement innerHTML.
(MarkupElement.prototype.get value): Added.
(MarkupElement.prototype.set value): Added.
(MarkupElement.prototype.get style): Added. Returns a fake writeonly CSSStyleDeclaration.
(MarkupElement.prototype.set style): Added.
(MarkupElement.get selfClosingNames): Added. A small list of self-closing tags for the HTML generation.
(MarkupText): Added.
(MarkupText.prototype.clone): Added.
(MarkupText.prototype._markup): Added.
(MarkupText.prototype.get data): Added.
(MarkupText.prototype.set data): Added.
(MarkupComponentBase): Added.
(MarkupComponentBase.prototype.element): Added. Like ComponentBase's element.
(MarkupComponentBase.prototype.content): Added. Like ComponentBase's content.
(MarkupComponentBase.prototype._findElementRecursivelyById): Added. A fake getElementById.
(MarkupComponentBase.prototype.render): Added. Like ComponentBase's render.
(MarkupComponentBase.prototype.runRenderLoop): Added. In ComponentBase, we use requestAnimationFrame.
In MarkupComponentBase, we keep rendering until the queue drains empty.
(MarkupComponentBase.prototype.renderReplace): Added. Like ComponentBase's renderReplace but applies
the transformation of classes to workaround the lack of shadow tree support in scriptless HTML.
(MarkupComponentBase.prototype._applyStyleOverrides): Added. Recursively applies the transformation.
(MarkupComponentBase.prototype._ensureContentTree): Added. Like ComponentBase's _ensureShadowTree.
(MarkupComponentBase.reset): Added.
(MarkupComponentBase._parseTemplates): Added. Parses the content & style templates, and generates the
transformed fake DOM tree and stylesheet text whereby selectors in each component is modified to be
unique across all components. The function to apply the necessary changes to an element is saved in
the global map of components, and later used in renderReplace via _applyStyleOverrides.
(MarkupComponentBase.defineElement): Added. Like ComponentBase's defineElement.
(MarkupComponentBase.prototype.createEventHandler): Added.
(MarkupComponentBase.createEventHandler): Added.
(MarkupPage): Added. The top-level component responsible for generating a DOCTYPE, head, and body.
(MarkupPage.prototype.pageTitle): Added.
(MarkupPage.prototype.content): Added. Overrides the one in MarkupComponentBase to return what would
be the content of the body element as opposed to the html element for the connivance of subclasses,
and to match the behavior of the frontend Page class.
(MarkupPage.prototype.render): Added.
(MarkupPage.prototype._updateComponentsStylesheet): Added. Concatenates the transformed stylesheet of
all components used.
(MarkupPage.get contentTemplate): Added.
(MarkupPage.prototype.generateMarkup): Added. Enqueues the page to render, spin the render loop, and
generates the HTML. We enqueue the page twice in order to invoke _updateComponentsStylesheet after
all subcomponent had finished rendering.
* unit-tests/markup-component-base-tests.js: Added.
* unit-tests/markup-element-tests.js: Added.
(.createElement): Added.
* unit-tests/markup-page-tests.js: Added.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@232588 268f45cc-cd09-0410-ab3c-d52691b4dbfc

17 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/browser-tests/close-button-tests.js
Websites/perf.webkit.org/browser-tests/commit-log-viewer-tests.js
Websites/perf.webkit.org/browser-tests/component-base-tests.js
Websites/perf.webkit.org/browser-tests/editable-text-tests.js
Websites/perf.webkit.org/browser-tests/index.html
Websites/perf.webkit.org/browser-tests/markup-page-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/browser-tests/page-router-tests.js
Websites/perf.webkit.org/browser-tests/page-tests.js
Websites/perf.webkit.org/browser-tests/test-group-form-tests.js
Websites/perf.webkit.org/public/shared/common-component-base.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/base.js
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/tools/js/markup-component.js [new file with mode: 0644]
Websites/perf.webkit.org/unit-tests/markup-component-base-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/unit-tests/markup-element-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/unit-tests/markup-page-tests.js [new file with mode: 0644]

index c9543ff..116b353 100644 (file)
@@ -1,3 +1,149 @@
+2018-06-07  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Add the basic support for writing components in node.js
+        https://bugs.webkit.org/show_bug.cgi?id=186299
+
+        Reviewed by Antti Koivisto.
+
+        Add the basic support for writing components in node.js for generating rich email notifications.
+
+        To do this, this patch introduces MarkupComponentBase and MarkupPage which implement similar API
+        to ComponentBase and Page classes of v3 UI code. This enables us to share code between frontend
+        and the backend in the future. Because there is no support for declarative custom elements or
+        shadow root in HTML, MarkupComponentBase uses a similar but distinct concept of "content" tree
+        to represent the "DOM" tree for a component. When generating the HTML, MarkupComponentBase and
+        MarkupPage collectively transforms stylesheets and flattens the tree into a single HTML. In order
+        to keep this flatteneing logic simple, MarkupComponentBase only supports a very small subset of
+        CSS selectors to select elements by their local names and class names.
+
+        Specifically, each class name and element name based selectors are replaced by a globally unique
+        class name based selector, and each element which matches the selector is applied of the same
+        globally unique class name. The transformation is applied when constructing the "content" tree
+        as well as calls to renderReplace.
+
+        Because much of v3 frontend code relies on DOM API, this patch also implements the simplest form
+        of a fake DOM API as MarkupNode, MarkupParentNode, MarkupElement, and MarkupText. In order to avoid
+        reimplementing HTML & CSS parsers, this patch introduces the concept of content and style templates
+        to ComponentBase which are JSON alternatives to HTML & CSS template strings which can be used in
+        both frontend & backend.
+
+        * browser-tests/close-button-tests.js: Include CommonComponentBase.
+        * browser-tests/commit-log-viewer-tests.js: Ditto.
+        * browser-tests/component-base-tests.js: Ditto. Added a test cases for content & style templates.
+        (async.importComponentBase): Added.
+        * browser-tests/editable-text-tests.js: Include CommonComponentBase.
+        * browser-tests/index.html:
+        * browser-tests/markup-page-tests.js: Added.
+        * browser-tests/page-router-tests.js: Include CommonComponentBase.
+        * browser-tests/page-tests.js: Ditto.
+        * browser-tests/test-group-form-tests.js: Ditto.
+        * public/shared/common-component-base.js: Added.
+        (CommonComponentBase): Extracted out of ComponentBase.
+        (CommonComponentBase.prototype.renderReplace): Added.
+        (CommonComponentBase.renderReplace): Moved from ComponentBase.
+        (CommonComponentBase.prototype._recursivelyUpgradeUnknownElements): Moved and renamed from
+        ComponentBase's _recursivelyReplaceUnknownElementsByComponents.
+        (CommonComponentBase.prototype._upgradeUnknownElement): Extracted out of the same function.
+        (CommonComponentBase._constructStylesheetFromTemplate): Added.
+        (CommonComponentBase._constructNodeTreeFromTemplate): Added.
+        (CommonComponentBase.prototype.createElement): Added.
+        (CommonComponentBase.createElement): Moved from ComponentBase.
+        (CommonComponentBase._addContentToElement): Moved from ComponentBase.
+        (CommonComponentBase.prototype.createLink): Added.
+        (CommonComponentBase.createLink): Moved from ComponentBase.
+        (CommonComponentBase._context): Added. Set to document in a browser and MarkupDocument in node.js.
+        (CommonComponentBase._isNode): Added. Set to a function which does instanceof Node/MarkupNode check.
+        (CommonComponentBase._baseClass): Added. Set to ComponentBase or MarkupComponentBase.
+        * public/v3/components/base.js:
+        (ComponentBase):
+        (ComponentBase.prototype._ensureShadowTree): Added the support for the content and style templates.
+        Also avoid parsing the html template each time a component is instantiated by caching the result.
+        * public/v3/index.html:
+        * tools/js/markup-component.js: Added.
+        (MarkupDocument): Added. A fake Document.
+        (MarkupDocument.prototype.createContentRoot): A substitude for attachShadow.
+        (MarkupDocument.prototype.createElement):
+        (MarkupDocument.prototype.createTextNode):
+        (MarkupDocument.prototype._idForClone):
+        (MarkupDocument.prototype.reset):
+        (MarkupDocument.prototype.markup):
+        (MarkupDocument.prototype.escapeAttributeValue):
+        (MarkupDocument.prototype.escapeNodeData):
+        (MarkupNode): Added. A fake Node. Each node gets an unique ID.
+        (MarkupNode.prototype._markup):
+        (MarkupNode.prototype.clone): Implemented by the leave class.
+        (MarkupNode.prototype._cloneNodeData):
+        (MarkupNode.prototype.remove):
+        (MarkupParentNode): Added. An equivalent of ContainerNode in WebCore.
+        (MarkupParentNode.prototype.get childNodes):
+        (MarkupParentNode.prototype._cloneNodeData):
+        (MarkupParentNode.prototype.appendChild):
+        (MarkupParentNode.prototype.removeChild):
+        (MarkupParentNode.prototype.removeAllChildren):
+        (MarkupParentNode.prototype.replaceChild):
+        (MarkupContentRoot): Added. Used like a shadow tree.
+        (MarkupContentRoot.prototype._markup): Added.
+        (MarkupElement): Added. A fake Element. It also implements a subset of IDL attributes implemented by
+        subclasses such as HTMLInputElement for simplicity.
+        (MarkupElement.prototype.get id): Added.
+        (MarkupElement.prototype.get localName): Added.
+        (MarkupElement.prototype.clone): Added.
+        (MarkupElement.prototype.appendChild): Added.
+        (MarkupElement.prototype.addEventListener): Added.
+        (MarkupElement.prototype.setAttribute): Added.
+        (MarkupElement.prototype.getAttribute): Added.
+        (MarkupElement.prototype.get attributes): Added.
+        (MarkupElement.prototype.get textContent): Added.
+        (MarkupElement.prototype.set textContent): Added.
+        (MarkupElement.prototype._serializeStyle): Added.
+        (MarkupElement.prototype._markup): Added. Flattens the tree with content tree like copy & paste so
+        this can't be used to implement innerHTML.
+        (MarkupElement.prototype.get value): Added.
+        (MarkupElement.prototype.set value): Added.
+        (MarkupElement.prototype.get style): Added. Returns a fake writeonly CSSStyleDeclaration.
+        (MarkupElement.prototype.set style): Added.
+        (MarkupElement.get selfClosingNames): Added. A small list of self-closing tags for the HTML generation.
+        (MarkupText): Added.
+        (MarkupText.prototype.clone): Added.
+        (MarkupText.prototype._markup): Added.
+        (MarkupText.prototype.get data): Added.
+        (MarkupText.prototype.set data): Added.
+        (MarkupComponentBase): Added.
+        (MarkupComponentBase.prototype.element): Added. Like ComponentBase's element.
+        (MarkupComponentBase.prototype.content): Added. Like ComponentBase's content.
+        (MarkupComponentBase.prototype._findElementRecursivelyById): Added. A fake getElementById.
+        (MarkupComponentBase.prototype.render): Added. Like ComponentBase's render.
+        (MarkupComponentBase.prototype.runRenderLoop): Added. In ComponentBase, we use requestAnimationFrame.
+        In MarkupComponentBase, we keep rendering until the queue drains empty.
+        (MarkupComponentBase.prototype.renderReplace): Added. Like ComponentBase's renderReplace but applies
+        the transformation of classes to workaround the lack of shadow tree support in scriptless HTML.
+        (MarkupComponentBase.prototype._applyStyleOverrides): Added. Recursively applies the transformation.
+        (MarkupComponentBase.prototype._ensureContentTree): Added. Like ComponentBase's _ensureShadowTree.
+        (MarkupComponentBase.reset): Added.
+        (MarkupComponentBase._parseTemplates): Added. Parses the content & style templates, and generates the
+        transformed fake DOM tree and stylesheet text whereby selectors in each component is modified to be
+        unique across all components. The function to apply the necessary changes to an element is saved in
+        the global map of components, and later used in renderReplace via _applyStyleOverrides.
+        (MarkupComponentBase.defineElement): Added. Like ComponentBase's defineElement.
+        (MarkupComponentBase.prototype.createEventHandler): Added.
+        (MarkupComponentBase.createEventHandler): Added.
+        (MarkupPage): Added. The top-level component responsible for generating a DOCTYPE, head, and body.
+        (MarkupPage.prototype.pageTitle): Added.
+        (MarkupPage.prototype.content): Added. Overrides the one in MarkupComponentBase to return what would
+        be the content of the body element as opposed to the html element for the connivance of subclasses,
+        and to match the behavior of the frontend Page class.
+        (MarkupPage.prototype.render): Added.
+        (MarkupPage.prototype._updateComponentsStylesheet): Added. Concatenates the transformed stylesheet of
+        all components used.
+        (MarkupPage.get contentTemplate): Added.
+        (MarkupPage.prototype.generateMarkup): Added. Enqueues the page to render, spin the render loop, and
+        generates the HTML. We enqueue the page twice in order to invoke _updateComponentsStylesheet after
+        all subcomponent had finished rendering.
+        * unit-tests/markup-component-base-tests.js: Added.
+        * unit-tests/markup-element-tests.js: Added.
+        (.createElement): Added.
+        * unit-tests/markup-page-tests.js: Added.
+
 2018-05-23  Dewei Zhu  <dewei_zhu@apple.com>
 
         OSBuildFetcher should respect maxRevision while finding OS builds to report.
index fda3ce9..ed47cdc 100644 (file)
@@ -1,6 +1,6 @@
 
 describe('CloseButton', () => {
-    const scripts = ['instrumentation.js', 'components/base.js', 'components/button-base.js', 'components/close-button.js'];
+    const scripts = ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'components/button-base.js', 'components/close-button.js'];
 
     it('must dispatch "activate" action when the anchor is clicked', () => {
         const context = new BrowsingContext();
index ea95688..dbc4cfc 100644 (file)
@@ -10,10 +10,11 @@ describe('CommitLogViewer', () => {
             'models/repository.js',
             'models/commit-set.js',
             'models/commit-log.js',
+            '../shared/common-component-base.js',
             'components/base.js',
             'components/spinner-icon.js',
             'components/commit-log-viewer.js'];
-        return context.importScripts(scripts, 'ComponentBase', 'CommitLogViewer', 'Repository', 'CommitLog', 'RemoteAPI').then(() => {
+        return context.importScripts(scripts, 'CommonComponentBase', 'ComponentBase', 'CommitLogViewer', 'Repository', 'CommitLog', 'RemoteAPI').then(() => {
             return context.symbols.CommitLogViewer;
         });
     }
index 00c7a28..897c24f 100644 (file)
@@ -1,10 +1,18 @@
 
 describe('ComponentBase', function() {
 
+    async function importComponentBase(context)
+    {
+        const [Instrumentation, CommonComponentBase, ComponentBase] = await context.importScripts(
+            ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js'],
+            'Instrumentation', 'CommonComponentBase', 'ComponentBase');
+        return ComponentBase;
+    }
+
     function createTestToCheckExistenceOfShadowTree(callback, options = {htmlTemplate: false, cssTemplate: true})
     {
         const context = new BrowsingContext();
-        return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+        return importComponentBase(context).then((ComponentBase) => {
             class SomeComponent extends ComponentBase { }
             if (options.htmlTemplate)
                 SomeComponent.htmlTemplate = () => { return '<div id="div" style="height: 10px;"></div>'; };
@@ -20,7 +28,7 @@ describe('ComponentBase', function() {
 
     it('must enqueue a connected component to render', () => {
         const context = new BrowsingContext();
-        return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+        return importComponentBase(context).then((ComponentBase) => {
             let renderCall = 0;
             class SomeComponent extends ComponentBase {
                 render() { renderCall++; }
@@ -46,7 +54,7 @@ describe('ComponentBase', function() {
 
     it('must enqueue a connected component to render upon a resize event if enqueueToRenderOnResize is true', () => {
         const context = new BrowsingContext();
-        return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+        return importComponentBase(context).then((ComponentBase) => {
             class SomeComponent extends ComponentBase {
                 static get enqueueToRenderOnResize() { return true; }
             }
@@ -70,7 +78,7 @@ describe('ComponentBase', function() {
 
     it('must not enqueue a disconnected component to render upon a resize event if enqueueToRenderOnResize is true', () => {
         const context = new BrowsingContext();
-        return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+        return importComponentBase(context).then((ComponentBase) => {
             class SomeComponent extends ComponentBase {
                 static get enqueueToRenderOnResize() { return true; }
             }
@@ -92,13 +100,13 @@ describe('ComponentBase', function() {
 
     describe('constructor', () => {
         it('is a function', () => {
-            return new BrowsingContext().importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 expect(ComponentBase).to.be.a('function');
             });
         });
 
         it('can be instantiated', () => {
-            return new BrowsingContext().importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 let callCount = 0;
                 class SomeComponent extends ComponentBase {
                     constructor() {
@@ -123,7 +131,7 @@ describe('ComponentBase', function() {
     describe('element()', () => {
         it('must return an element', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 let instance = new SomeComponent('some-component');
                 expect(instance.element()).to.be.a(context.global.HTMLElement);
@@ -131,7 +139,8 @@ describe('ComponentBase', function() {
         });
 
         it('must return an element whose component() matches the component', () => {
-            return new BrowsingContext().importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            const context = new BrowsingContext();
+            return importComponentBase(context).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 let instance = new SomeComponent('some-component');
                 expect(instance.element().component()).to.be(instance);
@@ -161,7 +170,7 @@ describe('ComponentBase', function() {
         });
 
         it('must return the element matching the id if an id is specified', () => {
-            return new BrowsingContext().importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase {
                     static htmlTemplate() { return '<div id="part1" title="foo"></div><div id="part1"></div>'; }
                 }
@@ -174,6 +183,28 @@ describe('ComponentBase', function() {
                 expect(instance.content('part2')).to.be(null);
             });
         });
+
+        it('it must create DOM tree from contentTemplate', async () => {
+            const context = new BrowsingContext();
+            const ComponentBase = await importComponentBase(context);
+            class SomeComponent extends ComponentBase { };
+            SomeComponent.contentTemplate = ['div', {id: 'container'}, 'hello, world'];
+            const instance = new SomeComponent('some-component');
+            const container = instance.content('container');
+            expect(container).to.be.a(context.global.HTMLDivElement);
+            expect(container.textContent).to.be('hello, world');
+        });
+
+        it('it must create stylsheet from styleTemplate', async () => {
+            const context = new BrowsingContext();
+            const ComponentBase = await importComponentBase(context);
+            class SomeComponent extends ComponentBase { };
+            SomeComponent.contentTemplate = ['span', 'hello, world'];
+            SomeComponent.styleTemplate = {':host': {'font-weight': 'bold'}};
+            const instance = new SomeComponent('some-component');
+            context.document.body.append(instance.element());
+            expect(context.global.getComputedStyle(instance.content().firstChild).fontWeight).to.be('bold');
+        });
     });
 
     describe('part()', () => {
@@ -185,7 +216,7 @@ describe('ComponentBase', function() {
         });
 
         it('must return the component matching the id if an id is specified', () => {
-            return new BrowsingContext().importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 ComponentBase.defineElement('some-component', SomeComponent);
 
@@ -206,7 +237,7 @@ describe('ComponentBase', function() {
 
     describe('dispatchAction()', () => {
         it('must invoke a callback specified in listenToAction', () => {
-            return new BrowsingContext().importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 ComponentBase.defineElement('some-component', SomeComponent);
 
@@ -227,7 +258,7 @@ describe('ComponentBase', function() {
         });
 
         it('must not do anything when there are no callbacks', () => {
-            return new BrowsingContext().importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(new BrowsingContext()).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 ComponentBase.defineElement('some-component', SomeComponent);
 
@@ -240,7 +271,7 @@ describe('ComponentBase', function() {
     describe('enqueueToRender()', () => {
         it('must not immediately call render()', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 context.global.requestAnimationFrame = () => {}
 
                 let renderCallCount = 0;
@@ -259,7 +290,7 @@ describe('ComponentBase', function() {
 
         it('must request an animation frame exactly once', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let requestAnimationFrameCount = 0;
                 context.global.requestAnimationFrame = () => { requestAnimationFrameCount++; }
 
@@ -286,7 +317,7 @@ describe('ComponentBase', function() {
 
         it('must invoke render() when the callback to requestAnimationFrame is called', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let callback = null;
                 context.global.requestAnimationFrame = (newCallback) => {
                     expect(callback).to.be(null);
@@ -321,7 +352,7 @@ describe('ComponentBase', function() {
 
         it('must immediately invoke render() on a component enqueued inside another render() call', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let callback = null;
                 context.global.requestAnimationFrame = (newCallback) => {
                     expect(callback).to.be(null);
@@ -366,7 +397,7 @@ describe('ComponentBase', function() {
 
         it('must request a new animation frame once it exited the callback from requestAnimationFrame', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let requestAnimationFrameCount = 0;
                 let callback = null;
                 context.global.requestAnimationFrame = (newCallback) => {
@@ -449,7 +480,7 @@ describe('ComponentBase', function() {
 
         it('must invoke didConstructShadowTree after creating the shadow tree', () => {
             const context = new BrowsingContext();
-            return context.importScripts(['instrumentation.js', 'components/base.js'], 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let didConstructShadowTreeCount = 0;
                 let htmlTemplateCount = 0;
 
@@ -482,7 +513,7 @@ describe('ComponentBase', function() {
 
         it('should create an element of the specified name', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const div = ComponentBase.createElement('div');
                 expect(div).to.be.a(context.global.HTMLDivElement);
             });
@@ -490,7 +521,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified attributes', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const input = ComponentBase.createElement('input', {'title': 'hi', 'id': 'foo', 'required': false, 'checked': true});
                 expect(input).to.be.a(context.global.HTMLInputElement);
                 expect(input.attributes.length).to.be(3);
@@ -499,13 +530,13 @@ describe('ComponentBase', function() {
                 expect(input.attributes[1].localName).to.be('id');
                 expect(input.attributes[1].value).to.be('foo');
                 expect(input.attributes[2].localName).to.be('checked');
-                expect(input.attributes[2].value).to.be('checked');
+                expect(input.attributes[2].value).to.be('');
             });
         });
 
         it('should create an element with the specified event handlers and attributes', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let clickCount = 0;
                 const div = ComponentBase.createElement('div', {'title': 'hi', 'onclick': () => clickCount++});
                 expect(div).to.be.a(context.global.HTMLDivElement);
@@ -520,7 +551,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified children when there is no attribute specified', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const element = ComponentBase.createElement;
                 const span = element('span');
                 const div = element('div', [span, 'hi']);
@@ -535,7 +566,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified children when the second argument is a span', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const element = ComponentBase.createElement;
                 const span = element('span');
                 const div = element('div', span);
@@ -548,7 +579,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified children when the second argument is a Text node', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const element = ComponentBase.createElement;
                 const text = context.document.createTextNode('hi');
                 const div = element('div', text);
@@ -561,7 +592,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified children when the second argument is a component', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { };
                 ComponentBase.defineElement('some-component', SomeComponent);
                 const element = ComponentBase.createElement;
@@ -576,7 +607,7 @@ describe('ComponentBase', function() {
 
         it('should create an element with the specified attributes and children', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 const element = ComponentBase.createElement;
                 const span = element('span');
                 const div = element('div', {'lang': 'en'}, [span, 'hi']);
@@ -597,7 +628,7 @@ describe('ComponentBase', function() {
 
         it('must define a custom element with a class of an appropriate name', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 class SomeComponent extends ComponentBase { }
                 ComponentBase.defineElement('some-component', SomeComponent);
 
@@ -609,7 +640,7 @@ describe('ComponentBase', function() {
 
         it('must define a custom element that can be instantiated via document.createElement', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let instances = [];
                 class SomeComponent extends ComponentBase {
                     constructor() {
@@ -632,7 +663,7 @@ describe('ComponentBase', function() {
 
         it('must define a custom element that can be instantiated via new', () => {
             const context = new BrowsingContext();
-            return context.importScript('components/base.js', 'ComponentBase').then((ComponentBase) => {
+            return importComponentBase(context).then((ComponentBase) => {
                 let instances = [];
                 class SomeComponent extends ComponentBase {
                     constructor() {
index cfed690..7f3618c 100644 (file)
@@ -1,6 +1,6 @@
 
 describe('EditableText', () => {
-    const scripts = ['instrumentation.js', 'components/base.js', 'components/editable-text.js'];
+    const scripts = ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'components/editable-text.js'];
 
     it('show the set text', () => {
         const context = new BrowsingContext();
index dded179..d26bdce 100644 (file)
@@ -27,6 +27,7 @@ mocha.setup('bdd');
 <script src="chart-revision-range-tests.js"></script>
 <script src="commit-log-viewer-tests.js"></script>
 <script src="test-group-form-tests.js"></script>
+<script src="markup-page-tests.js"></script>
 <script>
 
 afterEach(() => {
@@ -224,10 +225,11 @@ const ChartTest = {
             'models/metric.js',
             'models/commit-set.js',
             'models/commit-log.js',
+            '../shared/common-component-base.js',
             'components/base.js',
             'components/time-series-chart.js',
             'components/interactive-time-series-chart.js'],
-            'ComponentBase', 'TimeSeriesChart', 'InteractiveTimeSeriesChart',
+            'CommonComponentBase', 'ComponentBase', 'TimeSeriesChart', 'InteractiveTimeSeriesChart',
             'Platform', 'Metric', 'Test', 'Repository', 'MeasurementSet', 'MockRemoteAPI', 'AsyncTask').then(() => {
                 return context.symbols.TimeSeriesChart;
             })
diff --git a/Websites/perf.webkit.org/browser-tests/markup-page-tests.js b/Websites/perf.webkit.org/browser-tests/markup-page-tests.js
new file mode 100644 (file)
index 0000000..1d045c1
--- /dev/null
@@ -0,0 +1,274 @@
+
+describe('MarkupPage', function () {
+
+    async function importMarkupComponent(context)
+    {
+        return await context.importScripts(['lazily-evaluated-function.js', '../shared/common-component-base.js', '../../tools/js/markup-component.js'],
+            'MarkupComponentBase', 'MarkupPage');
+    }
+
+    describe('pageContent', function () {
+        it('should define the content of the generated page', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { };
+            SomePage.pageContent = ['div', 'hello world'];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            const page = new SomePage;
+            expect(context.document.title).not.to.be('Some Page');
+            expect(page.generateMarkup()).to.contain('<div>hello world</div>');
+        });
+    });
+
+    describe('content', function () {
+        it('should return the page body', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage {
+                render()
+                {
+                    super.render();
+                    this.renderReplace(this.content(), MarkupComponentBase.createElement('span'));
+                }
+            }
+            SomePage.pageContent = ['div', ['some-component']];
+            SomePage.styleTemplate = {'span': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            expect(context.document.head.querySelector('style')).to.be.a(context.global.HTMLStyleElement);
+            expect(context.document.head.querySelector('title')).to.be.a(context.global.HTMLTitleElement);
+        });
+    });
+
+    describe('generateMarkup', function () { 
+        it('must enqueue itself to render and run the render loop', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+            let renderCall = 0;
+            class SomePage extends MarkupPage {
+                render() {
+                    super.render();
+                    renderCall++;
+                }
+            }
+            MarkupComponentBase.defineElement('some-page', SomePage);
+            const page = new SomePage;
+            page.generateMarkup();
+            expect(renderCall).to.be.greaterThan(0);
+        });
+
+        it('must generate DOCTYPE, html, head, and body elements', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            let renderCall = 0;
+            class SomePage extends MarkupPage {
+                render() {
+                    super.render();
+                    renderCall++;
+                }
+            }
+            MarkupComponentBase.defineElement('some-page', SomePage);
+            const page = new SomePage;
+            expect(page.generateMarkup()).to.contain('<!DOCTYPE html><html><head');
+            expect(page.generateMarkup()).to.contain('</head><body');
+        });
+
+        it('must generate the title element', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            let renderCall = 0;
+            class SomePage extends MarkupPage {
+                constructor() { super('Some Page'); }
+                render() {
+                    super.render();
+                    renderCall++;
+                }
+            }
+            MarkupComponentBase.defineElement('some-page', SomePage);
+            const page = new SomePage;
+            expect(context.document.title).not.to.be('Some Page');
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            expect(context.document.title).to.be('Some Page');
+        });
+
+        it('must generate the content for components in the page', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', ['some-component']];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            class SomeComponent extends MarkupComponentBase { };
+            SomeComponent.contentTemplate = ['div', 'hello world'];
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            const page = new SomePage;
+            expect(page.generateMarkup()).to.contain('<div>hello world</div>');
+        });
+
+        it('must generate the style for components in the page', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', ['some-component']];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            class SomeComponent extends MarkupComponentBase {};
+            SomeComponent.contentTemplate = [['span', 'hello world'], ['p']];
+            SomeComponent.styleTemplate = {'span': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            expect(context.global.getComputedStyle(context.document.querySelector('p')).fontWeight).to.be('normal');
+            expect(context.global.getComputedStyle(context.document.querySelector('span')).fontWeight).to.be('bold');
+        });
+
+        it('must not apply the styles from a sibling component', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', [['some-component'], ['other-component']]];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            class SomeComponent extends MarkupComponentBase {};
+            SomeComponent.contentTemplate = [['section', 'hello'], ['p', 'world']];
+            SomeComponent.styleTemplate = {'section': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            class OtherComponent extends MarkupComponentBase {};
+            OtherComponent.contentTemplate = [['section', 'hello'], ['p', 'world']];
+            OtherComponent.styleTemplate = {'p': {'color': 'blue'}};
+            MarkupComponentBase.defineElement('other-component', OtherComponent);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            const getComputedStyle = (element) => context.global.getComputedStyle(element);
+            const querySelector = (selector) => context.document.querySelector(selector);
+            expect(getComputedStyle(querySelector('some-component section')).fontWeight).to.be('bold');
+            expect(getComputedStyle(querySelector('other-component section')).fontWeight).to.be('normal');
+            expect(getComputedStyle(querySelector('some-component p')).color).to.be('rgb(0, 0, 0)');
+            expect(getComputedStyle(querySelector('other-component p')).color).to.be('rgb(0, 0, 255)');
+        });
+
+        it('must not apply the styles from a ancestor component', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', ['some-component']];
+            SomePage.styleTemplate = {'div': {'width': '300px'}};
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            class SomeComponent extends MarkupComponentBase {};
+            SomeComponent.contentTemplate = ['div', ['other-component']];
+            SomeComponent.styleTemplate = {'div': {'width': '200px'}};
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            class OtherComponent extends MarkupComponentBase {};
+            OtherComponent.contentTemplate = ['div'];
+            OtherComponent.styleTemplate = {'div': {'width': '100px'}};
+            MarkupComponentBase.defineElement('other-component', OtherComponent);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            const getComputedStyle = (element) => context.global.getComputedStyle(element);
+            const divs = Array.from(context.document.querySelectorAll('div'));
+            expect(getComputedStyle(divs[0]).width).to.be('300px');
+            expect(getComputedStyle(divs[1]).width).to.be('200px');
+            expect(getComputedStyle(divs[2]).width).to.be('100px');
+        });
+
+        it('must apply the styles to elements generated in renderReplace', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', ['some-component']];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            class SomeComponent extends MarkupComponentBase {
+                render()
+                {
+                    super.render();
+                    this.renderReplace(this.content(), MarkupComponentBase.createElement('span'));
+                }
+            };
+            SomeComponent.contentTemplate = [];
+            SomeComponent.styleTemplate = {'span': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            expect(context.global.getComputedStyle(context.document.querySelector('span')).fontWeight).to.be('bold');
+        });
+
+        it('must apply the styles to elements based on class names', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', {class: 'target'}, ['some-component']];
+            SomePage.styleTemplate = {'.target': {'border': 'solid 1px black'}};
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            const target = context.document.querySelector('.target');
+            expect(context.global.getComputedStyle(target).borderWidth).to.be('1px');
+            expect(target.classList.length).to.be(2);
+        });
+
+        it('must not add the same class name multiple times to an element', async () => {
+            const context = new BrowsingContext();
+            const [MarkupComponentBase, MarkupPage] = await importMarkupComponent(context);
+
+            class SomePage extends MarkupPage {
+                render()
+                {
+                    super.render();
+                    const container = this.createElement('div');
+                    this.renderReplace(container, this.createElement('div', {class: 'target'}));
+                    this.renderReplace(this.content(), container);
+                }
+            }
+            SomePage.pageContent = [];
+            SomePage.styleTemplate = {'.target': {'border': 'solid 1px black'}};
+            MarkupComponentBase.defineElement('some-page', SomePage);
+
+            const page = new SomePage;
+            context.document.open();
+            context.document.write(page.generateMarkup());
+            context.document.close();
+            const target = context.document.querySelector('.target');
+            expect(context.global.getComputedStyle(target).borderWidth).to.be('1px');
+            expect(target.classList.length).to.be(2);
+        });
+
+    });
+});
+
index 9f42d45..9367b6d 100644 (file)
@@ -4,7 +4,7 @@ describe('PageRouter', () => {
         it('should choose the longest match', async () => {
             const context = new BrowsingContext();
             const [Page, PageRouter, ComponentBase] = await context.importScripts(
-                ['instrumentation.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
+                ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
                 'Page', 'PageRouter', 'ComponentBase');
 
             let someRenderCount = 0;
index 30916d0..e1e4f14 100644 (file)
@@ -4,7 +4,7 @@ describe('Page', function() {
     describe('open', () => {
         it('must replace the content of document.body', async () => {
             const context = new BrowsingContext();
-            const Page = await context.importScripts(['instrumentation.js', 'components/base.js', 'pages/page.js'], 'Page');
+            const Page = await context.importScripts(['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js'], 'Page');
 
             class SomePage extends Page {
                 constructor() { super('some page'); }
@@ -25,7 +25,7 @@ describe('Page', function() {
 
         it('must update the document title', async () => {
             const context = new BrowsingContext();
-            const Page = await context.importScripts(['instrumentation.js', 'components/base.js', 'pages/page.js'], 'Page');
+            const Page = await context.importScripts(['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js'], 'Page');
 
             class SomePage extends Page {
                 constructor() { super('some page'); }
@@ -41,7 +41,7 @@ describe('Page', function() {
 
         it('must enqueue itself to render', async () => {
             const context = new BrowsingContext();
-            const [Page, ComponentBase] = await context.importScripts(['instrumentation.js', 'components/base.js', 'pages/page.js'], 'Page', 'ComponentBase');
+            const [Page, ComponentBase] = await context.importScripts(['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js'], 'Page', 'ComponentBase');
 
             let renderCount = 0;
             class SomePage extends Page {
@@ -62,7 +62,7 @@ describe('Page', function() {
         it('must update the current page of the router', async () => {
             const context = new BrowsingContext();
             const [Page, PageRouter, ComponentBase] = await context.importScripts(
-                ['instrumentation.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
+                ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
                 'Page', 'PageRouter', 'ComponentBase');
 
             class SomePage extends Page {
@@ -83,7 +83,7 @@ describe('Page', function() {
         it('must not enqueue itself to render if the router is set and the current page is not itself', async () => {
             const context = new BrowsingContext();
             const [Page, PageRouter, ComponentBase] = await context.importScripts(
-                ['instrumentation.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
+                ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'pages/page.js', 'pages/page-router.js'],
                 'Page', 'PageRouter', 'ComponentBase');
 
             let someRenderCount = 0;
index 2653567..7e20933 100644 (file)
@@ -1,6 +1,6 @@
 
 describe('TestGroupFormTests', () => {
-    const scripts = ['instrumentation.js', 'components/base.js', 'components/test-group-form.js'];
+    const scripts = ['instrumentation.js', '../shared/common-component-base.js', 'components/base.js', 'components/test-group-form.js'];
 
     function createTestGroupFormWithContext(context)
     {
diff --git a/Websites/perf.webkit.org/public/shared/common-component-base.js b/Websites/perf.webkit.org/public/shared/common-component-base.js
new file mode 100644 (file)
index 0000000..aafc908
--- /dev/null
@@ -0,0 +1,172 @@
+
+class CommonComponentBase {
+
+    renderReplace(element, content) { CommonComponentBase.renderReplace(element, content); }
+
+    // FIXME: Deprecate these static functions.
+    static renderReplace(element, content)
+    {
+        element.textContent = '';
+        if (content)
+            ComponentBase._addContentToElement(element, content);
+    }
+
+    _recursivelyUpgradeUnknownElements(parent, findUpgrade, didConstructComponent = () => { })
+    {
+        let nextSibling;
+        for (let child of parent.childNodes) {
+            const componentClass = findUpgrade(child);
+            if (componentClass) {
+                const intance = this._upgradeUnknownElement(parent, child, componentClass);
+                didConstructComponent(intance);
+            }
+            if (child.childNodes)
+                this._recursivelyUpgradeUnknownElements(child, findUpgrade, didConstructComponent);
+        }
+    }
+
+    _upgradeUnknownElement(parent, unknownElement, componentClass)
+    {
+        const instance = new componentClass;
+        const newElement = instance.element();
+
+        for (let i = 0; i < unknownElement.attributes.length; i++) {
+            const attr = unknownElement.attributes[i];
+            newElement.setAttribute(attr.name, attr.value);
+        }
+        parent.replaceChild(newElement, unknownElement);
+
+        for (const child of Array.from(unknownElement.childNodes))
+            newElement.appendChild(child);
+
+        return instance;
+    }
+
+    static _constructStylesheetFromTemplate(styleTemplate, didCreateRule = (selector, rule) => selector)
+    {
+        let stylesheet = '';
+        for (const selector in styleTemplate) {
+            const rules = styleTemplate[selector];
+
+            let ruleText = '';
+            for (const property in rules) {
+                const value = rules[property];
+                ruleText += `    ${property}: ${value};\n`;
+            }
+
+            const modifiedSelector = didCreateRule(selector, ruleText);
+
+            stylesheet += modifiedSelector + ' {\n' + ruleText + '}\n\n';
+        }
+        return stylesheet;
+    }
+
+    static _constructNodeTreeFromTemplate(template, didCreateElement = (element) => { })
+    {
+        if (typeof(template) == 'string')
+            return [CommonComponentBase._context.createTextNode(template)];
+        console.assert(Array.isArray(template));
+        if (typeof(template[0]) == 'string') {
+            const tagName = template[0];
+            let attributes = {};
+            let content = null;
+            if (Array.isArray(template[1])) {
+                content = template[1];
+            } else {
+                attributes = template[1];
+                content = template[2];
+            }
+            const element = this.createElement(tagName, attributes);
+            didCreateElement(element);
+            const children = content && content.length ? this._constructNodeTreeFromTemplate(content, didCreateElement) : [];
+            for (const child of children)
+                element.appendChild(child);
+            return [element];
+        } else {
+            let result = [];
+            for (const item of template) {
+                if (typeof(item) == 'string')
+                    result.push(CommonComponentBase._context.createTextNode(item));
+                else
+                    result = result.concat(this._constructNodeTreeFromTemplate(item, didCreateElement));
+            }
+            return result;
+        }
+    }
+
+    createElement(name, attributes, content) { return CommonComponentBase.createElement(name, attributes, content); }
+
+    static createElement(name, attributes, content)
+    {
+        const element = CommonComponentBase._context.createElement(name);
+        if (!content && (Array.isArray(attributes) || CommonComponentBase._isNode(attributes)
+            || attributes instanceof CommonComponentBase._baseClass || typeof(attributes) != 'object')) {
+            content = attributes;
+            attributes = {};
+        }
+
+        if (attributes) {
+            for (const name in attributes) {
+                if (name.startsWith('on'))
+                    element.addEventListener(name.substring(2), attributes[name]);
+                else if (attributes[name] === true)
+                    element.setAttribute(name, '');
+                else if (attributes[name] !== false)
+                    element.setAttribute(name, attributes[name].toString());
+            }
+        }
+        
+        if (content)
+            CommonComponentBase._addContentToElement(element, content);
+
+        return element;
+    }
+
+    static _addContentToElement(element, content)
+    {
+        if (Array.isArray(content)) {
+            for (var nestedChild of content)
+                this._addContentToElement(element, nestedChild);
+        } else if (CommonComponentBase._isNode(content))
+            element.appendChild(content);
+         else if (content instanceof CommonComponentBase._baseClass)
+            element.appendChild(content.element());
+        else
+            element.appendChild(CommonComponentBase._context.createTextNode(content));
+    }
+
+    createLink(content, titleOrCallback, callback, isExternal)
+    {
+        return CommonComponentBase.createLink(content, titleOrCallback, callback, isExternal);
+    }
+
+    static createLink(content, titleOrCallback, callback, isExternal)
+    {
+        var title = titleOrCallback;
+        if (callback === undefined) {
+            title = content;
+            callback = titleOrCallback;
+        }
+
+        var attributes = {
+            href: '#',
+            title: title,
+        };
+
+        if (typeof(callback) === 'string')
+            attributes['href'] = callback;
+        else
+            attributes['onclick'] = CommonComponentBase._baseClass.createEventHandler(callback);
+
+        if (isExternal)
+            attributes['target'] = '_blank';
+        return CommonComponentBase.createElement('a', attributes, content);
+    }
+};
+
+CommonComponentBase._context = null;
+CommonComponentBase._isNode = null;
+CommonComponentBase._baseClass = null;
+
+if (typeof module != 'undefined')
+    module.exports.CommonComponentBase = CommonComponentBase;
index c288fef..42ed8fe 100644 (file)
@@ -1,7 +1,8 @@
 
-class ComponentBase {
+class ComponentBase extends CommonComponentBase {
     constructor(name)
     {
+        super();
         this._componentName = name || ComponentBase._componentByClass.get(new.target);
 
         const currentlyConstructed = ComponentBase._currentlyConstructedByInterface;
@@ -144,39 +145,56 @@ class ComponentBase {
         ComponentBase._componentsToRenderOnResize.delete(component);
     }
 
-    renderReplace(element, content) { ComponentBase.renderReplace(element, content); }
-
-    static renderReplace(element, content)
-    {
-        element.innerHTML = '';
-        if (content)
-            ComponentBase._addContentToElement(element, content);
-    }
-
     _ensureShadowTree()
     {
         if (this._shadow)
             return;
 
-        const newTarget = this.__proto__.constructor;
-        const htmlTemplate = newTarget['htmlTemplate'];
-        const cssTemplate = newTarget['cssTemplate'];
+        const thisClass = this.__proto__.constructor;
+
+        let content;
+        let stylesheet;
+        if (!thisClass._parsed) {
+            thisClass._parsed = true;
 
-        if (!htmlTemplate && !cssTemplate)
+            const contentTemplate = thisClass['contentTemplate'];
+            if (contentTemplate)
+                content = ComponentBase._constructNodeTreeFromTemplate(contentTemplate);
+            else if (thisClass.htmlTemplate) {
+                const templateElement = document.createElement('template');
+                templateElement.innerHTML = thisClass.htmlTemplate();
+                content = [templateElement.content];
+            }
+
+            const styleTemplate = thisClass['styleTemplate'];
+            if (styleTemplate)
+                stylesheet = ComponentBase._constructStylesheetFromTemplate(styleTemplate);
+            else if (thisClass.cssTemplate)
+                stylesheet = thisClass.cssTemplate();
+
+            thisClass._parsedContent = content;
+            thisClass._parsedStylesheet = stylesheet;
+        } else {
+            content = thisClass._parsedContent;
+            stylesheet = thisClass._parsedStylesheet;
+        }
+
+        if (!content && !stylesheet)
             return;
 
         const shadow = this._element.attachShadow({mode: 'closed'});
 
-        if (htmlTemplate) {
-            const template = document.createElement('template');
-            template.innerHTML = newTarget.htmlTemplate();
-            shadow.appendChild(document.importNode(template.content, true));
-            this._recursivelyReplaceUnknownElementsByComponents(shadow);
+        if (content) {
+            for (const node of content)
+                shadow.appendChild(document.importNode(node, true));
+            this._recursivelyUpgradeUnknownElements(shadow, (node) => {
+                return node instanceof Element ? ComponentBase._componentByName.get(node.localName) : null;
+            });
         }
 
-        if (cssTemplate) {
+        if (stylesheet) {
             const style = document.createElement('style');
-            style.textContent = newTarget.cssTemplate();
+            style.textContent = stylesheet;
             shadow.appendChild(style);
         }
         this._shadow = shadow;
@@ -185,29 +203,6 @@ class ComponentBase {
 
     didConstructShadowTree() { }
 
-    _recursivelyReplaceUnknownElementsByComponents(parent)
-    {
-        let nextSibling;
-        for (let child = parent.firstChild; child; child = child.nextSibling) {
-            if (child instanceof HTMLElement && !child.component) {
-                const elementInterface = ComponentBase._componentByName.get(child.localName);
-                if (elementInterface) {
-                    const component = new elementInterface();
-                    const newChild = component.element();
-
-                    for (let i = 0; i < child.attributes.length; i++) {
-                        const attr = child.attributes[i];
-                        newChild.setAttribute(attr.name, attr.value);
-                    }
-
-                    parent.replaceChild(newChild, child);
-                    child = newChild;
-                }
-            }
-            this._recursivelyReplaceUnknownElementsByComponents(child);
-        }
-    }
-
     static defineElement(name, elementInterface)
     {
         ComponentBase._componentByName.set(name, elementInterface);
@@ -254,68 +249,6 @@ class ComponentBase {
         customElements.define(name, elementClass);
     }
 
-    static createElement(name, attributes, content)
-    {
-        var element = document.createElement(name);
-        if (!content && (Array.isArray(attributes) || attributes instanceof Node
-            || attributes instanceof ComponentBase || typeof(attributes) != 'object')) {
-            content = attributes;
-            attributes = {};
-        }
-
-        if (attributes) {
-            for (let name in attributes) {
-                if (name.startsWith('on'))
-                    element.addEventListener(name.substring(2), attributes[name]);
-                else if (attributes[name] === true)
-                    element.setAttribute(name, name);
-                else if (attributes[name] !== false)
-                    element.setAttribute(name, attributes[name]);
-            }
-        }
-
-        if (content)
-            ComponentBase._addContentToElement(element, content);
-
-        return element;
-    }
-
-    static _addContentToElement(element, content)
-    {
-        if (Array.isArray(content)) {
-            for (var nestedChild of content)
-                this._addContentToElement(element, nestedChild);
-        } else if (content instanceof Node)
-            element.appendChild(content);
-         else if (content instanceof ComponentBase)
-            element.appendChild(content.element());
-        else
-            element.appendChild(document.createTextNode(content));
-    }
-
-    static createLink(content, titleOrCallback, callback, isExternal)
-    {
-        var title = titleOrCallback;
-        if (callback === undefined) {
-            title = content;
-            callback = titleOrCallback;
-        }
-
-        var attributes = {
-            href: '#',
-            title: title,
-        };
-
-        if (typeof(callback) === 'string')
-            attributes['href'] = callback;
-        else
-            attributes['onclick'] = ComponentBase.createEventHandler(callback);
-
-        if (isExternal)
-            attributes['target'] = '_blank';
-        return ComponentBase.createElement('a', attributes, content);
-    }
-
     createEventHandler(callback) { return ComponentBase.createEventHandler(callback); }
     static createEventHandler(callback)
     {
@@ -327,6 +260,10 @@ class ComponentBase {
     }
 }
 
+CommonComponentBase._context = document;
+CommonComponentBase._isNode = (node) => node instanceof Node;
+CommonComponentBase._baseClass = ComponentBase;
+
 ComponentBase.useNativeCustomElements = !!window.customElements;
 ComponentBase._componentByName = new Map;
 ComponentBase._componentByClass = new Map;
index 9c0876a..e731077 100644 (file)
@@ -39,6 +39,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
     <template id="unbundled-scripts">
         <script src="../shared/statistics.js"></script>
         <script src="../shared/common-remote.js"></script>
+        <script src="../shared/common-component-base.js"></script>
 
         <script src="instrumentation.js"></script>
         <script src="remote.js"></script>
diff --git a/Websites/perf.webkit.org/tools/js/markup-component.js b/Websites/perf.webkit.org/tools/js/markup-component.js
new file mode 100644 (file)
index 0000000..acf049d
--- /dev/null
@@ -0,0 +1,653 @@
+
+const MarkupDocument = new class MarkupDocument {
+    constructor()
+    {
+        this._nodeId = 1;
+    }
+
+    createContentRoot(host)
+    {
+        const id = this._nodeId++;
+        return new MarkupContentRoot(id, host);
+    }
+
+    createElement(name)
+    {
+        const id = this._nodeId++;
+        return new MarkupElement(id, name);
+    }
+
+    createTextNode(data)
+    {
+        const id = this._nodeId++;
+        const text = new MarkupText(id);
+        text.data = data;
+        return text;
+    }
+
+    _idForClone(original)
+    {
+        console.assert(original instanceof MarkupNode);
+        return this._nodeId++;
+    }
+
+    reset()
+    {
+        this._nodeId = 1;
+    }
+
+    markup(node)
+    {
+        console.assert(node instanceof MarkupNode);
+        return node._markup();
+    }
+
+    escapeAttributeValue(string)
+    {
+        return this.escapeNodeData(string).replace(/\"/g, '&quod8;').replace(/\'/g, '&apos;');
+    }
+
+    escapeNodeData(string)
+    {
+        return string.replace(/&/g, '&amp;').replace(/\</g, '&lt;').replace(/\>/g, '&gt;');
+    }
+}
+
+class MarkupNode {
+    constructor(id)
+    {
+        console.assert(typeof(id) == 'number');
+        this._id = id;
+        this._parentNode = null;
+    }
+
+    _markup()
+    {
+        throw 'NotImplemented';
+    }
+
+    clone()
+    {
+        throw 'NotImplemented';
+    }
+
+    _cloneNodeData(clonedNode)
+    {
+        console.assert(typeof(clonedNode._id) == 'number');
+        console.assert(this._id != clonedNode._id);
+        console.assert(clonedNode._parentNode == null);
+    }
+
+    remove()
+    {
+        const parentNode = this._parentNode;
+        if (parentNode)
+            parentNode.removeChild(this);
+    }
+}
+
+class MarkupParentNode extends MarkupNode {
+    constructor(id)
+    {
+        super(id);
+        this._childNodes = [];
+    }
+
+    get childNodes() { return this._childNodes.slice(0); }
+
+    _cloneNodeData(clonedNode)
+    {
+        super._cloneNodeData(clonedNode);
+        clonedNode._childNodes = this._childNodes.map((child) => {
+            const clonedChild = child.clone();
+            clonedChild._parentNode = clonedNode;
+            return clonedChild;
+        });
+    }
+
+    appendChild(child)
+    {
+        if (child._parentNode == this)
+            return;
+
+        if (child._parentNode)
+            child.remove();
+
+        console.assert(child._parentNode == null);
+        this._childNodes.push(child);
+        child._parentNode = this;
+    }
+
+    removeChild(child)
+    {
+        if (child._parentNode != this)
+            return;
+        const index = this._childNodes.indexOf(child);
+        console.assert(index >= 0);
+        this._childNodes.splice(index, 1);
+        child._parentNode = null;
+    }
+
+    removeAllChildren()
+    {
+        for (const child of this._childNodes)
+            child._parentNode = null;
+        this._childNodes = [];
+    }
+
+    replaceChild(newChild, oldChild)
+    {
+        if (oldChild._parentNode != this)
+            throw 'Invalid operation';
+
+        if (newChild._parentNode)
+            newChild.remove();
+        console.assert(newChild._parentNode == null);
+
+        const index = this._childNodes.indexOf(oldChild);
+        console.assert(index >= 0);
+        this._childNodes.splice(index, 1, newChild);
+        oldChild._parentNode = null;
+        newChild._parentNode = this;
+    }
+}
+
+class MarkupContentRoot extends MarkupParentNode {
+    constructor(id, host)
+    {
+        console.assert(host instanceof MarkupElement);
+        console.assert(host._contentRoot == null);
+        super(id);
+        this._hostElement = null;
+        host._contentRoot = this;
+    }
+
+    _markup()
+    {
+        let result = '';
+        for (const child of this._childNodes)
+            result += child._markup();
+        return result;
+    }
+}
+
+class MarkupElement extends MarkupParentNode {
+    constructor(id, name)
+    {
+        super(id);
+        console.assert(typeof(name) == 'string');
+        this._name = name;
+        this._attributes = new Map;
+        this._value = null;
+        this._styleProxy = null;
+        this._inlineStyleProperties = new Map;
+        this._contentRoot = null;
+    }
+
+    get id() { return this.getAttribute('id'); }
+    get localName() { return this._name; }
+
+    clone()
+    {
+        const clonedNode = new MarkupElement(MarkupDocument._idForClone(this), this._name);
+        super._cloneNodeData(clonedNode);
+        for (const [name, value] of this._attributes)
+            clonedNode._attributes.set(name, value);
+        clonedNode._value = this._value;
+        for (const [name, value] of this._inlineStyleProperties)
+            clonedNode._inlineStyleProperties.set(name, value);
+        if (this._contentRoot) {
+            const clonedContentRoot = new MarkupContentRoot(MarkupDocument._idForClone(this._contentRoot), clonedNode);
+            this._contentRoot._cloneNodeData(clonedContentRoot);
+        }
+        return clonedNode;
+    }
+
+    appendChild(child)
+    {
+        if (MarkupElement.selfClosingNames.includes(this._name))
+            throw 'The operation is not supported';
+        super.appendChild(child);
+    }
+
+    addEventListener(name, callback)
+    {
+        throw 'The operation is not supported';
+    }
+
+    setAttribute(name, value = null)
+    {
+        if (name == 'style')
+            this._inlineStyleProperties.clear();
+        this._attributes.set(name.toString(), '' + value);
+    }
+    
+    getAttribute(name, value)
+    {
+        if (name == 'style' && this._inlineStyleProperties.size)
+            return this._serializeStyle();
+        return this._attributes.get(name);
+    }
+
+    get attributes()
+    {
+        // FIXME: Add the support for named property.
+        const result = [];
+        for (const [name, value] of this._attributes)
+            result.push({localName: name, name, value});
+        return result;
+    }
+
+    get textContent()
+    {
+        let result = '';
+        for (const node of this._childNodes) {
+            if (node instanceof MarkupText)
+                result += node.data;
+        }
+        return result;
+    }
+
+    set textContent(newContent)
+    {
+        this.removeAllChildren();
+        if (newContent)
+            this.appendChild(MarkupDocument.createTextNode(newContent));
+    }
+
+    _serializeStyle()
+    {
+        let styleValue = '';
+        for (const [name, value] of this._inlineStyleProperties)
+            styleValue += (styleValue ? '; ' : '') + name + ': ' + value;
+        return styleValue;
+    }
+
+    _markup()
+    {
+        let markup = '<' + this._name;
+        if (this._styleProxy && this._inlineStyleProperties.size)
+            markup += ` style="${MarkupDocument.escapeAttributeValue(this._serializeStyle())}"`;
+        for (const [name, value] of this._attributes)
+            markup += ` ${name}="${MarkupDocument.escapeAttributeValue(value)}"`;
+        markup += '>';
+        if (this._contentRoot)
+            markup += this._contentRoot._markup();
+        else {
+            for (const child of this._childNodes)
+                markup += child._markup();
+        }
+        if (!MarkupElement.selfClosingNames.includes(this._name))
+            markup += '</' + this._name + '>';
+        return markup;
+    }
+
+    get value()
+    {
+        if (this._name != 'input')
+            throw 'The operation is not supported';
+        return this._value;
+    }
+    set value(value)
+    {
+        if (this._name != 'input')
+            throw 'The operation is not supported';
+        this._value = value.toString();
+    }
+
+    get style()
+    {
+        if (!this._styleProxy) {
+            const proxyTarget = {};
+            const cssPropertyFromJSProperty = (jsPropertyName) => {
+                let cssPropertyName = '';
+                for (let i = 0; i < jsPropertyName.length; i++) {
+                    const currentChar = jsPropertyName.charAt(i);
+                    if ('A' <= currentChar && currentChar <= 'Z')
+                        cssPropertyName += '-' + currentChar.toLowerCase();
+                    else
+                        cssPropertyName += currentChar;
+                }
+                return cssPropertyName;
+            };
+            this._styleProxy = new Proxy(proxyTarget, {
+                get: (target, property) => {
+                    throw 'The operation is not supported';
+                },
+                set: (target, property, value) => {
+                    this._inlineStyleProperties.set(cssPropertyFromJSProperty(property), value);
+                    return true;
+                },
+            });
+        }
+        return this._styleProxy;
+    }
+
+    set style(value)
+    {
+        throw 'This operation is not supported';
+    }
+
+    static get selfClosingNames() { return ['img', 'br', 'meta', 'link']; }
+}
+
+class MarkupText extends MarkupNode {
+    constructor(id)
+    {
+        super(id);
+        this._data = null;
+    }
+
+    clone()
+    {
+        const clonedNode = new MarkupText(MarkupDocument._idForClone(this));
+        clonedNode._data = this._data;
+        return clonedNode;
+    }
+
+    _markup()
+    {
+        return MarkupDocument.escapeNodeData(this._data);
+    }
+
+    get data() { return this._data; }
+    set data(newData) { this._data = newData.toString(); }
+}
+
+const componentsMap = new Map;
+const componentsByClass = new Map;
+const componentsToRender = new Set;
+let currentlyRenderingComponent = null;
+class MarkupComponentBase extends CommonComponentBase {
+    constructor(name)
+    {
+        super();
+        this._name = componentsByClass.get(new.target);
+        const component = componentsMap.get(this._name);
+        console.assert(component, `Component "${this._name}" has not been defined`);
+        this._componentId = component.id;
+        this._element = null;
+        this._contentRoot = null;
+    }
+
+    element()
+    {
+        if (!this._element) {
+            this._element = MarkupDocument.createElement(this._name);
+            this._element.component = () => this;
+        }
+        return this._element;
+    }
+
+    content(id = null)
+    {
+        this._ensureContentTree();
+        if (id) {
+            // FIXME: Make this more efficient.
+            return this._contentRoot ? this._findElementRecursivelyById(this._contentRoot, id) : null;
+        }
+        return this._contentRoot;
+    }
+
+    _findElementRecursivelyById(parent, id)
+    {
+        for (const child of parent.childNodes) {
+            if (child.id == id)
+                return child;
+            if (child instanceof MarkupParentNode) {
+                const result = this._findElementRecursivelyById(child, id);
+                if (result)
+                    return result;
+            }
+        }
+        return null;
+    }
+
+    render() { this._ensureContentTree(); }
+
+    enqueueToRender()
+    {
+        componentsToRender.add(this);
+    }
+
+    static runRenderLoop()
+    {
+        console.assert(!currentlyRenderingComponent);
+        do {
+            const currentSet = [...componentsToRender];
+            componentsToRender.clear();
+            for (let component of currentSet) {
+                const enqueuedAgain = componentsToRender.has(component);
+                if (enqueuedAgain)
+                    continue;
+                currentlyRenderingComponent = component;
+                component.render();
+            }
+            currentlyRenderingComponent = null;
+        } while (componentsToRender.size);
+    }
+
+    renderReplace(parentNode, content)
+    {
+        console.assert(currentlyRenderingComponent == this);
+        console.assert(parentNode instanceof MarkupParentNode);
+        parentNode.removeAllChildren();
+        if (content) {
+            MarkupComponentBase._addContentToElement(parentNode, content);
+
+            const component = componentsMap.get(this._name);
+            console.assert(component);
+            this._applyStyleOverrides(parentNode, component.styleOverride);
+        }
+    }
+
+    _applyStyleOverrides(node, styleOverride)
+    {
+        if (node instanceof MarkupElement)
+            styleOverride(node);
+        if (node.childNodes) {
+            for (const child of node.childNodes)
+                this._applyStyleOverrides(child, styleOverride);
+        }
+    }
+
+    _ensureContentTree()
+    {
+        if (this._contentRoot)
+            return;
+
+        const thisClass = this.__proto__.constructor;
+        const component = componentsMap.get(this._name);
+        if (!component.parsed) {
+            component.parsed = true;
+
+            const htmlTemplate = thisClass['htmlTemplate'];
+            const cssTemplate = thisClass['cssTemplate'];
+            if (htmlTemplate || cssTemplate)
+                throw 'The operation is not supported';
+
+            const contentTemplate = thisClass['contentTemplate'];
+            const styleTemplate = thisClass['styleTemplate'];
+            if (!contentTemplate && !styleTemplate)
+                return;
+
+            const result = MarkupComponentBase._parseTemplates(this._name, this._componentId, contentTemplate, styleTemplate);
+            component.content = result.content;
+            component.stylesheet = result.stylesheet;
+            component.styleOverride = result.styleOverride;
+        }
+
+        this._contentRoot = MarkupDocument.createContentRoot(this.element());
+        if (component.content) {
+            for (const node of component.content)
+                this._contentRoot.appendChild(node.clone());
+            this._recursivelyUpgradeUnknownElements(this._contentRoot,
+                (node) => {
+                    const component = node instanceof MarkupElement ? componentsMap.get(node.localName) : null;
+                    return component ? component.class : null;
+                },
+                (component) => component.enqueueToRender());
+        }
+        // FIXME: Add a call to didConstructShadowTree.
+    }
+
+    static reset()
+    {
+        console.assert(!currentlyRenderingComponent);
+        MarkupDocument.reset();
+        componentsMap.clear();
+        componentsByClass.clear();
+        componentsToRender.clear();
+    }
+
+    static _parseTemplates(componentName, componentId, contentTemplate, styleTemplate)
+    {
+        const styledClasses = new Map;
+        const styledElements = new Map;
+        let stylesheet = null;
+        let selectorId = 0;
+        let content = null;
+        let styleOverride = () => { }
+        if (styleTemplate) {
+            stylesheet = this._constructStylesheetFromTemplate(styleTemplate, (selector, rule) => {
+                if (selector == ':host')
+                    return componentName;
+
+                const match = selector.match(/^(\.?[a-zA-Z0-9\-]+)(\[[a-zA-Z0-9\-]+\]|\:[a-z\-]+)*$/);
+                if (!match)
+                    throw 'Unsupported selector: ' + selector;
+
+                const selectorSuffix = match[2] || '';
+                let globalClassName;
+                // FIXME: Preserve the specificity of selectors.
+                selectorId++;
+                if (match[1].startsWith('.')) {
+                    const className = match[1].substring(1);
+                    globalClassName = `content-${componentId}-class-${className}`;
+                    styledClasses.set(className, globalClassName);
+                    return '.' + globalClassName + selectorSuffix;
+                }
+
+                const elementName = match[1].toLowerCase();
+                globalClassName = `content-${componentId}-element-${elementName}`;
+                styledElements.set(elementName, globalClassName);
+                return '.' + globalClassName + selectorSuffix;
+            });
+
+            if (styledClasses.size || styledElements.size) {
+                styleOverride = (element) => {
+                    const classNamesToAdd = new Set;
+                    const globalClassNameForName = styledElements.get(element.localName);
+                    if (globalClassNameForName)
+                        classNamesToAdd.add(globalClassNameForName);
+
+                    const currentClass = element.getAttribute('class');
+                    if (currentClass) {
+                        const classList = currentClass.split(/\s+/);
+                        for (const className of classList) {
+                            const globalClass = styledClasses.get(className);
+                            if (globalClass)
+                                classNamesToAdd.add(globalClass);
+                            classNamesToAdd.add(className);
+                        }
+                        if (classList.length == classNamesToAdd.size)
+                            return;
+                    } else if (!classNamesToAdd.size)
+                        return;
+                    element.setAttribute('class', Array.from(classNamesToAdd).join(' '));
+                }
+            }
+        }
+
+        if (contentTemplate)
+            content = MarkupComponentBase._constructNodeTreeFromTemplate(contentTemplate, styleOverride);
+
+        return {stylesheet, content, styleOverride};
+    }
+
+    static defineElement(name, componentClass)
+    {
+        console.assert(!componentsMap.get(name), `The component "${name}" has already been defined`);
+        const existingComponentForClass = componentsByClass.get(componentClass);
+        console.assert(!existingComponentForClass, existingComponentForClass
+            ? `The component class "${existingComponentForClass}" has already been used to define another component "${existingComponentForClass.name}"` : '');
+        componentsMap.set(name, {
+            class: componentClass,
+            id: componentsMap.size + 1,
+            parsed: false,
+            content: null,
+            stylesheet: null,
+        });
+        componentsByClass.set(componentClass, name);
+    }
+
+    createEventHandler(callback) { return MarkupComponentBase.createEventHandler(callback); }
+    static createEventHandler(callback)
+    {
+        throw 'The operation is not supported';
+    }
+}
+CommonComponentBase._context = MarkupDocument;
+CommonComponentBase._isNode = (node) => node instanceof MarkupNode;
+CommonComponentBase._baseClass = MarkupComponentBase;
+
+class MarkupPage extends MarkupComponentBase {
+    constructor(title)
+    {
+        super('page-component');
+        this._title = title;
+        this._updateComponentsStylesheetLazily = new LazilyEvaluatedFunction(this._updateComponentsStylesheet.bind(this));
+    }
+
+    pageTitle() { return this._title; }
+
+    content(id)
+    {
+        if (id)
+            return super.content(id);
+        return super.content('page-body');
+    }
+
+    render()
+    {
+        super.render();
+        this.content('page-title').textContent = this.pageTitle();
+        this._updateComponentsStylesheetLazily.evaluate([...componentsMap.values()].filter((component) => component.parsed && component.stylesheet));
+    }
+
+    _updateComponentsStylesheet(componentsWithStylesheets)
+    {
+        let mergedStylesheetText = '';
+        for (const component of componentsWithStylesheets)
+            mergedStylesheetText += component.stylesheet;
+        this.content('component-style-rules').textContent = mergedStylesheetText;
+    }
+
+    static get contentTemplate()
+    {
+        return ['html', [
+            ['head', [
+                ['title', {id: 'page-title'}],
+                ['style', {id: 'component-style-rules'}]
+            ]],
+            ['body', {id: 'page-body'}, this.pageContent]
+        ]];
+    }
+
+    generateMarkup()
+    {
+        this.enqueueToRender(this);
+        MarkupComponentBase.runRenderLoop();
+        this.enqueueToRender(this);
+        MarkupComponentBase.runRenderLoop();
+        return '<!DOCTYPE html>' + MarkupDocument.markup(super.content());
+    }
+
+}
+MarkupComponentBase.defineElement('page-component', MarkupPage);
+
+if (typeof module != 'undefined') {
+    module.exports.MarkupDocument = MarkupDocument;
+    module.exports.MarkupComponentBase = MarkupComponentBase;
+    module.exports.MarkupPage = MarkupPage;
+}
diff --git a/Websites/perf.webkit.org/unit-tests/markup-component-base-tests.js b/Websites/perf.webkit.org/unit-tests/markup-component-base-tests.js
new file mode 100644 (file)
index 0000000..e87b639
--- /dev/null
@@ -0,0 +1,514 @@
+'use strict';
+
+const assert = require('assert');
+global.LazilyEvaluatedFunction = require('../public/v3/lazily-evaluated-function.js').LazilyEvaluatedFunction;
+global.CommonComponentBase = require('../public/shared/common-component-base.js').CommonComponentBase;
+const MarkupComponentBase = require('../tools/js/markup-component.js').MarkupComponentBase;
+
+describe('MarkupComponentBase', function () {
+    beforeEach(() => {
+        MarkupComponentBase.reset();
+    });
+
+    describe('constructor', function () {
+        it('should construct a component', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            assert.ok(component instanceof SomeComponent);
+        });
+
+        it('should throw if the component had not been defined', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            assert.throws(() => new SomeComponent);
+        });
+
+        it('should throw if the component was defined with a different class (legacy named-based lookup should not be supported)', () => {
+            class SomeComponent extends MarkupComponentBase {
+                constructor() { super('some-component'); }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            class OtherComponent extends MarkupComponentBase {
+                constructor() { super('some-component'); }
+            };
+            assert.throws(() => new OtherComponent);
+        });
+    });
+
+    describe('defineElement', function () {
+        it('should throw if the component had already been defined', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            assert.throws(() => MarkupComponentBase.defineElement('some-component', SomeComponent));
+        });
+
+        it('should throw if the same class has already been used to define another component', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            assert.throws(() => MarkupComponentBase.defineElement('other-component', SomeComponent));
+        });
+    });
+
+    describe('element', function () {
+        it('should return a MarkupElement', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            const element = component.element();
+            assert.ok(element);
+            assert.equal(element.__proto__.constructor.name, 'MarkupElement');
+        });
+
+        it('should return the same element each time called', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            const element = component.element();
+            assert.equal(component.element(), element);
+        });
+
+        it('should return a different element for each instance', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component1 = new SomeComponent;
+            const component2 = new SomeComponent;
+            assert.notEqual(component1.element(), component2.element());
+        });
+    });
+
+    describe('content', function () {
+        it('should parse the content template once', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            SomeComponent.contentTemplate = ['span', {'id': 'some'}, 'original'];
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance1 = new SomeComponent;
+            assert.equal(instance1.content('some').textContent, 'original');
+            SomeComponent.contentTemplate = ['span', {'id': 'some'}, 'modified'];
+            const instance2 = new SomeComponent;
+            assert.equal(instance2.content('some').textContent, 'original');
+        });
+
+        it('should upgrade components in the content tree', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            SomeComponent.contentTemplate = ['span', ['other-component', {'id': 'other'}, 'hello']];
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            class OtherComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('other-component', OtherComponent);
+            const someComponent = new SomeComponent;
+            const otherComponent = someComponent.content('other');
+            assert.equal(otherComponent.localName, 'other-component');
+            assert.equal(otherComponent.textContent, 'hello');
+            assert.ok(otherComponent.component() instanceof OtherComponent);
+        });
+
+        it('should upgrade components in the content tree in each instance', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            SomeComponent.contentTemplate = ['span', ['other-component', {'id': 'other'}, 'hello']];
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            let constructorCount = 0;
+            class OtherComponent extends MarkupComponentBase {
+                constructor(...args)
+                {
+                    super(...args);
+                    constructorCount++;
+                }
+            };
+            MarkupComponentBase.defineElement('other-component', OtherComponent);
+            assert.equal(constructorCount, 0);
+            const someComponent1 = new SomeComponent;
+            assert.ok(someComponent1.content('other').component() instanceof OtherComponent);
+            assert.equal(constructorCount, 1);
+            assert.ok(someComponent1.content('other').component() instanceof OtherComponent);
+            const someComponent2 = new SomeComponent;
+            assert.equal(constructorCount, 1);
+            assert.ok(someComponent2.content('other').component() instanceof OtherComponent);
+            assert.equal(constructorCount, 2);
+        });
+
+        it('should throw when the style template contains an unsupported selector', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            SomeComponent.styleTemplate = {'div.target': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            assert.throws(() => component.content());
+        });
+
+        describe('without arguments', function () {
+            it('should return null when there are no templates', () => {
+                class SomeComponent extends MarkupComponentBase { };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                assert.equal(component.content(), null);
+            });
+
+            it('should return a MarkupContentRoot when there is a content template', () => {
+                class SomeComponent extends MarkupComponentBase {
+                    static get contentTemplate() { return []; }
+                };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                const contentRoot = component.content();
+                assert.ok(contentRoot);
+                assert.equal(contentRoot.__proto__.constructor.name, 'MarkupContentRoot');
+                assert.deepEqual(contentRoot.childNodes, []);
+            });
+        });
+
+        describe('with an ID', () => {
+            it('should return null when there are no templates', () => {
+                class SomeComponent extends MarkupComponentBase { };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                assert.equal(component.content('some'), null);
+            });
+
+            it('should return null when there is a content template but no matching element', () => {
+                class SomeComponent extends MarkupComponentBase {
+                    static get contentTemplate() { return ['span', {'id': 'other'}, 'hello world']; }
+                };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                const contentRoot = component.content();
+                assert.ok(contentRoot);
+                assert.equal(component.content('some'), null);
+            });
+
+            it('should return the matching element when there is one', () => {
+                class SomeComponent extends MarkupComponentBase {
+                    static get contentTemplate() { return ['span', {'id': 'some'}, 'hello world']; }
+                };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                const contentRoot = component.content();
+                assert.ok(contentRoot);
+                const element = component.content('some');
+                assert.ok(element);
+                assert.equal(element.__proto__.constructor.name, 'MarkupElement');
+                assert.equal(element.id, 'some');
+                assert.equal(element.localName, 'span');
+                assert.equal(element.textContent, 'hello world');
+            });
+
+            it('should return the first matching element in the tree order', () => {
+                class SomeComponent extends MarkupComponentBase {
+                    static get contentTemplate() { return [
+                        ['div', ['b', {'id': 'some'}, 'hello']],
+                        ['span', {'id': 'some'}, 'world'],
+                    ]; }
+                };
+                MarkupComponentBase.defineElement('some-component', SomeComponent);
+                const component = new SomeComponent;
+                const contentRoot = component.content();
+                assert.ok(contentRoot);
+                const element = component.content('some');
+                assert.ok(element);
+                assert.equal(element.__proto__.constructor.name, 'MarkupElement');
+                assert.equal(element.id, 'some');
+                assert.equal(element.localName, 'b');
+                assert.equal(element.textContent, 'hello');
+            });
+        });
+    });
+
+    describe('enqueueRender', function () {
+        it('should enqueue the component to render', () => {
+            let renderCalls = 0;
+            class SomeComponent extends MarkupComponentBase {
+                render() { renderCalls++; }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            component.enqueueToRender();
+            assert.equal(renderCalls, 0);
+            MarkupComponentBase.runRenderLoop();
+            assert.equal(renderCalls, 1);
+        });
+
+        it('should not enqueue the same component multiple times', () => {
+            let renderCalls = 0;
+            class SomeComponent extends MarkupComponentBase {
+                render() { renderCalls++; }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component = new SomeComponent;
+            component.enqueueToRender();
+            component.enqueueToRender();
+            assert.equal(renderCalls, 0);
+            MarkupComponentBase.runRenderLoop();
+            assert.equal(renderCalls, 1);
+        });
+    });
+
+    describe('runRenderLoop', function () {
+        it('should invoke render() on enqueued components in the oreder', () => {
+            let renderCalls = [];
+            class SomeComponent extends MarkupComponentBase {
+                render() { renderCalls.push(this); }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const component1 = new SomeComponent;
+            const component2 = new SomeComponent;
+            component1.enqueueToRender();
+            component2.enqueueToRender();
+            assert.deepEqual(renderCalls, []);
+            MarkupComponentBase.runRenderLoop();
+            assert.deepEqual(renderCalls, [component1, component2]);
+        });
+
+        it('should process cascading calls to enqueueRender()', () => {
+            let renderCalls = [];
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    renderCalls.push(this);
+                    if (this == instance1)
+                        instance2.enqueueToRender();
+                }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance1 = new SomeComponent;
+            const instance2 = new SomeComponent;
+            instance1.enqueueToRender();
+            assert.deepEqual(renderCalls, []);
+            MarkupComponentBase.runRenderLoop();
+            assert.deepEqual(renderCalls, [instance1, instance2]);
+        });
+
+        it('should delay render() call upon a cascading enqueuing', () => {
+            let renderCalls = [];
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    renderCalls.push(this);
+                    if (this == instance1)
+                        instance2.enqueueToRender();
+                }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance1 = new SomeComponent;
+            const instance2 = new SomeComponent;
+            instance1.enqueueToRender();
+            instance2.enqueueToRender();
+            assert.deepEqual(renderCalls, []);
+            MarkupComponentBase.runRenderLoop();
+            assert.deepEqual(renderCalls, [instance1, instance2]);
+        });
+
+        it('should call render() again when a cascading enqueueing occurs after the initial call', () => {
+            let renderCalls = [];
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    renderCalls.push(this);
+                    if (this == instance1)
+                        instance2.enqueueToRender();
+                }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance1 = new SomeComponent;
+            const instance2 = new SomeComponent;
+            instance2.enqueueToRender();
+            instance1.enqueueToRender();
+            assert.deepEqual(renderCalls, []);
+            MarkupComponentBase.runRenderLoop();
+            assert.deepEqual(renderCalls, [instance1, instance2, instance1]);
+        });
+    });
+
+    describe('renderReplace', function () {
+        it('should remove old children', () => {
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    const element = MarkupComponentBase.createElement;
+                    this.renderReplace(this.content(), element('b', 'world'));
+                }
+
+                static get contentTemplate() {
+                    return ['span', 'hello'];
+                }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance = new SomeComponent;
+
+            let content = instance.content();
+            assert.equal(content.childNodes.length, 1);
+            assert.equal(content.childNodes[0].localName, 'span');
+            assert.equal(content.childNodes[0].textContent, 'hello');
+
+            instance.enqueueToRender();
+            MarkupComponentBase.runRenderLoop();
+
+            content = instance.content();
+            assert.equal(content.childNodes.length, 1);
+            assert.equal(content.childNodes[0].localName, 'b');
+            assert.equal(content.childNodes[0].textContent, 'world');
+        });
+
+        it('should insert the element of a component in the content tree', () => {
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    this.renderReplace(this.content(), new OtherComponent);
+                }
+                static get contentTemplate() {
+                    return [];
+                }
+            };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+
+            class OtherComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('other-component', OtherComponent);
+
+            const someComponent = new SomeComponent;
+            const content = someComponent.content();
+            assert.equal(content.childNodes.length, 0);
+
+            someComponent.enqueueToRender();
+            MarkupComponentBase.runRenderLoop();
+
+            assert.equal(content.childNodes.length, 1);
+            assert.equal(content.childNodes[0].localName, 'other-component');
+            assert.equal(content.childNodes[0].textContent, '');
+            const otherComponent = content.childNodes[0].component();
+            assert.ok(otherComponent instanceof OtherComponent);
+        });
+
+        it('should add classes to the generated elements if there are matching styles', () => {
+            class SomeComponent extends MarkupComponentBase {
+                render() {
+                    this.renderReplace(this.content(), [
+                        this.createElement('div'),
+                        this.createElement('section', {class: 'target'}),
+                    ]);
+                }
+            };
+            SomeComponent.styleTemplate = {
+                'div': {'font-weight': 'bold'},
+                '.target': {'border': 'solid 1px blue'},
+            }
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const instance = new SomeComponent;
+            instance.enqueueToRender();
+            MarkupComponentBase.runRenderLoop();
+
+            const content = instance.content();
+            const div = content.childNodes[0];
+            const section = content.childNodes[1];
+            assert.equal(div.localName, 'div');
+            assert.ok(div.getAttribute('class'));
+            assert.equal(section.localName, 'section');
+            assert.ok(section.getAttribute('class').split(/\s+/).length, 2);
+        });
+    });
+
+    describe('createElement', function () {
+
+        it('should create an element of the specified name', () => {
+            const div = MarkupComponentBase.createElement('div');
+            assert.equal(div.localName, 'div');
+            assert.equal(div.__proto__.constructor.name, 'MarkupElement');
+        });
+
+        it('should create an element with the specified attributes', () => {
+            const input = MarkupComponentBase.createElement('input', {'title': 'hi', 'id': 'foo', 'required': false, 'checked': true});
+            assert.equal(input.localName, 'input');
+            assert.equal(input.attributes.length, 3);
+            assert.equal(input.attributes[0].localName, 'title');
+            assert.equal(input.attributes[0].value, 'hi');
+            assert.equal(input.attributes[1].localName, 'id');
+            assert.equal(input.attributes[1].value, 'foo');
+            assert.equal(input.attributes[2].localName, 'checked');
+            assert.equal(input.attributes[2].value, '');
+        });
+
+        it('should throw when an event handler is set', () => {
+            assert.throws(() => MarkupComponentBase.createElement('a', {'onclick': () => {}}));
+        });
+
+        it('should create an element with the specified children when the second argument is a span', () => {
+            const element = MarkupComponentBase.createElement;
+            const span = element('span');
+            const div = element('div', span);
+            assert.equal(div.attributes.length, 0);
+            assert.equal(div.childNodes.length, 1);
+            assert.equal(div.childNodes[0], span);
+        });
+
+        it('should create an element with the specified children when the second argument is a string', () => {
+            const element = MarkupComponentBase.createElement;
+            const div = element('div', 'hello');
+            assert.equal(div.attributes.length, 0);
+            assert.equal(div.childNodes.length, 1);
+            assert.equal(div.childNodes[0].__proto__.constructor.name, 'MarkupText');
+            assert.equal(div.childNodes[0].data, 'hello');
+        });
+
+        it('should create an element with the specified children when the second argument is a component', () => {
+            class SomeComponent extends MarkupComponentBase { };
+            MarkupComponentBase.defineElement('some-component', SomeComponent);
+            const element = MarkupComponentBase.createElement;
+            const component = new SomeComponent;
+            const div = element('div', component);
+            assert.equal(div.attributes.length, 0);
+            assert.equal(div.childNodes.length, 1);
+            assert.equal(div.childNodes[0], component.element());
+        });
+
+        it('should create an element with the specified attributes and children', () => {
+            const element = MarkupComponentBase.createElement;
+            const span = element('span');
+            const div = element('div', {'lang': 'en'}, [span, 'hi']);
+            assert.equal(div.localName, 'div');
+            assert.equal(div.attributes.length, 1);
+            assert.equal(div.attributes[0].localName, 'lang');
+            assert.equal(div.attributes[0].value, 'en');
+            assert.equal(div.childNodes.length, 2);
+            assert.equal(div.childNodes[0], span);
+            assert.equal(div.childNodes[1].data, 'hi');
+        });
+    });
+
+    describe('createLink', function () {
+        it('should create an anchor element', () => {
+            const anchor = MarkupComponentBase.createLink('hello', '#some-url');
+            assert.equal(anchor.localName, 'a');
+            assert.equal(anchor.__proto__.constructor.name, 'MarkupElement');
+        });
+
+        it('should create an anchor element with href and title when the second argument is a string and the third argument is ommitted', () => {
+            const anchor = MarkupComponentBase.createLink('hello', '#some-url');
+            assert.equal(anchor.localName, 'a');
+            assert.equal(anchor.__proto__.constructor.name, 'MarkupElement');
+            assert.equal(anchor.attributes.length, 2);
+            assert.equal(anchor.getAttribute('href'), '#some-url');
+            assert.equal(anchor.getAttribute('title'), 'hello');
+            assert.equal(anchor.textContent, 'hello');
+        });
+
+        it('should create an anchor element with href and title when the second and third arguments are string', () => {
+            const anchor = MarkupComponentBase.createLink('hello', 'some link', '#some-url');
+            assert.equal(anchor.localName, 'a');
+            assert.equal(anchor.__proto__.constructor.name, 'MarkupElement');
+            assert.equal(anchor.attributes.length, 2);
+            assert.equal(anchor.getAttribute('href'), '#some-url');
+            assert.equal(anchor.getAttribute('title'), 'some link');
+            assert.equal(anchor.textContent, 'hello');
+        });
+
+        it('should throw when the second argument is a function', () => {
+            assert.throws(() => MarkupComponentBase.createLink('hello', () => { }));
+        });
+
+        it('should throw when the third argument is a function', () => {
+            assert.throws(() => MarkupComponentBase.createLink('hello', 'some link', () => { }));
+        });
+
+        it('should create an anchor element with target=_blank when isExternal is true', () => {
+            const anchor = MarkupComponentBase.createLink('hello', 'some link', '#some-url', true);
+            assert.equal(anchor.localName, 'a');
+            assert.equal(anchor.__proto__.constructor.name, 'MarkupElement');
+            assert.equal(anchor.attributes.length, 3);
+            assert.equal(anchor.getAttribute('href'), '#some-url');
+            assert.equal(anchor.getAttribute('title'), 'some link');
+            assert.equal(anchor.getAttribute('target'), '_blank');
+            assert.equal(anchor.textContent, 'hello');
+        });
+    });
+   
+});
diff --git a/Websites/perf.webkit.org/unit-tests/markup-element-tests.js b/Websites/perf.webkit.org/unit-tests/markup-element-tests.js
new file mode 100644 (file)
index 0000000..459f1ba
--- /dev/null
@@ -0,0 +1,66 @@
+'use strict';
+
+const assert = require('assert');
+global.LazilyEvaluatedFunction = require('../public/v3/lazily-evaluated-function.js').LazilyEvaluatedFunction;
+global.CommonComponentBase = require('../public/shared/common-component-base.js').CommonComponentBase;
+const MarkupComponentBase = require('../tools/js/markup-component.js').MarkupComponentBase;
+
+describe('MarkupElement', function () {
+    beforeEach(() => {
+        MarkupComponentBase.reset();
+    });
+
+    function createElement(name)
+    {
+        class DummyComponent extends MarkupComponentBase { }
+        DummyComponent.contentTemplate = [name];
+        MarkupComponentBase.defineElement('dummy-component', DummyComponent);
+        const component = new DummyComponent;
+        return component.content().childNodes[0];
+    }
+
+    describe('style', function () {
+        it('should set the style content attribute', () => {
+            const div = createElement('div');
+            assert.equal(div.getAttribute('style'), null);
+            div.style.color = 'blue';
+            assert.equal(div.getAttribute('style'), 'color: blue');
+        });
+
+        it('should convert camelCased property names', () => {
+            const div = createElement('div');
+            assert.equal(div.getAttribute('style'), null);
+            div.style.fontWeight = 'bold';
+            assert.equal(div.getAttribute('style'), 'font-weight: bold');
+        });
+
+        it('should be able to serialize multiple properties', () => {
+            const div = createElement('div');
+            assert.equal(div.getAttribute('style'), null);
+            div.style.color = 'blue';
+            div.style.fontWeight = 'bold';
+            assert.equal(div.getAttribute('style'), 'color: blue; font-weight: bold');
+        });
+
+        it('should override properties after the conversion from camelCase', () => {
+            const div = createElement('div');
+            assert.equal(div.getAttribute('style'), null);
+            div.style['font-weight'] = 'bold';
+            assert.equal(div.getAttribute('style'), 'font-weight: bold');
+            div.style.fontWeight = 'normal';
+            assert.equal(div.getAttribute('style'), 'font-weight: normal');
+        });
+    });
+
+    describe('setAttribute', function () {
+        it('should override the inline style', () => {
+            const div = createElement('div');
+            assert.equal(div.getAttribute('style'), null);
+            div.style.color = 'blue';
+            assert.equal(div.getAttribute('style'), 'color: blue');
+            div.setAttribute('style', 'font-weight: bold');
+            assert.equal(div.getAttribute('style'), 'font-weight: bold');
+        });
+    });
+   
+});
diff --git a/Websites/perf.webkit.org/unit-tests/markup-page-tests.js b/Websites/perf.webkit.org/unit-tests/markup-page-tests.js
new file mode 100644 (file)
index 0000000..03f71e1
--- /dev/null
@@ -0,0 +1,39 @@
+'use strict';
+
+const assert = require('assert');
+global.LazilyEvaluatedFunction = require('../public/v3/lazily-evaluated-function.js').LazilyEvaluatedFunction;
+global.CommonComponentBase = require('../public/shared/common-component-base.js').CommonComponentBase;
+const {MarkupComponentBase, MarkupPage} = require('../tools/js/markup-component.js');
+
+describe('MarkupPage', function () {
+    beforeEach(() => {
+        MarkupComponentBase.reset();
+    });
+
+    describe('generateMarkup', function () {
+        it('should render page contents', () => {
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', 'hello, world'];
+            MarkupComponentBase.defineElement('some-page', SomePage);
+            const page = new SomePage;
+            assert.ok(page instanceof SomePage);
+            const markup = page.generateMarkup();
+            assert.ok(markup.startsWith('<!DOCTYPE html><html><head'));
+            assert.ok(markup.includes('</head><body'));
+            assert.ok(markup.endsWith('</body></html>'));
+            assert.ok(markup.includes('<div>hello, world</div>'));
+        });
+
+        it('should render page contents with stylesheet when a style template is available', () => {
+            class SomePage extends MarkupPage { }
+            SomePage.pageContent = ['div', {class: 'container'}, 'hello, world'];
+            SomePage.styleTemplate = {'.container': {'font-weight': 'bold'}};
+            MarkupComponentBase.defineElement('some-page', SomePage);
+            const page = new SomePage;
+            assert.ok(page instanceof SomePage);
+            const markup = page.generateMarkup();
+            assert.ok(markup.search(/font-weight\:\s*bold;\s*}\s*<\/style>/));
+        });
+    });
+   
+});