atomicojs / atomico

Atomico a micro-library for creating webcomponents using only functions, hooks and virtual-dom.
https://atomicojs.dev
MIT License
1.16k stars 43 forks source link

(Suggestion) Use XHTM instead of HTM for HTML parsing #63

Closed lokimckay closed 2 years ago

lokimckay commented 2 years ago

Example repo

https://github.com/lokimckay-references/atomico-html2jsx

Screenshot 2021-10-26 011725

Motivation

As an atomico user, I want to grab regular HTML elements from the "light DOM" and render them in my web component (including HTML5 void elements)

According to the docs, the way to do that is like this:

import { c, html } from "https://unpkg.com/atomico";

function component({ name }) {
  return html`<host shadowDom>Hello, ${name}</host>`;
}
...

..but atomico uses "htm" (htm) which is not intended to be an HTML parser, and does not support HTML5 void elements

As a result of this, the following snippet will not work in atomico:

import { c, html } from "https://unpkg.com/atomico";

function component({ name }) {
  return html`<host shadowDom>
      <img src="https://placekitten.com/64">
    </host>`;
}
...

Workaround

Use the xhtm module instead

import { h } from "atomico";
import xhtm from "xhtm";
const xhtml = xhtm.bind(h);

function MyComponent() {
  return (
    <host shadowDom>
      {xhtml`<img src="https://placekitten.com/64">`}
    </host>
  );
}
...
UpperCod commented 2 years ago

Thanks for your observation, I had never analyzed the limitations of HTM, I will review this to know the cost vs. benefit.

Regarding the use of the html of the lightDOM within the shadowDOM, the ideal is to always use a slot, but depending on the case I attach some techniques that today would work maintaining the expected effect:

Clone the nodes

This technique allows you to manipulate the cloned node, be it associating events, properties or even associating new children. example https://webcomponents.dev/edit/PUYhxjl2JSsyFQ5dvrL4/src/index.jsx

import { c, html, useRef } from "atomico";
import { useSlot } from "@atomico/hooks/use-slot";

function myExample() {
  const ref = useRef();
  const childNodes = useSlot(ref);

  return html`<host shadowDom>
    <h1>Inside</h1>
    <slot ref=${ref} style="display:none"></slot>
    <ul>
      ${childNodes
        .filter((el) => el instanceof Element)
        .map((child) => html`<li><${child.cloneNode(true)} /></li>`)}
    </ul>
  </host>`;
}

export const MyExample = c(myExample);

InnerHTML

If you use innerHTML on a node that does not define children, the Atomico render will skip the parsing of that Node, example:

html`<span innerHTML="<b>a<b>"></span>`

Would this meet your objectives?

lokimckay commented 2 years ago

Thanks for the info @UpperCod - those seem like reasonable ways to manipulate the child nodes.

However, my use case is a bit strange I'm afraid The component I have created is an accordion which is intended to be used within sitebuilder platforms e.g. Squarespace / Wordpress etc.

The sitebuilder user simply adds a "code block" (common feature among these platforms that allows you to insert HTML) to create a page that looks like this:

(code block)
<my-accordion></my-accordion>
(/code block)

(text block)
<h1>Accordion button 1</h1>
(/text block)

(other Wordpress/Squarespace text/image blocks etc.)

(text block)
<h1>Accordion button 2</h1>
(/text block)

(other Wordpress/Squarespace text/image blocks etc.)

(code block)
<my-accordion></my-accordion>
(/code block)

My accordion grabs everything between the two elements, and splits the elements into <h1/2/3/4/5/6> headings and their matching content that should be displayed when the heading is clicked

Clone the nodes

I want the sitebuilder user to be able to use the full features of their platform, so I cannot ask them to pass the child nodes as a <slot> in the code block

InnerHTML

I wanted to avoid including an extra container <div> or <span> and then set the innerHTML. In my case I just preferred to render the HTML without an extra container


I understand this use case might be outside the normal expected usage of Atomico.
No worries if you decide nothing needs to be done to support it๐Ÿ˜„

UpperCod commented 2 years ago

What you are looking for is interesting and it can be achieved with Atomico without problems, I attach the example:

https://webcomponents.dev/edit/YzDmaVb3hNgw2IUW9dvZ

The other alternative is the use of the IS attribute on the div the container to define, this avoids the webcomponent wrapper. https://twitter.com/atomicojs/status/1388713726279311360

... personally I have worked a lot with Wordpress, I am attentive to what you need

lokimckay commented 2 years ago

Fantastic! thanks for that example, I didn't think of using the useHost hook ๐Ÿ˜„

My only extra requirement that I did not mention earlier is that sometimes the web component will be nested by the sitebuilder platform e.g:

<div>
  <div>
    <div>
      <div>
        <my-fragment></my-fragment>
      </div>
    </div>
  </div>

  <h1>Title 1</h1>
  <p>Bla bla...</p>

  <h1>Title 2</h1>
  <p>Bla bla...</p>

  <h1>Title 3</h1>
  <p>Bla bla...</p>

  <h1>Title 4</h1>
  <p>Bla bla...</p>

  <div>
    <div>
      <div>
        <my-fragment></my-fragment>
      </div>
    </div>
  </div>
</div>

I was using a Range previously to combat this

Based on your example, I guess I would still need to create a range using the current host reference from useHost and a reference to the second instance of <my-fragment> from document.querySelectorAll(my-fragment) ?

UpperCod commented 2 years ago

Range is the solution, there is a hook called useParentPath with which you will be able to know the parent nodes of the webcomponent, useful when looking for the sibling nodes through a querySelectorAll, example:

import { c, useHost, useMemo } from "atomico";
import { useParentPath } from "@atomico/hooks/use-parent";

function useRange() {
  const host = useHost();

  const path = useParentPath();

  return useMemo(() => {
    let brother;
    path.some((el) => {
      const current = [...el.querySelectorAll(host.current.localName)].find(
        (el) => el !== host.current
      );
      if (el) {
        brother = current;
        return true;
      }
    });
    if (brother) {
      const range = new Range();
      range.setStartBefore(host.current);
      range.setEndBefore(brother);
    }
  });
}

I am curious how it captures the nodes within the range?

lokimckay commented 2 years ago

Cool thanks, I'll check out that hook too ๐Ÿ‘

I am curious how it captures the nodes within the range?

Here's the process I was using:

  1. Capture all elements between the two instances of <my-component> using a range
  2. Use a querySelector on the range's children to find all heading elements (h1/h2 etc.)
  3. Iterate over found headings
  4. Use another range to get all elements between the current heading and the next one (or the end of the original range if this is the last heading)
  5. Return an array of matched headings + their content to the first instance of <my-component> which renders the results as it pleases

I've just recently open-sourced my code, here are the relevant files ๐Ÿ˜„

accordion.component.js

dom.js

UpperCod commented 2 years ago

Thanks for sharing, I find the use of Range and renderHTML interesting, I'm going to explore more solutions with it