you can't use JavaScript const
or let
variables
Block Scope Solution (simple)
If you define your const
and let
declarations within curly braces {}
you can avoid the Identifier * has already been declared
error.
For example, leave everything how you have it, except within the embedded document, use curly braces within the <script>
:
<!doctype html>
<html lang="en">
<head>
<title>Sample Code</title>
</head>
<body>
<p>Hello!</p>
<script>
{
const p = document.querySelector('p');
p.style.color = 'blue';
}
</script>
</body>
</html>
You can add as many block scopes as you like:
<!doctype html>
<html lang="en">
<head>
<title>Sample Code</title>
</head>
<body>
<p>Hello!</p>
<a href="#link">page anchor</a>
<h2 id="link">Link here</h2>
<script>
{
const p = document.querySelector('p');
p.style.color = 'blue';
}
{
let h2 = document.querySelector('h2');
h2.style.marginTop = '400px';
}
</script>
</body>
</html>
Dynamic iframe
Solution (preferred)
Recreating the iframe
during the input
event also prevents the Identifier * has already been declared
error.
Change example HTML to this:
<textarea></textarea>
<div id="iframe"></div>
Change your CSS to this:
textarea,
iframe {
width: 400px;
height: 300px;
}
#iframe {
display: inline-block;
}
And change your script to this:
const framebox = document.getElementById('iframe');
const textarea = document.querySelector('textarea');
function preview() {
const iframe = document.createElement('iframe');
framebox.querySelector('iframe')?.remove();
framebox.appendChild(iframe);
const iframeDoc = iframe.contentDocument;
iframeDoc.open();
iframeDoc.write(textarea.value);
iframeDoc.close();
}
textarea.addEventListener('input', preview);
Here's an online example (view the page source). This would also work better by using DOMParser
to handle the textarea.value
as well and that is shown in the example below.
Parsing/RegEx Solution (complex)
You can try a regex or parsing. Of course parsing is going to be more robust.
- Use
DomParser
to parse the embedded document as text/html
.
- From the parsed document generate an AST from the found
script
elements text content.
- From the AST find all
VariableDeclaration
nodes and convert them to var
declarations using magic-string
for convenience.
- Update the text content for the
script
node with the const
/let
replaced with var
.
- Update the
iframe
's content with the updated DomParser
object content.
By no means is this fully optimized, but it gives an idea of how you could proceed along this path. For instance, you should store a reference to the identifier values used from the AST so that you can guard/warn against overwriting previous identifiers since var
has global scope in this case.
You can use something like serve to load this in your browser, or simply the file:///
protocol.
Demo available at https://morganney.github.io/simple-html-editor/.
<!doctype html>
<html lang="en">
<head>
<title>Simple HTML Editor</title>
<style>
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
height: 400px;
}
textarea,
iframe {
border: 1px solid black;
}
iframe {
width: 100%;
height: 100%;
}
</style>
<script type="importmap">
{
"imports": {
"acorn": "https://esm.sh/[email protected]",
"acorn-walk": "https://esm.sh/[email protected]",
"magic-string": "https://esm.sh/[email protected]"
}
}
</script>
<script type="module">
import { parse } from 'acorn'
import { simple } from 'acorn-walk'
import MagicString from 'magic-string'
const textarea = document.querySelector('textarea')
const constLetRegex = /\bconst\s|\blet\s/g
const parser = new DOMParser()
function previewRegex() {
const iframe = document.querySelector('iframe')
const iframeDoc = iframe.contentDocument
const doc = parser.parseFromString(textarea.value, 'text/html')
const scripts = doc.querySelectorAll('script')
if (scripts.length) {
scripts.forEach((script) => {
script.textContent = script.textContent.replace(constLetRegex, 'var ')
})
}
iframeDoc.open()
iframeDoc.write(doc.documentElement.innerHTML)
iframeDoc.close()
}
function previewParse() {
const iframe = document.querySelector('iframe')
const iframeDoc = iframe.contentDocument
const doc = parser.parseFromString(textarea.value, 'text/html')
const scripts = doc.querySelectorAll('script')
if (scripts.length) {
scripts.forEach((script) => {
const magic = new MagicString(script?.textContent || '')
let ast = null
try {
ast = parse(script.textContent, {
ecmaVersion: 2023,
sourceType: 'module'
})
} catch {
/* do nothing */
}
if (ast) {
simple(ast, {
VariableDeclaration(node) {
magic.overwrite(node.start, node.start + node.kind.length, 'var')
}
})
script.textContent = magic.toString()
}
})
}
iframeDoc.open()
iframeDoc.write(doc.documentElement.innerHTML)
iframeDoc.close()
}
textarea.addEventListener('input', previewRegex)
// Or use a RegEx
// textarea.addEventListener('input', previewRegex)
</script>
</head>
<body>
<main>
<textarea></textarea>
<iframe></iframe>
</main>
</body>
</html>
Can I use the try...catch
statement to define a function only for this specific error?
Yes and no.
If you do something like this:
try {
iframeDoc.write();
} catch (err) {}
The error will be fired against the iframe's window and the parent window will not catch that. You'd have to listen to the error
event on a reference to the iframe's window:
iframeDoc.defaultView.addEventListener('error', (err) => {
if (/has already been declared/.test(err.message) {
const scopeErrorOccured = true;
}
});
However, the error will only be thrown after the call to iframeDoc.write(textarea.value)
so you're back to the same condition of the iframe not updating the content because of the uncaught SyntaxError
. Kind of a catch-22.
<iframe>
each time? – Bannock