All files / custom-element / mod.ts

100.00% Branches 23/23
100.00% Lines 90/90
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
 
x8
x8
 
 
 
x8
x8
x24
x8
x8
 
 
x8
x8
x8
x8
x8
x38
x8
x8
x120
x131
x131
x131
x131
x131
x131
x131
x131
x131
x131
x8
x8
 
x25
x26
x26
x25
x26
x78
x26
x25
x25
x26
x78
x26
 
 
x25
x78
x26
x25
x27
x81
x27
x36
 
 
x108
x36
x36
x36
x36
 
x48
x49
x49
 
 
x177
x59
 
 
x59
x48
x63
x63
x71
x71
x63
x71
x71
x63
x63
x63
x63
x63
x59
x71
x59
x59
x48
x49
x49
x48
x36
 
x108
x25
x8
 
 
x8
x8
x8
x8
x8
 
 
x32












































































































// Imports
import { type Cache, type Directive, type Nullable, Phase } from "@mizu/internal/engine"
import { isValidCustomElementName } from "@std/html/unstable-is-valid-custom-element-name"
export type * from "@mizu/internal/engine"

/** `*custom-element` typings. */
export const typings = {
  modifiers: {
    flat: { type: Boolean },
  },
} as const

/** `*custom-element` directive. */
export const _custom_element = {
  name: "*custom-element",
  phase: Phase.CUSTOM_ELEMENT,
  typings,
  init(renderer) {
    renderer.cache<Cache<typeof _custom_element>>(this.name, new WeakMap())
  },
  setup(renderer, element, { cache }) {
    if ((renderer.isHtmlElement(element)) && (cache.get(element))) {
      return {
        state: {
          $slots: cache.get(element)!,
          $attrs: new Proxy({}, {
            has: (_, name: string) => element.hasAttribute(name),
            get: (_, name: string) => element.getAttribute(name) ?? undefined,
          }),
        },
      }
    }
  },
  async execute(renderer, element, { cache, attributes: [attribute], ...options }) {
    // Validate element and custom element name
    if (!renderer.isHtmlElement(element)) {
      return
    }
    if ((element.tagName !== "TEMPLATE")) {
      renderer.warn(`A [${this.name}] directive must be defined on a <template> element, ignoring`, element)
      return { final: true }
    }
    const tagname = isValidCustomElementName(attribute.value) ? attribute.value : `${await renderer.evaluate(element, attribute.value || "''", options)}`
    if (!tagname) {
      renderer.warn(`A [${this.name}] directive must have a valid custom element name, ignoring`, element)
      return { final: true }
    }

    // Skip already registered custom elements
    if (cache.has(element)) {
      return { final: true }
    }
    if (renderer.window.customElements.get(tagname)) {
      renderer.warn(`<${tagname}> is already registered as a custom element, ignoring`, element)
      return { final: true }
    }
    cache.set(element, null)

    // Register custom element
    const parsed = renderer.parseAttribute(attribute, this.typings, { modifiers: true })
    renderer.window.customElements.define(
      tagname,
      class extends renderer.window.HTMLElement {
        connectedCallback(this: HTMLElement) {
          // Skip element if it has an expansion directive
          if (renderer.elementHasPhase(this, Phase.EXPAND)) {
            return
          }

          // Store provided content and replace it by the template
          const content = Array.from(renderer.createElement("div", { innerHTML: this.innerHTML.trim() }).childNodes) as HTMLElement[]
          this.innerHTML = element.innerHTML

          // Sort provided content into their designated <slot>
          const slots = cache.set(this, {}).get(this)!
          for (const child of content) {
            const names = []
            if (child.nodeType === renderer.window.Node.ELEMENT_NODE) {
              names.push(...renderer.getAttributes(child, _slot.name).map((attribute) => attribute.name.slice(_slot.prefix.length)))
            }
            if (!names.length) {
              names.push("")
            }
            for (const name of names) {
              slots[name] ??= renderer.createElement("slot")
              slots[name].appendChild(child.cloneNode(true))
            }
          }
          Object.entries(slots).forEach(([name, content]) => {
            this.querySelectorAll<HTMLSlotElement>(`slot${name ? `[name="${name}"]` : ":not([name])"}`).forEach((slot) => renderer.replaceElementWithChildNodes(slot, content))
          })
          this.querySelectorAll<HTMLSlotElement>("slot").forEach((slot) => renderer.replaceElementWithChildNodes(slot, slot))
          if (parsed.modifiers.flat) {
            renderer.replaceElementWithChildNodes(this, this)
          }
        }
      },
    )
    return { final: true }
  },
} as Directive<WeakMap<HTMLElement, Nullable<Record<PropertyKey, HTMLSlotElement>>>, typeof typings> & { name: string }

/** `#slot` directive. */
export const _slot = {
  name: /^#(?<slot>)/,
  prefix: "#",
  phase: Phase.META,
} as Directive & { prefix: string }

/** Default exports. */
export default [_custom_element, _slot]