All files / custom-element / mod.ts

100.00% Branches 41/41
100.00% Functions 6/6
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
112
113
114
 
x8
x8
 
 
 
x8
x8
x8
x8
x8
 
 
x8
x8
x8
x8
x8
x34
x8
x8
x136
x13
x13
x13
x13
x13
x13
x13
x13
x13
x13
x8
x8
 
x20
x1
x1
x20
x1
x1
x1
x20
x20
x1
x1
x1
 
 
x20
x3
x3
x20
x2
x2
x2
x12
 
 
x12
x12
x12
x12
x12
 
x14
x1
x1
 
 
x13
x13
 
 
x13
x14
x17
x17
x12
x12
x17
x9
x9
x17
x17
x17
x17
x17
x13
x14
x13
x13
x14
x1
x1
x14
x12
 
x12
x20
x8
 
 
 
 
 
x8
x8
x8
x8
x8
 
 
x8















































































































// Imports
import { 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(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(String(name)),
            get: (_, name: string) => element.getAttribute(String(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.setAttribute(this, "*once.flat")
          }
        }
      },
    )
    return { final: true }
  },
} as const satisfies Directive<{
  Cache: WeakMap<HTMLElement, Nullable<Record<PropertyKey, HTMLSlotElement>>>
  Typings: typeof typings
}>

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

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