diff --git a/src/main.ts b/src/main.ts index ed995ba..0fabe59 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,33 +1,50 @@ -import { Component, type ComponentBlueprint } from "./module/Component"; -import { HTMLComponent } from "./module/HTMLComponent"; +import { Component, type Blueprint } from "./module/Component"; import { State } from "./module/State"; -import { TextComponent } from "./module/TextComponent"; - -class Counter extends Component<{ initValue: number, children: State[]> }> { - state: { count: State } = { - count: new State(0) - }; - - constructor(props: { initValue: number }) { - super({ - ...props, - children: [ - { - component: HTMLComponent, - props: { - children: new State([{ - component: TextComponent, - props: { - content: this.state.count - } - }]) - } - } - ] - }); - } +import { html } from "./module/html"; +type Props = { initValue?: number } +class Counter extends Component, double: State }> { init() { - this.state.count.set(this.props.initValue); + this.states.count = new State(this.propStates.initValue?.value ?? 0); + this.states.double = this.derived((value) => value * 2, [this.states.count]); } -} \ No newline at end of file + + blueprint(): Blueprint[] { + const s = html` + + `; + return [ + ...s, + { + type: 'component', + component: RedText, + props: { + content: this.derived((double) => `doulbed: ${double}`, [this.states.double]) + }, + children: [] + } + ] + } +} + +class RedText extends Component<{ content: string }, {}> { + init() { } + blueprint(): Blueprint[] { + return html` + + ${this.propStates.content} + + ` + } +} + +const counter = new Counter({ initValue: 1 }); +counter.mount(document.body); + + +const btn = document.createElement('button'); +btn.innerText = "destroy"; +btn.onclick = () => {counter.destroy()}; +document.body.appendChild(btn); \ No newline at end of file diff --git a/src/module/Component.ts b/src/module/Component.ts index 1212470..a9acb28 100644 --- a/src/module/Component.ts +++ b/src/module/Component.ts @@ -1,44 +1,27 @@ +import { ComponentBase } from "./ComponentBase"; +import { ComponentFactory } from "./ComponentFactory"; import { State } from "./State"; -import { TextComponent } from "./TextComponent"; -export abstract class Component { - static propsToPropState(props: Props) { - const propsState: Record> = {}; - Object.entries(props).forEach(([key, value]) => { - if (value instanceof State) { - propsState[key] = value; - } - else { - propsState[key] = new State(value); - } +export abstract class Component extends ComponentBase { + declare node: DocumentFragment; + + constructor(props: Props, children: Blueprint[] = []) { + super(props); + this.node = this.createNode(); + + const blueprint = this.blueprint(children); + this.children = blueprint.map((bp) => ComponentFactory.blueprintToComponentInstance(bp)); + this.children.forEach((child) => { + child.mount(this.node); }); - return propsState as PropState; } - static blueprintsToComponentInstances(blueprints: Blueprint[]): Component[] { - return blueprints.map((blueprint) => this.blueprintToComponentInstance(blueprint)); - } - - static blueprintToComponentInstance(blueprint: Blueprint) { - if (blueprint.type === "text") { - return new TextComponent(blueprint.props); - } - } - - propState: PropState; - children: Component[] = []; - mounted: boolean = false; - subscriberSymbols: Symbol[] = []; - abstract node: Node; - - constructor(props: Props) { - this.propState = Component.propsToPropState(props); - this.init(); - } - - abstract init(): void; abstract blueprint(children?: Blueprint[]): Blueprint[]; - mount(target: HTMLElement) { + createNode(): DocumentFragment { + return document.createDocumentFragment(); + } + + mount(target: Node) { if (this.mounted) { throw new Error("This component is already mounted"); } @@ -46,18 +29,22 @@ export abstract class Component { this.mounted = true; } destroy() { - + this.children.forEach((child) => child.destroy()); + this.subscriberSymbols.forEach(({ state, symbol }) => { + state.unsubscribe(symbol); + }); }; } +export type StatesBase = Record>; export type PropsBase = Record; export type PropState = { [K in keyof Props]: State } -export type ComponentBlueprint = { +export type ComponentBlueprint = { type: 'component', - component: new (props: Props) => Component, + component: typeof Component, props: Props, children: Blueprint[] } @@ -71,7 +58,4 @@ export type TextBlueprint = { type: 'text', props: { content: State | any } } -export type Blueprint = ComponentBlueprint | HTMLBlueprint | TextBlueprint; - -type K = PropState<{ asd: number }> -type X = K['asd'] \ No newline at end of file +export type Blueprint = ComponentBlueprint | HTMLBlueprint | TextBlueprint; \ No newline at end of file diff --git a/src/module/ComponentBase.ts b/src/module/ComponentBase.ts new file mode 100644 index 0000000..c38b341 --- /dev/null +++ b/src/module/ComponentBase.ts @@ -0,0 +1,104 @@ +import { State, type ValuesOfStateArray } from "./State"; + +export abstract class ComponentBase { + static propsToPropStates(props: Props | PropStates) { + const propsState: Record> = {}; + Object.entries(props).forEach(([key, value]) => { + if (value instanceof State) { + propsState[key] = value; + } + else { + propsState[key] = new State(value); + } + }); + return propsState as PropStates; + } + + states: States = {} as States; + propStates: PropStates; + children: ComponentBase[] = []; + mounted: boolean = false; + subscriberSymbols: { state: State, symbol: Symbol }[] = []; + abstract node: Node; + + constructor(props: PropStates | Props) { + this.propStates = ComponentBase.propsToPropStates(props); + this.init(); + } + + init(): void { } + protected abstract createNode(): Node; + + mount(target: Node) { + if (this.mounted) { + throw new Error("This component is already mounted"); + } + target.appendChild(this.node); + this.mounted = true; + this.onMount?.(); + } + destroy() { + this.children.forEach((child) => child.destroy()); + this.subscriberSymbols.forEach(({ state, symbol }) => { + state.unsubscribe(symbol); + }); + this.onDestoy?.(); + }; + onMount(): void { }; + onDestoy(): void { }; + + effect[]>(callback: (...values: ValuesOfStateArray) => void, states: T) { + states.forEach((state) => { + const symbol = state.subscribe(() => callback(...states.map(({ value }) => value) as ValuesOfStateArray)); + this.subscriberSymbols.push({ state, symbol }); + }) + } + derived[], U>(callback: (...values: ValuesOfStateArray) => U, states: T): State { + const derivedState = new State(undefined as U); + states.forEach((state) => { + const symbol = state.subscribe(() => { + const derivedValue = callback(...states.map(({ value }) => value) as ValuesOfStateArray); + derivedState.set(derivedValue); + }); + this.subscriberSymbols.push({ state, symbol }); + }); + return derivedState; + } + update( + key: T, + value: T extends keyof Props ? + Props[T] extends State ? U : Props[T] : + U + ) { + let propState: State = this.propStates[key]; + if (!propState) { + propState = new State(value); + this.propStates[key] = propState; + } + propState.set(value); + } +} + +export type StatesBase = Record>; +export type PropsBase = Record; +export type PropStates = { + [K in keyof Props]: State +} + +export type ComponentBlueprint = { + type: 'component', + component: new (props: Props, children?: Blueprint[]) => ComponentBase, + props: Props, + children: Blueprint[] +} +export type HTMLBlueprint = { + type: 'html', + tag: string; + props: Record; + children: Blueprint[] +} +export type TextBlueprint = { + type: 'text', + props: { content: State | any } +} +export type Blueprint = ComponentBlueprint | HTMLBlueprint | TextBlueprint; \ No newline at end of file diff --git a/src/module/ComponentFactory.ts b/src/module/ComponentFactory.ts new file mode 100644 index 0000000..d9ae506 --- /dev/null +++ b/src/module/ComponentFactory.ts @@ -0,0 +1,18 @@ +import type { Blueprint } from "./Component"; +import type { ComponentBase } from "./ComponentBase"; +import { HTMLComponent } from "./HTMLComponent"; +import { TextComponent } from "./TextComponent"; + +export namespace ComponentFactory { + export function blueprintToComponentInstance(blueprint: Blueprint): ComponentBase { + if (blueprint.type === "text") { + return new TextComponent(blueprint.props); + } + else if (blueprint.type === "html") { + return new HTMLComponent(blueprint.tag, blueprint.props, blueprint.children); + } + else { + return new blueprint.component(blueprint.props, blueprint.children); + } + } +} \ No newline at end of file diff --git a/src/module/HTMLComponent.ts b/src/module/HTMLComponent.ts index 69c82dd..0ce730f 100644 --- a/src/module/HTMLComponent.ts +++ b/src/module/HTMLComponent.ts @@ -1,31 +1,49 @@ -import { Component, type Blueprint, type PropsBase } from "./Component"; +import { type Blueprint, type PropsBase } from "./Component"; +import { ComponentBase } from "./ComponentBase"; +import { ComponentFactory } from "./ComponentFactory"; import { State } from "./State"; -export class HTMLComponent extends Component { +export class HTMLComponent extends ComponentBase { state: Record> = {}; tag: string; - node: HTMLElement; - subscriberSymbols: { state: State, symbol: Symbol }[] = []; + declare node: HTMLElement; - constructor(tag: string, props: Props) { + constructor(tag: string, props: Props, children: Blueprint[] = []) { super(props); this.tag = tag; - this.node = document.createElement(tag); + this.node = this.createNode(); - const blueprint = this.blueprint(); - this.children = Component.blueprintsToComponentInstances(blueprint); + const blueprint = this.blueprint(children); + this.children = blueprint.map((bp) => ComponentFactory.blueprintToComponentInstance(bp)); this.children.forEach((child) => { - this.node.appendChild(child.node); + child.mount(this.node); + }); + + Object.entries(this.propStates).forEach(([key, prop]) => { + if (prop instanceof State) { + prop.subscribe((value) => { + this.setNodePropertyOrAttribute(key, value); + }) + } + else { + this.setNodePropertyOrAttribute(key, prop); + } }) } - init() { } + private setNodePropertyOrAttribute(key: string, value: any) { + if (key in this.node) { + (this.node as any)[key] = value; + } + else { + this.node.setAttribute(key, value + "") + } + } + createNode() { + return document.createElement(this.tag); + } blueprint(children?: Blueprint[]): Blueprint[] { return children ?? []; } - - destroy(): void { - - } } \ No newline at end of file diff --git a/src/module/State.ts b/src/module/State.ts index 6fd4574..557a500 100644 --- a/src/module/State.ts +++ b/src/module/State.ts @@ -29,4 +29,5 @@ export class State { } } -type SubscribeCallback = (value: T) => any \ No newline at end of file +type SubscribeCallback = (value: T) => any +export type ValuesOfStateArray[]> = { [K in keyof T]: T[K] extends State ? V : never }; \ No newline at end of file diff --git a/src/module/TextComponent.ts b/src/module/TextComponent.ts index 0cc3c22..4884215 100644 --- a/src/module/TextComponent.ts +++ b/src/module/TextComponent.ts @@ -1,23 +1,22 @@ -import { Component, type TextBlueprint } from "./Component"; +import { ComponentBase } from "./ComponentBase"; -export class TextComponent extends Component<{ content: any }> { +export class TextComponent extends ComponentBase<{ content: any }, {}> { init() { }; node: Text; - subscribeSymbol: Symbol; constructor(props: { content: any }) { super(props); - this.node = document.createTextNode(this.propState.content.value + ""); - this.subscribeSymbol = this.propState.content.subscribe((value) => { - this.node.textContent = value = ""; - }); + this.node = this.createNode(); + this.effect((value) => { + this.node.textContent = value + ""; + }, [this.propStates.content]) + } + + protected createNode(): Text { + return document.createTextNode(this.propStates.content.value + ""); } blueprint() { return [] } - - destroy(): void { - this.propState.content.unsubscribe(this.subscribeSymbol); - } } diff --git a/src/module/html.ts b/src/module/html.ts new file mode 100644 index 0000000..d445274 --- /dev/null +++ b/src/module/html.ts @@ -0,0 +1,113 @@ +import { type Blueprint } from "./Component"; +import { State } from "./State"; + +const MARKER_PREFIX = "__GEMINI_MARKER_"; +// DOMParser는 태그 이름을 소문자로 변환하므로 소문자 버전도 준비합니다. +const LOWER_MARKER_PREFIX = MARKER_PREFIX.toLowerCase(); +const MARKER_REGEX = new RegExp(`${MARKER_PREFIX}(\\d+)__`, 'g'); +const TAG_MARKER_REGEX = new RegExp(`${LOWER_MARKER_PREFIX}(\\d+)__`); + +export function html(strings: TemplateStringsArray, ...values: any[]): Blueprint[] { + const htmlString = strings.reduce((acc, str, i) => { + return acc + str + (i < values.length ? `${MARKER_PREFIX}${i}__` : ""); + }, ""); + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + + const blueprints: Blueprint[] = []; + Array.from(doc.body.childNodes).forEach(node => { + const result = nodeToBlueprint(node, values); + if (Array.isArray(result)) { + blueprints.push(...result); + } else if (result) { + blueprints.push(result); + } + }); + return blueprints; +} + +function nodeToBlueprint(node: Node, values: any[]): Blueprint | Blueprint[] | null { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + const matches = [...text.matchAll(MARKER_REGEX)]; + + if (matches.length === 0) { + if (text.trim() === "") return null; + return { type: 'text', props: { content: text } }; + } + + if (matches.length === 1 && text.trim() === matches[0][0]) { + const index = parseInt(matches[0][1]); + const val = values[index]; + if (Array.isArray(val)) return val; + if (val && typeof val === 'object' && 'type' in val) return val; + return { type: 'text', props: { content: val } }; + } + + let content: any = text; + matches.forEach(match => { + const index = parseInt(match[1]); + const val = values[index]; + + // 컴포넌트 클래스(함수)가 텍스트 노드에 포함된 경우 치환하지 않고 건너뜁니다. + if (typeof val === 'function' && !(val instanceof State)) { + content = content.replace(match[0], ""); + return; + } + + const replacement = val instanceof State ? val.value : val; + content = content.replace(match[0], replacement); + }); + return { type: 'text', props: { content } }; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tagName = el.tagName.toLowerCase(); + + // 태그 자체가 마커인지 확인 (컴포넌트 케이스) + const tagMatch = tagName.match(TAG_MARKER_REGEX); + + const props: Record = {}; + Array.from(el.attributes).forEach(attr => { + const match = attr.value.match(new RegExp(`${MARKER_PREFIX}(\\d+)__`)); + if (match) { + const index = parseInt(match[1]); + props[attr.name] = values[index]; + } else { + props[attr.name] = attr.value; + } + }); + + const children: Blueprint[] = []; + Array.from(el.childNodes).forEach(child => { + const result = nodeToBlueprint(child, values); + if (Array.isArray(result)) { + children.push(...result); + } else if (result) { + children.push(result); + } + }); + + if (tagMatch) { + const index = parseInt(tagMatch[1]); + const componentClass = values[index]; + return { + type: 'component', + component: componentClass, + props, + children + }; + } + + return { + type: 'html', + tag: tagName, + props, + children + }; + } + + return null; +} diff --git a/tsconfig.json b/tsconfig.json index 4ba8dd9..1a3882d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true },