Skip to content

Commit d4fe312

Browse files
Support single-file component inputs
If the input contains a single root <template> and the other root elements are all <script> or <style>, then pick the <template> contents as the actual root element and only render its contents. This allows us to use SFC *.vue files as the input. It’s worth noting that we can’t use getBodyElement() directly, as I had originally hoped – if the SFC starts with a <script> and then has a <template> afterwards, rather than starting with the <template>, then the PHP HTML parser will actually place the whole contents inside a synthetic <head> rather than a synthetic <body>. (Such an SFC structure is disallowed by eslint-config-wikimedia via vue/component-tags-order, but I don’t think this library should rely on that.) Bug: T395802
1 parent e48a191 commit d4fe312

File tree

3 files changed

+134
-10
lines changed

3 files changed

+134
-10
lines changed

src/HtmlParser.php

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,11 @@ public function parseHtml( string $html ): DOMDocument {
5353
* Get the root node of the template represented by the given document.
5454
*/
5555
public function getRootNode( DOMDocument $document ): DOMElement {
56-
$rootNodes = $this->getBodyElement( $document )->childNodes;
57-
58-
if ( $rootNodes->length > 1 ) {
59-
throw new Exception( 'Template should have only one root node' );
60-
}
61-
62-
return $rootNodes[0];
56+
$htmlElement = $this->getHtmlElement( $document );
57+
$headOrBody = $this->getSoleHeadOrBody( $htmlElement );
58+
$rootNodeParent = $this->getTemplateElement( $headOrBody ) ?? $headOrBody;
59+
$rootNode = $this->getOnlySubstantialChild( $rootNodeParent );
60+
return $rootNode;
6361
}
6462

6563
/**
@@ -88,4 +86,90 @@ public function getBodyElement( DOMDocument $document ): DOMElement {
8886
return $bodyElement;
8987
}
9088

89+
/**
90+
* Get the `<head>` or `<body>` element of the given document,
91+
* asserting that it is the only child (cannot have both nor any other children).
92+
*/
93+
private function getSoleHeadOrBody( DOMElement $htmlElement ): DOMElement {
94+
$length = $htmlElement->childNodes->length;
95+
if ( $length !== 1 ) {
96+
throw new Exception( "Expected exactly 1 <html> child, got $length" );
97+
}
98+
99+
$child = $htmlElement->childNodes[0];
100+
$tagName = $child->tagName;
101+
if ( $tagName !== 'head' && $tagName !== 'body' ) {
102+
throw new Exception( "Expected <head> or <body>, got <$tagName>" );
103+
}
104+
105+
return $child;
106+
}
107+
108+
/**
109+
* Get the `<template>` element of the given `<head>` or `<body>` element,
110+
* discarding any adjacent `<script>` or `<style>` elements
111+
* if the input is in Single-File Component (SFC) syntax.
112+
*/
113+
private function getTemplateElement( DOMElement $rootElement ): ?DOMElement {
114+
$onlyTemplateElement = null;
115+
foreach ( $rootElement->childNodes as $node ) {
116+
if ( $node->nodeType === XML_COMMENT_NODE ) {
117+
// comment node, ignore
118+
continue;
119+
} elseif ( $node->nodeType === XML_TEXT_NODE ) {
120+
if ( trim( $node->textContent ) === '' ) {
121+
// whitespace-only text node, ignore
122+
continue;
123+
} else {
124+
// not SFC
125+
$onlyTemplateElement = null;
126+
break;
127+
}
128+
}
129+
if ( $node->tagName === 'template' ) {
130+
if ( $onlyTemplateElement === null ) {
131+
$onlyTemplateElement = $node;
132+
} else {
133+
// more than one <template>, handle as non-SFC and throw error below
134+
$onlyTemplateElement = null;
135+
break;
136+
}
137+
} elseif ( $node->tagName !== 'script' && $node->tagName !== 'style' ) {
138+
// top-level tag other than <template>, <script> or <style> => not SFC
139+
$onlyTemplateElement = null;
140+
break;
141+
}
142+
}
143+
return $onlyTemplateElement;
144+
}
145+
146+
/**
147+
* Get the only “substantial” child of the given element.
148+
* Ignore any adjacent comments or whitespace-only text nodes
149+
* (such as line breaks or indentation).
150+
*/
151+
private function getOnlySubstantialChild( DOMElement $element ): DOMElement {
152+
$onlySubstantialChild = null;
153+
foreach ( $element->childNodes as $node ) {
154+
if ( $node->nodeType === XML_COMMENT_NODE ) {
155+
// comment node, ignore
156+
continue;
157+
} elseif ( $node->nodeType === XML_TEXT_NODE && trim( $node->textContent ) === '' ) {
158+
// whitespace-only text node, ignore
159+
continue;
160+
}
161+
if ( $onlySubstantialChild === null ) {
162+
$onlySubstantialChild = $node;
163+
} else {
164+
throw new Exception( 'Template should only have one root node' );
165+
}
166+
}
167+
168+
if ( $onlySubstantialChild !== null ) {
169+
return $onlySubstantialChild;
170+
} else {
171+
throw new Exception( 'Template contained no root node' );
172+
}
173+
}
174+
91175
}

tests/php/HtmlParserTest.php

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,31 @@ private function parseAndGetRootNode( string $html ): DOMElement {
1818
return $htmlParser->getRootNode( $document );
1919
}
2020

21+
private function assertIsDivTest( DOMElement $element ): void {
22+
$this->assertSame( 'div', $element->tagName );
23+
$this->assertSame( 'test', $element->className );
24+
}
25+
2126
public function testSingleRootNode(): void {
2227
$rootNode = $this->parseAndGetRootNode( '<div class="test"></div>' );
28+
$this->assertIsDivTest( $rootNode );
29+
}
30+
31+
public function testSingleFileComponent_OnlyTemplate(): void {
32+
$rootNode = $this->parseAndGetRootNode( '<template><div class="test"></div></template>' );
33+
$this->assertIsDivTest( $rootNode );
34+
}
35+
36+
public function testSingleFileComponent_TemplateAndScriptAndStyle(): void {
37+
$template = '<template><div class="test"></div></template><script></script><style></style>';
38+
$rootNode = $this->parseAndGetRootNode( $template );
39+
$this->assertIsDivTest( $rootNode );
40+
}
2341

24-
$this->assertSame( 'div', $rootNode->tagName );
25-
$this->assertSame( 'test', $rootNode->className );
42+
public function testSingleFileComponent_ScriptAndTemplateAndStyle(): void {
43+
$template = '<script></script><template><div class="test"></div></template><style></style>';
44+
$rootNode = $this->parseAndGetRootNode( $template );
45+
$this->assertIsDivTest( $rootNode );
2646
}
2747

2848
public function testEmptyDocument(): void {
@@ -34,7 +54,7 @@ public function testEmptyDocument(): void {
3454
public function testHeadElement(): void {
3555
$html = '<html><head><title>Title</title></head><body>ABC</body></html>';
3656
$this->expectException( Exception::class );
37-
$this->expectExceptionMessage( 'Expected <body>, got <head>' );
57+
$this->expectExceptionMessage( 'Expected exactly 1 <html> child, got 2' );
3858
$this->parseAndGetRootNode( $html );
3959
}
4060

tests/php/TemplatingTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ public function testJustASingleEmptyHtmlElement() {
1818
$this->assertSame( '<div></div>', $result );
1919
}
2020

21+
public function testSingleFileComponent(): void {
22+
$template = <<< 'EOF'
23+
<template>
24+
<!-- eslint-disable-next-line something -->
25+
<div></div>
26+
</template>
27+
<script setup>
28+
const something = 'something';
29+
</script>
30+
<style scoped>
31+
.some-class {
32+
font-weight: bold;
33+
}
34+
</style>
35+
EOF;
36+
$result = $this->createAndRender( $template, [] );
37+
38+
$this->assertSame( '<div></div>', $result );
39+
}
40+
2141
public function testTemplateHasOnClickHandler_RemoveHandlerFormOutput() {
2242
$result = $this->createAndRender( '<div v-on:click="doStuff"></div>', [] );
2343

0 commit comments

Comments
 (0)