All files / for / mod.ts

98.73% Branches 78/79
100.00% Functions 4/4
100.00% Lines 126/126
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
 
x13
x13
 
 
 
x13
x13
x13
x13
x82
x13
x13
x541
x541
x37
x37
x13
x13
 
x64
x64
x64
x64
x64
x12
x1
x1
x1
 
x11
x11
x12
x3
x7
x17
x7
x12
x1
x1
x1
x12
 
 
x62
x64
x42
x42
x42
x62
x62
x64
 
x228
 
 
x64
x64
x64
x64
x123
x123
x123
x48
x48
x48
x48
x123
x123
x123
x100
x100
x121
x23
x23
 
x123
x100
x100
x100
x11
x11
x100
x89
x89
x89
x35
x35
x89
x89
x100
x100
x100
 
x123
x123
x181
x181
x181
x123
x163
x163
x129
x129
x163
x123
x123
 
 
x64
x137
x15
x15
x15
x15
x137
x62
x64
x13
 
 
 
 
x13
x138
x138
x122
x122
x88
x11
x11
x5
x138
x32
x32
x5
x5
x32
x5
x138
 
 
x13
x13
x13
x13
 
 
x13


































































































































I















// Imports
import { type Arg, type Cache, type Context, type Directive, type Nullable, Phase, type Renderer } from "@mizu/internal/engine"
import { Expression } from "./parse.ts"
export type * from "@mizu/internal/engine"

/** `*for` directive. */
export const _for = {
  name: "*for",
  phase: Phase.EXPAND,
  init(renderer) {
    renderer.cache<Cache<typeof _for>>(this.name, new WeakMap())
  },
  setup(renderer, element, { cache, state }) {
    const original = (renderer.isComment(element) ? renderer.cache("*").get(element) : null) ?? element
    if ((cache.get(original) === null) && (!state[renderer.internal("iterating")])) {
      return false
    }
  },
  async execute(renderer, element, { cache, context, state, attributes: [attribute] }) {
    // Compute iterations using the expression parser
    const iterations = [] as Record<PropertyKey, unknown>[]
    try {
      const loop = `()=>{for(${attribute.value}){${renderer.internal("iterations")}.push({${Expression.parse(attribute.value).join(",")}})}}`
      await renderer.evaluate(null, loop, { context, state: { ...state, [renderer.internal("iterations")]: iterations }, args: [] })
    } catch (error) {
      if (!(error instanceof SyntaxError)) {
        renderer.warn(`[${this.name}] error while evaluating expression: ${error}`, element)
        return { final: true }
      }
      // Fallback to iterable values
      try {
        const value = await renderer.evaluate(null, attribute.value, { context, state })
        if (typeof value === "number") {
          iterations.push(...Array.from({ length: value }, () => ({})))
        } else {
          iterations.push(...Object.entries(value as Arg<typeof Object.entries>).map(([$key, $value]) => ({ $key, $value })))
        }
      } catch {
        renderer.warn(`[${this.name}] syntax error in expression: ${attribute.value}`, element)
        return { final: true }
      }
    }

    // Comment out templated element
    let comment = element as unknown as Comment
    if (!renderer.isComment(element)) {
      comment = renderer.comment(element, { directive: this.name as string, expression: attribute.value })
      cache.set(comment, { element, items: new Map(), contexts: new Map(), iteration: null })
    }
    const cached = cache.get(comment)!
    element = cached.element
    const identifiable = renderer.getAttributes(element, _id.name, { first: true })?.value
    // Templates carrying other directives are iterated as elements
    const expandable = (element.tagName === "TEMPLATE") && (!renderer.directives.some((directive) => (![this.name, _id.name].includes(directive.name)) && (renderer.getAttributes(element, directive.name, { first: true }))))

    // Generate items
    let position = comment as Node
    const generated = new Set<string>()
    const pending = []
    for (let i = 0; i < iterations.length; i++) {
      const meta = { $i: i, $I: i + 1, $iterations: iterations.length, $first: i === 0, $last: i === iterations.length - 1 }
      let id = `${i}`
      if (identifiable) {
        cached.iteration ??= context.with({ ...iterations[i] })
        Object.assign(cached.iteration.target, iterations[i])
        id = `${await renderer.evaluate(null, identifiable, { context: cached.iteration, state: { ...state, ...meta } })}`
      }
      generated.add(id)
      let iteration = cached.contexts.get(id)
      if (!iteration) {
        iteration = context.with(iterations[i])
        cached.contexts.set(id, iteration)
      } else {
        Object.assign(iteration.target, iterations[i])
      }
      // Create item if non-existent
      if (!cached.items.has(id)) {
        let nodes = [] as Node[]
        let end = null as Nullable<Comment>
        if (expandable) {
          nodes = Array.from((element as HTMLTemplateElement).content.cloneNode(true).childNodes)
          end = renderer.document.createComment(`[/${this.name}]`)
        } else {
          const item = element.cloneNode(true) as HTMLElement
          item.removeAttributeNode(renderer.getAttributes(item, this.name, { first: true })!)
          if (identifiable) {
            renderer.setAttribute(item, _id.name, id)
          }
          nodes = [item]
        }
        nodes.forEach((node) => renderer.isHtmlElement(node as HTMLElement) && cache.set(node as HTMLElement, null))
        cached.items.set(id, { nodes, end })
      }
      // Insert item
      const item = cached.items.get(id)!
      for (const node of range(renderer, item)) {
        comment.parentNode?.insertBefore(node, position.nextSibling)
        position = node
      }
      for (const node of item.nodes) {
        const current = renderer.getComment(node as HTMLElement) ?? node
        if (current.nodeType !== renderer.window.Node.TEXT_NODE) {
          pending.push(await renderer.render(current as HTMLElement, { context: iteration, state: { ...state, [renderer.internal("iterating")]: true, ...meta, $id: id } }))
        }
      }
    }
    await Promise.allSettled(pending)

    // Remove outdated items
    for (const [id, item] of cached.items) {
      if (!generated.has(id)) {
        cached.items.delete(id)
        cached.contexts.delete(id)
        range(renderer, item).forEach((node) => (node as ChildNode).remove())
      }
    }
    return { final: true }
  },
} as const satisfies Directive<{
  Cache: WeakMap<HTMLElement | Comment, Nullable<{ element: HTMLElement; items: Map<string, { nodes: Node[]; end: Nullable<Comment> }>; contexts: Map<string, Context>; iteration: Nullable<Context> }>>
}>

/** Resolve the nodes currently occupied by an item (from its first node up to its closing comment, if any). */
function range(renderer: Renderer, { nodes, end }: { nodes: Node[]; end: Nullable<Comment> }) {
  const current = nodes.map((node) => renderer.getComment(node as HTMLElement) ?? node)
  if (!end) {
    return current
  }
  if (!end.parentNode) {
    return [...current, end]
  }
  const occupied = [] as Node[]
  for (let node = (current[0] ?? end) as Nullable<ChildNode>; node; node = node.nextSibling) {
    occupied.push(node)
    if (node === end) {
      break
    }
  }
  return occupied
}

/** `*id` directive. */
export const _id = {
  name: "*id",
  phase: Phase.META,
} as Directive & { name: string }

/** Default exports. */
export default [_for, _id]