3

So, I'm starting with a variable string of HTML that is a UI template created by my users in a RichText editor (saved to an XML file on disk). It will always be valid XHTML. The XML could be as simple as this:

<div>{{FORM_PLACEHOLDER}}</div>

Or as complex as something like this:

<div id="user-customized-content">
    <h1 class="display-1">Customized Header</h1>
    <p class="mb-3">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt 
        ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 
        <strong>laboris nisi ut aliquip</strong> ex ea commodo consequat. Duis aute irure dolor in 
        reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint 
        occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
    </p>
    <div class="alert alert-info">
        <p>Laboris nisi ut <em>aliquip</em> ex ea commodo consequat</p>
    </div>
    <h3 class="display-4">Lorem ipsum dolor:</h3>
    <form>{{FORM_PLACEHOLDER}}</form>
    <p class="mb-3">
        Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim 
        id est laborum.
    </p>
</div>

But it will ALWAYS have the {{FORM_PLACEHOLDER}} somewhere in the string of XML. This will specify where exactly the HTML form should be placed inside the HTML Wrapper code.

On my SPA application (we use Vue.js but I don't think it matters what the framework/library is that is used), I retreive the HTML via an axios call and then I need to write the XHTML to the page AROUND my interactive form (example shown below).

With Vue.js, we use "slots" for this. So, the parent component would have the form in it and its child component (HtmlWrapper in the example below) would have a slot that would wrap around the form.

<template>
    <HtmlWrapper>
        Name: <input type="text" name="Name" v-validate="'required'" /><br />
        Email: <input type="email" name="Email" /><br />
        <button @click="dofunction()">Submit Form</button>
    </HtmlWrapper>
</template>

<script>
    import HtmlWrapper from "@/components/HtmlWrapper.vue"
</script>

Methods that I've already tried that didn't work:

  • Splitting the HTML string on the placeholder and injecting the HTML directly above and below the form fields. This didn't work because the tags at the top will automatically close if using Vue or JS to add them to the DOM.
  • Converting the XML string to XmlDocument in C# and then serializing it to JSON to pass down to the javascript. This worked great and enabled me to iterate through the json to build the HTML around the <slot></slot> tag BUT then I realized that multiple HTML tags of the same type would get grouped into arrays which corrupts the order they appear on the page.

What I think that needs to happen is I need to iterate through the string of XHTML, tag by tag recursively, and create each javascript element on the page as I go, and then when I hit the placeholder, I create the slot at that point (which is easy to do in Vue.js: this.$slots.default). I would rather not reinvent the wheel if I don't have to (making all the initial mistakes along the way) so if there's a method already out there and available or some kind of component that does this, that would be great. Otherwise, pointing me in the right direction would be invaluable. TIA.

RichC
  • 7,829
  • 21
  • 85
  • 149
  • I suspect that Vue will play a bigger role than you are expecting. But you could potentially use the DOMParser API to load the html blob. A crude example in pure javascript could be something like [this](https://codepen.io/surfjast/pen/yLodpXq) – David Nov 24 '21 at 17:08
  • @David I think you could be correct in that Vue might need to play a bigger role but I'm hoping this can be utilized. I'll need to render to the page using this method: https://v3.vuejs.org/guide/render-function.html#the-virtual-dom-tree Something like this: `render() { const slot = this.$slots.default ? this.$slots.default() : []; const doc = new DOMParser().parseFromString(this.htmlData.replace('{{FORM_PLACEHOLDER}}', slot),'text/html'); return h('content', {}, doc); },` Obviously this doesn't work because of doc and slot types. – RichC Nov 24 '21 at 22:29
  • 1
    My Vue knowledge is pretty limited, but maybe you could use an async component to async load the users template, inject a slot tag and then render it. I made a quick [example here](https://codepen.io/surfjast/pen/GRvbaEx) – David Nov 25 '21 at 09:59
  • @David can this be done using a component that isn't registed globally in main.js? I can't get it to work. – RichC Nov 26 '21 at 19:31
  • 1
    Sounds like you just want to create a component dynamically. Output the HTML so that it renders. Then invoke another event so that you can create the element and replace the placeholder one. https://css-tricks.com/creating-vue-js-component-instances-programmatically/ – John Nov 27 '21 at 04:54
  • Thanks @John I'll give this a shot as well. – RichC Nov 28 '21 at 16:10

4 Answers4

1

I'm not sure if you can feed the Vue component dynamic template. However, I think what you want can be done by Vue3's teleport. Although it feels a little tricky, it works.

My idea is that render the XHTML by v-html and then teleport you content into a specific element, for example, #target.

If it is possible, I suggest to replace the {{FORM_PLACEHOLDER}} by something like <div id="target"></div> to make use of the teleport.

<template>
  <div id="example-code">
    <!-- use v-html to render the template -->
    <div v-html="template" />
    <!-- use teleport to push content to #target which will be mounted by v-html above -->
    <teleport 
      v-if="initialized" 
      to="#target"
    >
      Name: <input type="text" name="Name" v-validate="'required'" /><br />
      Email: <input type="email" name="Email" /><br />
      <button @click="dofunction()">Submit Form</button>
    </teleport>
  </div>
</template>

<script>
import { nextTick, onMounted, ref } from 'vue'

export default {
  setup() {
    const initialized = ref(false)
    // for easier explaining, I create a #target div directly in the string
    // Otherwise, it should be an empty string: ref('') here
    const template = ref('<div id="target"></div>')

    onMounted(async () => {
      // The 2 lines below are just my assumtion the way you get the XHTML string
      // const {data} = await axios.get('/template.html')
      // template.value = data

      // use the nextTick to make sure the template is rendered
      nextTick(() => {
        initialized.value = true
      })
    })
    return {
      initialized,
      template,
    }
  }
}
</script>

Even if the {{FORM_PLACEHOLDER}} must appear in the template string, we can use replace to replace that by <div id="target"></div>:

str = "<form>{{FORM_PLACEHOLDER}}</form>"
re = /\{\{FORM_PLACEHOLDER\}\}/
str.replace(re, '<div id="target"></div>"')
// '<form><div id="target"></div>"</form>'

Because this XHTML string is stored as ref, the v-html content will be updated accordingly. Then the form content can be teleported as expected.

Hope it can give you some more new ideas~

kevinluo201
  • 1,444
  • 14
  • 18
  • Any idea if `teleport` is related to @linusborg and his `VuePortals` library? If so, my hunch that `VuePortals` as a vue2 approach might help out if vue3 is not an option. – T.Woody Nov 26 '21 at 02:25
  • @kevinluo201 I've been trying to get this to work but since my content is coming in async and after mounted is already called, I can't get the teleport to wire up properly. `Failed to locate Teleport target with selector "#inner-content". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree. ` – RichC Nov 26 '21 at 19:30
  • 1
    That's why I put a `v-if="initialized` in the , and it doesn't need to be called exactly after onMounted. You can call `nextTick(() => initialized.value = true)` after you get the async string. > the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree. Yes, I agree with you that it's a the perfect way to use teleport, it's just happen to do what I expected. : ) – kevinluo201 Nov 27 '21 at 02:14
  • @kevinluo201 yeah, I was able to get a simplistic example to work but when I applied it with all the axios promises and child components involved, it gave me that error. I'll give it another go and pay more attention to the details this time - I'll let you know. – RichC Nov 28 '21 at 16:08
  • 1
    @kevinluo201 I was able to get it all wired up successfully after using a watcher to monitor the html wrapper variable. Once it gets populated, I nextTick a boolean variable and teleport it at that point. Thanks!! – RichC Nov 30 '21 at 22:00
  • 1
    @RichC Thanks for the delicious 500 points, you made my day. There is one thing I'd better inform you is that: it's still a very tricky way to do teleporting because it doesn't follow the original design, which is "teleporting templates to the target outside of the component". You have to pay attention in the future if teleport has any updates related to this.(Although I think the possibility to change this behavior is pretty small) – kevinluo201 Dec 01 '21 at 02:05
0

I did some more research on component basics and I think it's possible to make components with dynamic templates. It's totally different from my previous answer so I add this one.

The idea is:

  1. get the XHTML string from axios as you said.
  2. replace the {{FORM_PLACEHOLDER}} by <slot />
  3. register globally a component named HtmlWrapper with the XHTML string
  4. import and use the HtmlWrapper and put the content you want into the slot

Here are the example codes:

// main.js

// import the module from vue.esm-bundler which supports runtime compilation
import { createApp } from 'vue/dist/vue.esm-bundler'
import axios from 'axios'
import App from './App.vue'

// Create a Vue application
document.addEventListener('DOMContentLoaded', async () => {
  const app = createApp(App)
  const {data} = await axios.get("./tempalte.xhtml")
  const xhtml = data.replace(/\{\{FORM_PLACEHOLDER\}\}/, '<slot />')
  // register the template
  app.component('html-wrapper', {
    template: xhtml
  })
  app.mount('#app')
})
// App.vue

<template>
  <div id="app">
    <HtmlWrapper>
      Name: <input type="text" name="Name" v-model="name" /><br />
      Email: <input type="email" name="Email" v-model="email" /><br />
      <button @click="dofunction">Submit Form</button>
    </HtmlWrapper>
  </div>
</template>

<script>
import { ref } from 'vue'

export default {
  setup() {
    const name = ref('')
    const email = ref('')
    const dofunction = () => {
      console.log('dofunction')
    }
    return {
      name,
      email,
      dofunction
    }
  }
}
</script>
kevinluo201
  • 1,444
  • 14
  • 18
  • unfortunately, my existing system has multiple placeholder names depending on the page so I can't just hardcode the placeholder in main.js. It would have to be a string that I pass into my wrapper component or something along those lines. – RichC Nov 26 '21 at 19:51
0

Can you simply use .innerHTML?

You have this:

<div id="formContainer"></div>

And then assign the text that you just got from axios to this element's .innerHTML:

receivedtextfromaxios = "<div><span>foo</span>foo{{FORM_PLACEHOLDER}}bar<span>bar</span></div>";

document.getElementById("formContainer").innerHTML = receivedtextfromaxios;

// then you use Vue the way you are used to:
Vue.createApp(...).mount('#formContainer);
continuning the answer based on comment made on 2021-11-28:

I believe my interpretation of the problem was misleading. The placeholder {{ }} in the question seems to have no relationship with Vue. It is RichC's templating, not Vue's.

Continuing on .innerHTML discussion, below an example of a HTML showing a live Vue object being enveloped by new HTML by using .innerHTML. It is a simple implementation which uses a counter object taken from Vue intro docs. RichC's marks were changed to << >> to facilitate reader's differentiation from Vue's {{ }}. Those template marks between << >> are simply replaced with the nodes that existed before, and their names are ignored.

There are the easy and the hard method to handle this kind of work. The simple would be simply replacing the content between << and >> with the HTML code that is expected to go there before using .innerHTML to apply the whole envelope+component to the DOM. The hard method would be firstly applying the envelope HTML to the DOM, and then manually looking the DOM tree for the template marks << >> and then creating a node there.

In the example we opt for the hard method because it allows us to keep elements already bound to Vue in the same state they are in, otherwise we would take the easier path.

To run, simply save the above HTML as a local file and browse to it:

<html>
    <head>
        <script src="https://unpkg.com/vue@next"></script>
    </head>
    <body>
        <div id="container">
            text <span>span</span> counter {{ counter }} <span>span</span> text
        </div>
        <script>
            // example from https://v3.vuejs.org/guide/introduction.html
            const Counter = {
              data() { return { counter: 0 } },
              mounted() { setInterval(() => { this.counter++; }, 1000) }
            }
            Vue.createApp(Counter).mount('#container');

            function nodesWithPlaceholdersInTheTree(node, detectednodeslist) { // recursive function, get all nodes with a placeholder and store them in detectednodeslist
                if(node.nodeType==1) // 1 => element
                    for (let childnode of node.childNodes)
                        nodesWithPlaceholdersInTheTree(childnode, detectednodeslist);
                if(node.nodeType==3) { // 3 => textnode
                    const i = node.nodeValue.indexOf(">>");
                    if (i > 0) {
                        const j = node.nodeValue.indexOf(">>", i);
                        if (j > 0)
                            detectednodeslist.push(node);
                    }
                }
            }
            function stringplaceholders(inputstring) {
                // "foo<<FORM_PLACEHODER_1>>bar<<FORM_PACEHOLDER_2>>baz" => [ "FORM_PLACEHODER_1", "FORM_PACEHOLDER_2" ]
                return inputstring.split("<<").map(s=>s.slice(0,s.indexOf(">>"))).slice(1);
            }
            function replaceholder(node, nodestoinsert) { // only first placeholder is replaced
                const i = node.nodeValue.indexOf("<<");
                if (i < 0)
                    return;
                const j = node.nodeValue.indexOf(">>", i);
                if (j < 0)
                    return;
                const nodeidx = Array.from(node.parentElement.childNodes).indexOf(node);
                const textnodeafter = document.createTextNode(node.nodeValue.slice(j+2));
                node.nodeValue = node.nodeValue.slice(0,i);
                let k = 1;
                if (nodeidx < node.parentElement.childNodes.length) {
                    while (nodestoinsert.length > 0) {
                        node.parentElement.insertBefore(nodestoinsert[0],node.parentElement.childNodes[nodeidx+k]);
                        k++;
                    }
                    node.parentElement.insertBefore(textnodeafter,node.parentElement.childNodes[nodeidx+k]);
                } else {
                    while (nodestoinsert.length > 0)
                        node.parentElement.appendChild(nodestoinsert[0]);
                    node.parentElement.appendChild(textnodeafter);
                }
            }
            function inserthtmlaround(originalelement, newsurroudinghtml) {
                // moving old elments to a temporary place
                const tempcontainer = document.createElement("template");
                originalelement.visible = "hidden"; // lazy way to avoid repaiting while removing/appending
                while (originalelement.childNodes.length > 0)
                    tempcontainer.appendChild(originalelement.childNodes[0]);

                // changing to new html
                originalelement.innerHTML = newsurroudinghtml;

                // detecting << placeholders >>
                const nodeswithplaceholders = []
                nodesWithPlaceholdersInTheTree(originalelement, nodeswithplaceholders);

                // breaking textnode placeholders in two and inserting previous nodes between them
                for (node of nodeswithplaceholders)
                    replaceholder(node, tempcontainer.childNodes);
                originalelement.visible = "";
            }

            setTimeout(function(){
                inserthtmlaround(container, "TEXT <span>SPAN</span> TEXT << originalcounter >> TEXT <span>SPAN</span> TEXT");
            }, 4000);

        </script>
    </body>
</html>

Explanation: After 4 seconds, the function inserthtmlaround runs. It takes a copy of previous child nodes of the container object. Then it updates .innerHTML with the envelope HTML, and then it applies RichC's modified template by looking for all << >>, and for each node with the template, it breaks the text node in two and inserts to previously copied child nodes in this new inner position (maybe it should replace only the first instance not all, but it is just a POC).

To the real job it would be necessary to make a few adaptations, but the core parts are those shown on the file, not forgeting that it is possible to use the easier method of replacing the RichC's template before applying the envelope to the DOM and avoid the hassle of manipulating DOM nodes if it is not necessary to reutilize the previous object.

brunoff
  • 4,161
  • 9
  • 10
  • unfortunately, my existing system has multiple placeholder names depending on the page so I can't just hardcode the placeholder in main.js. It would have to be a string that I pass into my wrapper component or something along those lines. – RichC Nov 28 '21 at 16:11
  • 1
    I have updated the answer with a live working example of what I call using `.innerHTML` to update a live Vue object. Just run the HTML file in your browser and you will see. I don't know if it is what you need since I can still be wrong about what you really want. – brunoff Nov 29 '21 at 04:51
  • Ah sorry - I didn't mean multiple placeholders for one site-wide wrapper, I meant each page on the site has its own custom content wrapper and each of the those pages can have one placeholder for the "guts" of that particular page. – RichC Nov 30 '21 at 15:57
0

for this I could recommend something that works quite well currently

Create In vanilla, a custom component that extends from HTMLElement (for example). And implement your logic so that you paint where you want, for example in your constructor (with a custom tag).

Simple example

    //create element
class SuperClassForm extends HTMLElement {
    constructor() {
        super();
       
    } 
    //coonect with dom
    connectedCallback() {
       
        
        this.innerHTML = this.renderTemplate("<form>...........</form>")
               
    }
       

    renderTemplate(data) {
           let htmlRender = this.innerHTML.replace("{{FORM_PLACEHOLDER}}", data)
    return htmlRender;
       
    }

}

//Init in js
 customElements.define("superclassform", SuperClassForm);
 
//define to html
<superclassform>{{FORM_PLACEHOLDER}}</superclassform>
Delari Jesus
  • 411
  • 6
  • 22