All files / custom-element / mod.ts

100.00% Branches 47/47
100.00% Functions 6/6
100.00% Lines 96/96
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
115
116
117
118
119
120
 
x10
x10
x10
 
 
 
x10
x10
x10
x10
x10
 
 
x10
x10
x10
x10
x10
x44
x10
x10
x179
x19
x19
x19
x19
x19
x19
x19
x19
x19
x19
x10
x10
 
x27
x1
x1
x27
x1
x1
x1
x27
x27
x1
x1
x1
 
 
x27
x4
x4
x27
x2
x2
x2
x18
 
 
x18
x18
x27
x1
x1
x1
x18
x18
x18
x18
 
x21
x4
x4
 
 
x17
x17
 
 
x17
x21
x20
x20
x15
x15
x20
x12
x12
x20
x20
x20
x20
x20
x17
x17
x17
x17
x21
x3
x3
x21
x18
 
x18
x27
x10
 
 
 
 
 
x10
x10
x10
x10
x10
 
 
x10





















































































































// Imports
import { type Directive, type Nullable, Phase } from "@mizu/internal/engine"
import { isValidCustomElementName } from "@std/html/unstable-is-valid-custom-element-name"
import { _once } from "@mizu/once"
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 })
    let flat = parsed.modifiers.flat
    if (flat && (!renderer.cache(_once.name))) {
      renderer.warn(`[${this.name}] .flat modifier requires [${_once.name}] directive to be loaded, ignoring`, element)
      flat = false
    }
    renderer.window.customElements.define(
      tagname,
      class extends renderer.window.HTMLElement {
        connectedCallback(this: HTMLElement) {
          // Skip element if it was already processed or if it has an expansion directive
          if ((cache.has(this)) || (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 (flat && (!renderer.getAttributes(this, _once.name, { first: true }))) {
            renderer.setAttribute(this, `${_once.name}.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]