|
| 1 | +import { on } from "../on"; |
| 2 | +import { useEffect } from "../state/useEffect"; |
| 3 | +import { useState } from "../state/useState"; |
| 4 | + |
| 5 | +type ComponentConstructor< |
| 6 | + Props extends Record<string, unknown>, |
| 7 | + Children extends string | undefined = undefined, |
| 8 | +> = Children extends string |
| 9 | + ? (props: Props) => { |
| 10 | + html: (children: TemplateStringsArray) => HtmlTemplateNode; |
| 11 | + } |
| 12 | + : (props: Props) => HtmlTemplateNode; |
| 13 | + |
| 14 | +/** |
| 15 | + * @description HTML 템플릿 노드를 나타내는 클래스 |
| 16 | + * Template literal의 strings와 expressions를 캡슐화 |
| 17 | + */ |
| 18 | +export class HtmlTemplateNode { |
| 19 | + constructor( |
| 20 | + private readonly html: { |
| 21 | + strings: TemplateStringsArray; |
| 22 | + expressions: unknown[]; |
| 23 | + }, |
| 24 | + ) {} |
| 25 | + |
| 26 | + /** |
| 27 | + * @description 저장된 HTML 템플릿 데이터 반환 |
| 28 | + * @returns {Object} 템플릿 데이터 객체 |
| 29 | + * @returns {TemplateStringsArray} returns.strings - Template literal의 문자열 배열 |
| 30 | + * @returns {unknown[]} returns.expressions - Template literal의 표현식 배열 |
| 31 | + * @example |
| 32 | + * const node = html`<div>${'Hello'}</div>`; |
| 33 | + * const { strings, expressions } = node.getHTML(); |
| 34 | + * console.log(strings); |
| 35 | + * // 출력: ['<div>', '</div>'] |
| 36 | + * console.log(expressions); |
| 37 | + * // 출력: ['Hello'] |
| 38 | + */ |
| 39 | + getHTML(): { strings: TemplateStringsArray; expressions: unknown[] } { |
| 40 | + return this.html; |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * @description 재사용 가능한 컴포넌트를 생성하는 함수 |
| 46 | + * Props와 Children을 받아 HtmlTemplateNode를 반환하는 컴포넌트 생성 |
| 47 | + * @param {Function} renderer - Props를 받아 HtmlTemplateNode를 반환하는 렌더 함수 |
| 48 | + * @param {Props} renderer.props - 컴포넌트에 전달될 속성 객체 |
| 49 | + * @param {Children} [renderer.children] - 자식 템플릿 (children을 받는 컴포넌트인 경우) |
| 50 | + * @returns {Function} 컴포넌트 생성자 함수 |
| 51 | + * @example |
| 52 | + * // Props만 받는 기본 컴포넌트 |
| 53 | + * const Button = component((props: { text: string; onClick: () => void }) => { |
| 54 | + * return html` |
| 55 | + * <button ${on('click', props.onClick)}> |
| 56 | + * ${props.text} |
| 57 | + * </button> |
| 58 | + * `; |
| 59 | + * }); |
| 60 | + * |
| 61 | + * render` |
| 62 | + * <div> |
| 63 | + * ${Button({ text: 'Click me', onClick: () => console.log('Clicked!') })} |
| 64 | + * </div> |
| 65 | + * `; |
| 66 | + * |
| 67 | + * @example |
| 68 | + * // State를 사용하는 컴포넌트 |
| 69 | + * const Counter = component((props: { initial: number }) => { |
| 70 | + * const $count = useState(props.initial); |
| 71 | + * |
| 72 | + * return html` |
| 73 | + * <div> |
| 74 | + * <p>Count: ${$count}</p> |
| 75 | + * <button ${on('click', () => $count.set(prev => prev + 1))}> |
| 76 | + * Increment |
| 77 | + * </button> |
| 78 | + * </div> |
| 79 | + * `; |
| 80 | + * }); |
| 81 | + * |
| 82 | + * render`${Counter({ initial: 0 })}`; |
| 83 | + * |
| 84 | + * @example |
| 85 | + * // Children을 받는 컴포넌트 |
| 86 | + * const Card = component((props: { title: string }, children: string) => { |
| 87 | + * return html` |
| 88 | + * <div class="card"> |
| 89 | + * <h2>${props.title}</h2> |
| 90 | + * <div class="card-body"> |
| 91 | + * ${children} |
| 92 | + * </div> |
| 93 | + * </div> |
| 94 | + * `; |
| 95 | + * }); |
| 96 | + * |
| 97 | + * render` |
| 98 | + * ${Card({ title: 'My Card' }).html` |
| 99 | + * <p>Card content here</p> |
| 100 | + * `} |
| 101 | + * `; |
| 102 | + * |
| 103 | + * @example |
| 104 | + * // useEffect와 함께 사용 |
| 105 | + * const Timer = component((props: { interval: number }) => { |
| 106 | + * const $seconds = useState(0); |
| 107 | + * |
| 108 | + * useEffect((deps) => { |
| 109 | + * const [interval] = deps; |
| 110 | + * const timerId = setInterval(() => { |
| 111 | + * $seconds.set(prev => prev + 1); |
| 112 | + * }, interval); |
| 113 | + * |
| 114 | + * return () => clearInterval(timerId); |
| 115 | + * }, [props.interval]); |
| 116 | + * |
| 117 | + * return html`<div>Seconds: ${$seconds}</div>`; |
| 118 | + * }); |
| 119 | + * |
| 120 | + * render`${Timer({ interval: 1000 })}`; |
| 121 | + * // 1초마다 seconds가 증가하며 자동으로 화면 업데이트 |
| 122 | + */ |
| 123 | +export function component<const Props extends Record<string, unknown>>( |
| 124 | + renderer: (props: Props) => HtmlTemplateNode, |
| 125 | +): (props: Props) => HtmlTemplateNode; |
| 126 | +export function component< |
| 127 | + const Props extends Record<string, unknown>, |
| 128 | + const Children extends string, |
| 129 | +>( |
| 130 | + renderer: (props: Props, children: Children) => HtmlTemplateNode, |
| 131 | +): (props: Props) => { |
| 132 | + html: (children: TemplateStringsArray) => HtmlTemplateNode; |
| 133 | +}; |
| 134 | +export function component< |
| 135 | + const Props extends Record<string, unknown>, |
| 136 | + const Children extends string | undefined = undefined, |
| 137 | +>(renderer: (props: Props, children?: Children) => HtmlTemplateNode) { |
| 138 | + return ((props: Props) => { |
| 139 | + if (renderer.length === 1) { |
| 140 | + return renderer(props); |
| 141 | + } |
| 142 | + |
| 143 | + return { |
| 144 | + html: (children: Children) => renderer(props, children), |
| 145 | + }; |
| 146 | + }) as ComponentConstructor<Props, Children>; |
| 147 | +} |
| 148 | + |
| 149 | +/** |
| 150 | + * @description Template literal을 HtmlTemplateNode로 변환하는 태그 함수 |
| 151 | + * render 함수나 component 내부에서 HTML 템플릿을 작성할 때 사용 |
| 152 | + * @param {TemplateStringsArray} strings - Template literal의 문자열 배열 |
| 153 | + * @param {...unknown[]} expressions - Template literal의 표현식 배열 |
| 154 | + * @returns {HtmlTemplateNode} HTML 템플릿 노드 |
| 155 | + * @example |
| 156 | + * // 기본 사용 |
| 157 | + * const node = html`<div>Hello World</div>`; |
| 158 | + * |
| 159 | + * @example |
| 160 | + * // 동적 값 포함 |
| 161 | + * const name = 'John'; |
| 162 | + * const node = html`<div>Hello, ${name}!</div>`; |
| 163 | + * |
| 164 | + * @example |
| 165 | + * // State 포함 |
| 166 | + * const $count = useState(0); |
| 167 | + * const node = html`<div>Count: ${$count}</div>`; |
| 168 | + * |
| 169 | + * @example |
| 170 | + * // 컴포넌트 내부에서 사용 |
| 171 | + * const MyComponent = component((props: { title: string }) => { |
| 172 | + * return html` |
| 173 | + * <div> |
| 174 | + * <h1>${props.title}</h1> |
| 175 | + * <p>Content</p> |
| 176 | + * </div> |
| 177 | + * `; |
| 178 | + * }); |
| 179 | + * |
| 180 | + * @example |
| 181 | + * // 배열 렌더링 |
| 182 | + * const items = ['Apple', 'Banana', 'Orange']; |
| 183 | + * const node = html` |
| 184 | + * <ul> |
| 185 | + * ${items.map(item => html`<li>${item}</li>`)} |
| 186 | + * </ul> |
| 187 | + * `; |
| 188 | + * // <ul><li>Apple</li><li>Banana</li><li>Orange</li></ul> |
| 189 | + */ |
| 190 | +export function html( |
| 191 | + strings: TemplateStringsArray, |
| 192 | + ...expressions: unknown[] |
| 193 | +): HtmlTemplateNode { |
| 194 | + return new HtmlTemplateNode({ strings, expressions }); |
| 195 | +} |
0 commit comments