Commit c073381e authored by dpapad's avatar dpapad Committed by Commit Bot

WebUI Polymer3: Add tool to auto-generate Polymer3 from v2 files.

This CL adds the core functionality of the polymer_modulizer() GN rule.
In follow up CLs, the js_modulizer() will be added, and some code will
be shared between the two tools.

Bug: 965770
Change-Id: I5f9e925c38d41d294e31877bfa7852e3fc5a88b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1663734
Commit-Queue: Demetrios Papadopoulos <dpapad@chromium.org>
Reviewed-by: default avatarRebekah Potter <rbpotter@chromium.org>
Cr-Commit-Position: refs/heads/master@{#672330}
parent e3c709bc
...@@ -2,17 +2,32 @@ ...@@ -2,17 +2,32 @@
# Use of this source code is governed by a BSD-style license that can be # Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file. # found in the LICENSE file.
template("js_html_template") { common_namespace_rewrites = [
action(target_name + "_js_html_template") { "Polymer.PaperRippleBehavior|PaperRippleBehavior",
"cr.ui.FocusOutlineManager|FocusOutlineManager",
# TODO(dpapad): Add more such rewrites as they get discovered.
]
template("polymer_modulizer") {
action(target_name + "_module") {
script = "//tools/polymer/polymer.py" script = "//tools/polymer/polymer.py"
inputs = [ inputs = [
invoker.js_file,
invoker.html_file, invoker.html_file,
] ]
if (invoker.html_type == "dom-module" || invoker.html_type == "v3-ready") {
inputs += [ invoker.js_file ]
}
output_js_file = "$target_gen_dir/" + invoker.js_file
if (invoker.html_type == "dom-module") {
output_js_file =
"$target_gen_dir/" + get_path_info(invoker.js_file, "file") + ".m.js"
}
outputs = [ outputs = [
"$target_gen_dir/" + invoker.js_file, "$target_gen_dir/" + output_js_file,
] ]
args = [ args = [
...@@ -27,5 +42,10 @@ template("js_html_template") { ...@@ -27,5 +42,10 @@ template("js_html_template") {
"--out_folder", "--out_folder",
rebase_path(target_gen_dir, root_build_dir), rebase_path(target_gen_dir, root_build_dir),
] ]
args += [ "--namespace_rewrites" ] + common_namespace_rewrites
if (defined(invoker.namespace_rewrites)) {
args += invoker.namespace_rewrites
}
} }
} }
...@@ -2,20 +2,46 @@ ...@@ -2,20 +2,46 @@
# Use of this source code is governed by a BSD-style license that can be # Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file. # found in the LICENSE file.
# Helper script for inlining HTML content from an HTML to a JS file. This is # Generates Polymer3 UI elements (using JS modules) from existing Polymer2
# necessary for Polymer3 UI elements. The following |html_type| options are # elements (using HTML imports). This is useful for avoiding code duplication
# provided # while Polymer2 to Polymer3 migration is in progress.
# - dom-module: Extracts HTML content from a Polymer2 HTML file hosting a
# dom-module.
# - custom-style: Extracts HTML content from a Polymer HTML file hosting a
# custom-style.
# - v3-ready: Extracts HTML content from a file that is only used in Polymer3.
# #
# "dom-module" and "custom-style" are useful for avoiding duplicating HTML code # Variables:
# between Polymer2 and Polymer3 while migration is in progress. # html_file:
# The input Polymer2 HTML file to be processed.
# #
# Note: Having multiple <dom-module>s within a single HTML file is not currently # js_file:
# supported by this script. # The input Polymer2 JS file to be processed, or the name of the output JS
# file when no input JS file exists (see |html_type| below).
#
# in_folder:
# The folder where |html_file| and |js_file| (when it exists) reside.
#
# out_folder:
# The output folder for the generated Polymer JS file.
#
# html_type:
# Specifies the type of the |html_file| such that the script knows how to
# process the |html_file|. Available values are:
# dom-module: A file holding a <dom-module> for a UI element (this is
# the majority case). Note: having multiple <dom-module>s
# within a single HTML file is not currently supported
# style-module: A file holding a shared style <dom-module>
# (no corresponding Polymer2 JS file exists)
# custom-style: A file holding a <custom-style> (usually a *_vars_css.html
# file, no corresponding Polymer2 JS file exists)
# iron-iconset: A file holding one or more <iron-iconset-svg> instances
# (no corresponding Polymer2 JS file exists)
# v3-ready: A file holding HTML that is already written for Polymer3. A
# Polymer3 JS file already exists for such cases. In this mode
# HTML content is simply pasted within the JS file. This mode
# will be the only supported mode after migration finishes.
#
# namespace_rewrites:
# A list of string replacements for replacing global namespaced references
# with explicitly imported dependencies in the generated JS module.
# For example "cr.foo.Bar|Bar" will replace all occurrences of "cr.foo.Bar"
# with "Bar".
import argparse import argparse
import os import os
...@@ -24,10 +50,65 @@ import sys ...@@ -24,10 +50,65 @@ import sys
_CWD = os.getcwd() _CWD = os.getcwd()
HTML_TEMPLATE_REGEX = '{__html_template__}' # Rewrite rules for removing unnecessary namespaces for example "cr.ui.Foo", to
# "Foo" within a generated JS module. Populated from command line arguments.
_namespace_rewrites = {}
def _extract_dependencies(html_file):
with open(html_file, 'r') as f:
lines = f.readlines()
deps = []
for line in lines:
match = re.search(r'\s*<link rel="import" href="(.*)"', line)
if match:
deps.append(match.group(1))
return deps;
def _rewrite_dependency_path(dep):
if re.match(r'chrome://resources/polymer/v1_0/', dep):
dep = dep.replace(r'/v1_0/', '/v3_0/')
dep = dep.replace(r'.html', '.js')
if dep.endswith('/html/polymer.html'):
dep = "chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js"
if re.match(r'chrome://resources/html/', dep):
dep = dep.replace(r'/resources/html/', 'resources/js/')
dep = dep.replace(r'.html', '.m.js')
return dep
def _construct_js_import(dep):
if dep.endswith('polymer_bundled.min.js'):
return 'import {%s, %s} from \'%s\';' % ('Polymer', 'html', dep)
# TODO(dpapad): Figure out how to pass these from the command line, such that
# users of this script can pass their own default imports.
if dep.endswith('paper-ripple-behavior.js'):
return 'import {%s} from \'%s\';' % ('PaperRippleBehavior', dep)
if dep.endswith('focus_outline_manager.m.js'):
return 'import {%s} from \'%s\';' % ('FocusOutlineManager', dep)
else:
return 'import \'%s\';' % dep
def _generate_js_imports(html_file):
return map(_construct_js_import,
map(_rewrite_dependency_path, _extract_dependencies(html_file)))
def _extract_dom_module_id(html_file):
with open(html_file, 'r') as f:
contents = f.read()
match = re.search(r'\s*<dom-module id="(.*)"', contents)
assert match
return match.group(1)
def ExtractTemplate(html_file, html_type): def _extract_template(html_file, html_type):
if html_type == 'v3-ready': if html_type == 'v3-ready':
with open(html_file, 'r') as f: with open(html_file, 'r') as f:
return f.read() return f.read()
...@@ -49,7 +130,48 @@ def ExtractTemplate(html_file, html_type): ...@@ -49,7 +130,48 @@ def ExtractTemplate(html_file, html_type):
assert re.match(r'\s*</template>', lines[i - 2]) assert re.match(r'\s*</template>', lines[i - 2])
assert re.match(r'\s*<script ', lines[i - 1]) assert re.match(r'\s*<script ', lines[i - 1])
end_line = i - 3; end_line = i - 3;
return ''.join(lines[start_line:end_line + 1]) return '\n' + ''.join(lines[start_line:end_line + 1])
if html_type == 'style-module':
with open(html_file, 'r') as f:
lines = f.readlines()
start_line = -1
end_line = -1
for i, line in enumerate(lines):
if re.match(r'\s*<dom-module ', line):
assert start_line == -1
assert end_line == -1
assert re.match(r'\s*<template', lines[i + 1])
start_line = i + 1;
if re.match(r'\s*</dom-module>', line):
assert start_line != -1
assert end_line == -1
assert re.match(r'\s*</template>', lines[i - 1])
end_line = i - 1;
return '\n' + ''.join(lines[start_line:end_line + 1])
if html_type == 'iron-iconset':
templates = []
with open(html_file, 'r') as f:
lines = f.readlines()
start_line = -1
end_line = -1
for i, line in enumerate(lines):
if re.match(r'\s*<iron-iconset-svg ', line):
assert start_line == -1
assert end_line == -1
start_line = i;
if re.match(r'\s*</iron-iconset-svg>', line):
assert start_line != -1
assert end_line == -1
end_line = i
templates.append(''.join(lines[start_line:end_line + 1]))
# Reset indices.
start_line = -1
end_line = -1
return '\n' + ''.join(templates)
assert html_type == 'custom-style' assert html_type == 'custom-style'
with open(html_file, 'r') as f: with open(html_file, 'r') as f:
...@@ -66,26 +188,109 @@ def ExtractTemplate(html_file, html_type): ...@@ -66,26 +188,109 @@ def ExtractTemplate(html_file, html_type):
assert end_line == -1 assert end_line == -1
end_line = i; end_line = i;
return ''.join(lines[start_line:end_line + 1]) return '\n' + ''.join(lines[start_line:end_line + 1])
# Replace various global references with their non-namespaced version, for
# example "cr.ui.Foo" becomes "Foo".
def _rewrite_namespaces(string):
for rewrite in _namespace_rewrites:
string = string.replace(rewrite, _namespace_rewrites[rewrite])
return string
def ProcessFile(js_file, html_file, html_type, out_folder): def _process_v3_ready(js_file, html_file):
html_template = ExtractTemplate(html_file, html_type) # Extract HTML template and place in JS file.
html_template = _extract_template(html_file, 'v3-ready')
with open(js_file) as f: with open(js_file) as f:
lines = f.readlines() lines = f.readlines()
HTML_TEMPLATE_REGEX = '{__html_template__}'
for i, line in enumerate(lines): for i, line in enumerate(lines):
line = line.replace(HTML_TEMPLATE_REGEX, html_template) line = line.replace(HTML_TEMPLATE_REGEX, html_template)
lines[i] = line lines[i] = line
# Reconstruct file.
out_filename = os.path.basename(js_file) out_filename = os.path.basename(js_file)
return lines, out_filename
def _process_dom_module(js_file, html_file):
html_template = _extract_template(html_file, 'dom-module')
js_imports = _generate_js_imports(html_file)
with open(js_file) as f:
lines = f.readlines()
for i, line in enumerate(lines):
# Place the JS imports right before the opening "Polymer({" line.
# Note: Currently assuming there is only one Polymer declaration per page,
# and no other code precedes it.
# Place the HTML content right after the opening "Polymer({" line.
line = line.replace(
r'Polymer({',
'%s\nPolymer({\n _template: html`%s`,' % (
'\n'.join(js_imports) + '\n', html_template))
line = _rewrite_namespaces(line)
lines[i] = line
# Use .m.js extension for the generated JS file, since both files need to be
# served by a chrome:// URL side-by-side.
out_filename = os.path.basename(js_file).replace('.js', '.m.js')
return lines, out_filename
def _process_style_module(js_file, html_file):
html_template = _extract_template(html_file, 'style-module')
js_imports = _generate_js_imports(html_file)
style_id = _extract_dom_module_id(html_file)
js_template = \
"""%(js_imports)s
const styleElement = document.createElement('dom-module');
styleElement.innerHTML = `%(html_template)s`;
styleElement.register('%(style_id)s');""" % {
'js_imports': '\n'.join(js_imports),
'html_template': html_template,
'style_id': style_id,
}
out_filename = os.path.basename(js_file)
return js_template, out_filename
def _process_custom_style(js_file, html_file):
html_template = _extract_template(html_file, 'custom-style')
js_imports = _generate_js_imports(html_file)
js_template = \
"""%(js_imports)s
const $_documentContainer = document.createElement('template');
$_documentContainer.innerHTML = `%(html_template)s`;
document.head.appendChild($_documentContainer.content);""" % {
'js_imports': '\n'.join(js_imports),
'html_template': html_template,
}
out_filename = os.path.basename(js_file)
return js_template, out_filename
def _process_iron_iconset(js_file, html_file):
html_template = _extract_template(html_file, 'iron-iconset')
js_imports = _generate_js_imports(html_file)
js_template = \
"""%(js_imports)s
const template = html`%(html_template)s`;
document.head.appendChild(template.content);
""" % {
'js_imports': '\n'.join(js_imports),
'html_template': html_template,
}
out_filename = os.path.basename(js_file)
return js_template, out_filename
with open(os.path.join(out_folder, out_filename), 'w') as f:
for l in lines:
f.write(l)
return
def main(argv): def main(argv):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
...@@ -93,18 +298,42 @@ def main(argv): ...@@ -93,18 +298,42 @@ def main(argv):
parser.add_argument('--out_folder', required=True) parser.add_argument('--out_folder', required=True)
parser.add_argument('--js_file', required=True) parser.add_argument('--js_file', required=True)
parser.add_argument('--html_file', required=True) parser.add_argument('--html_file', required=True)
parser.add_argument('--namespace_rewrites', required=False, nargs="*")
parser.add_argument( parser.add_argument(
'--html_type', choices=['dom-module', 'custom-style', 'v3-ready'], '--html_type', choices=['dom-module', 'style-module', 'custom-style',
'iron-iconset', 'v3-ready'],
required=True) required=True)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Extract namespace rewrites from arguments.
if args.namespace_rewrites:
for r in args.namespace_rewrites:
before, after = r.split('|')
_namespace_rewrites[before] = after
in_folder = os.path.normpath(os.path.join(_CWD, args.in_folder)) in_folder = os.path.normpath(os.path.join(_CWD, args.in_folder))
out_folder = os.path.normpath(os.path.join(_CWD, args.out_folder)) out_folder = os.path.normpath(os.path.join(_CWD, args.out_folder))
ProcessFile( js_file = os.path.join(in_folder, args.js_file)
os.path.join(in_folder, args.js_file), html_file = os.path.join(in_folder, args.html_file)
os.path.join(in_folder, args.html_file),
args.html_type, out_folder) result = ()
if args.html_type == 'dom-module':
result = _process_dom_module(js_file, html_file)
if args.html_type == 'style-module':
result = _process_style_module(js_file, html_file)
elif args.html_type == 'custom-style':
result = _process_custom_style(js_file, html_file)
elif args.html_type == 'iron-iconset':
result = _process_iron_iconset(js_file, html_file)
elif args.html_type == 'v3-ready':
result = _process_v3_ready(js_file, html_file)
# Reconstruct file.
with open(os.path.join(out_folder, result[1]), 'w') as f:
for l in result[0]:
f.write(l)
return
if __name__ == '__main__': if __name__ == '__main__':
......
...@@ -13,156 +13,65 @@ import unittest ...@@ -13,156 +13,65 @@ import unittest
_HERE_DIR = os.path.dirname(__file__) _HERE_DIR = os.path.dirname(__file__)
class HtmlToJsTest(unittest.TestCase): class PolymerModulizerTest(unittest.TestCase):
def setUp(self): def setUp(self):
self._out_folder = None self._out_folder = None
self._tmp_dirs = []
self._tmp_src_dir = None
def tearDown(self): def tearDown(self):
for tmp_dir in self._tmp_dirs: shutil.rmtree(self._out_folder)
shutil.rmtree(tmp_dir)
def _write_file_to_src_dir(self, file_path, file_contents):
if not self._tmp_src_dir:
self._tmp_src_dir = self._create_tmp_dir()
file_path_normalized = os.path.normpath(os.path.join(self._tmp_src_dir,
file_path))
file_dir = os.path.dirname(file_path_normalized)
if not os.path.exists(file_dir):
os.makedirs(file_dir)
with open(file_path_normalized, 'w') as tmp_file:
tmp_file.write(file_contents)
def _create_tmp_dir(self):
tmp_dir = tempfile.mkdtemp(dir=_HERE_DIR)
self._tmp_dirs.append(tmp_dir)
return tmp_dir
def _read_out_file(self, file_name): def _read_out_file(self, file_name):
assert self._out_folder assert self._out_folder
return open(os.path.join(self._out_folder, file_name), 'r').read() return open(os.path.join(self._out_folder, file_name), 'r').read()
def _run_html_to_js(self, js_file, html_file, html_type): def _run_test(self, html_type, html_file, js_file,
js_out_file, js_file_expected):
assert not self._out_folder assert not self._out_folder
self._out_folder = self._create_tmp_dir() self._out_folder = tempfile.mkdtemp(dir=_HERE_DIR)
polymer.main([ polymer.main([
'--in_folder', self._tmp_src_dir, '--in_folder', 'tests',
'--out_folder', self._out_folder, '--out_folder', self._out_folder,
'--js_file', js_file, '--js_file', js_file,
'--html_file', html_file, '--html_file', html_file,
'--html_type', html_type, '--html_type', html_type,
'--namespace_rewrites', 'Polymer.PaperRippleBehavior|PaperRippleBehavior',
]) ])
def _run_test_(self, html_type, src_html, src_js, expected_js): actual_js = self._read_out_file(js_out_file)
self._write_file_to_src_dir('foo.html', src_html) expected_js = open(os.path.join('tests', js_file_expected), 'r').read()
self._write_file_to_src_dir('foo.js', src_js)
self._run_html_to_js('foo.js', 'foo.html', html_type)
actual_js = self._read_out_file('foo.js')
self.assertEquals(expected_js, actual_js) self.assertEquals(expected_js, actual_js)
# Test case where HTML is extracted from a Polymer2 <dom-module>. # Test case where HTML is extracted from a Polymer2 <dom-module>.
def testSuccess_DomModule(self): def testSuccess_DomModule(self):
self._run_test_('dom-module', ''' self._run_test(
<link rel="import" href="../../foo/bar.html"> 'dom-module', 'dom_module.html', 'dom_module.js',
<link rel="import" href="chrome://resources/foo/bar.html"> 'dom_module.m.js', 'dom_module_expected.js')
<dom-module id="cr-checkbox"> # Test case where HTML is extracted from a Polymer2 style module.
<template> def testSuccess_StyleModule(self):
<style> self._run_test(
div { 'style-module', 'style_module.html', 'style_module.m.js',
font-size: 2rem; 'style_module.m.js', 'style_module_expected.js')
} return
</style>
<div>Hello world</div>
</template>
<script src="foo.js"></script>
</dom-module>
''', '''
Polymer({
is: 'cr-foo',
_template: html`
{__html_template__}
`,
});
''', '''
Polymer({
is: 'cr-foo',
_template: html`
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
`,
});
''')
# Test case where HTML is extracted from a Polymer2 <custom-style>. # Test case where HTML is extracted from a Polymer2 <custom-style>.
def testSuccess_CustomStyle(self): def testSuccess_CustomStyle(self):
self._run_test_('custom-style', ''' self._run_test(
<link rel="import" href="../../foo/bar.html"> 'custom-style', 'custom_style.html', 'custom_style.m.js',
<link rel="import" href="chrome://resources/foo/bar.html"> 'custom_style.m.js', 'custom_style_expected.js')
<custom-style> # Test case where HTML is extracted from a Polymer2 iron-iconset-svg file.
<style> def testSuccess_IronIconset(self):
html { self._run_test(
--foo-bar: 2rem; 'iron-iconset', 'iron_iconset.html', 'iron_iconset.m.js',
} 'iron_iconset.m.js', 'iron_iconset_expected.js')
</style>
</custom-style>
''', '''
$_documentContainer.innerHTML = `
{__html_template__}
`;
''', '''
$_documentContainer.innerHTML = `
<custom-style>
<style>
html {
--foo-bar: 2rem;
}
</style>
</custom-style>
`;
''')
# Test case where the provided HTML is already in the form needed by Polymer3. # Test case where the provided HTML is already in the form needed by Polymer3.
def testSuccess_V3Ready(self): def testSuccess_V3Ready(self):
self._run_test_('v3-ready', '''<style> self._run_test(
div { 'v3-ready', 'v3_ready.html', 'v3_ready.js',
font-size: 2rem; 'v3_ready.js', 'v3_ready_expected.js')
}
</style>
<div>Hello world</div>
''', '''
Polymer({
is: 'cr-foo',
_template: html`
{__html_template__}
`,
});
''', '''
Polymer({
is: 'cr-foo',
_template: html`
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
`,
});
''')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
<link rel="import" href="../../html/polymer.html">
<custom-style>
<style>
html {
--foo-bar: 2rem;
}
</style>
</custom-style>
import {Polymer, html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
const $_documentContainer = document.createElement('template');
$_documentContainer.innerHTML = `
<custom-style>
<style>
html {
--foo-bar: 2rem;
}
</style>
</custom-style>
`;
document.head.appendChild($_documentContainer.content);
\ No newline at end of file
<link rel="import" href="../../html/polymer.html">
<link rel="import" href="chrome://resources/polymer/v1_0/paper-behaviors/paper-ripple-behavior.html">
<link rel="import" href="../shared_vars_css.html">
<dom-module id="cr-test-foo">
<template>
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
</template>
<script src="dom_module.js"></script>
</dom-module>
Polymer({
is: 'cr-test-foo',
behaviors: [Polymer.PaperRippleBehavior],
});
import {Polymer, html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PaperRippleBehavior} from 'chrome://resources/polymer/v3_0/paper-behaviors/paper-ripple-behavior.js';
import '../shared_vars_css.m.js';
Polymer({
_template: html`
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
`,
is: 'cr-test-foo',
behaviors: [PaperRippleBehavior],
});
<link rel="import" href="../html/polymer.html">
<link rel="import" href="chrome://resources/polymer/v1_0/iron-iconset-svg/iron-iconset-svg.html">
<iron-iconset-svg name="cr_foo_20" size="20">
<svg>
<defs>
<g id="add"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></g>
</defs>
</svg>
</iron-iconset-svg>
<iron-iconset-svg name="cr_foo_24" size="24">
<svg>
<defs>
<g id="add"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></g>
</defs>
</svg>
</iron-iconset-svg>
import {Polymer, html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import 'chrome://resources/polymer/v3_0/iron-iconset-svg/iron-iconset-svg.js';
const template = html`
<iron-iconset-svg name="cr_foo_20" size="20">
<svg>
<defs>
<g id="add"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></g>
</defs>
</svg>
</iron-iconset-svg>
<iron-iconset-svg name="cr_foo_24" size="24">
<svg>
<defs>
<g id="add"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></g>
</defs>
</svg>
</iron-iconset-svg>
`;
document.head.appendChild(template.content);
<link rel="import" href="../html/polymer.html">
<link rel="import" href="some_other_style.html">
<dom-module id="cr-foo-style">
<template>
<style include="some-other-style">
:host {
margin: 0;
}
</style>
</template>
</dom-module>
import {Polymer, html} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import 'some_other_style.m.js';
const styleElement = document.createElement('dom-module');
styleElement.innerHTML = `
<template>
<style include="some-other-style">
:host {
margin: 0;
}
</style>
</template>
`;
styleElement.register('cr-foo-style');
\ No newline at end of file
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
Polymer({
is: 'cr-foo',
_template: html`
{__html_template__}
`,
});
Polymer({
is: 'cr-foo',
_template: html`
<style>
div {
font-size: 2rem;
}
</style>
<div>Hello world</div>
`,
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment