blueprint

This commit is contained in:
2026-03-15 13:29:58 +09:00
parent 054f8b471d
commit a457a80bfe
5 changed files with 83 additions and 153 deletions

View File

@@ -1,6 +1,7 @@
import { Component, type Blueprint } from "./module/Component";
import { Component } from "./module/Component";
import { type Blueprint } from "./module/ComponentBase";
import { State } from "./module/State";
import { html } from "./module/html";
import { html } from "./module/blueprint";
type Props = { initValue?: number }
class Counter extends Component<Props, { count: State<number>, double: State<number> }> {
@@ -14,18 +15,9 @@ class Counter extends Component<Props, { count: State<number>, double: State<num
<button onclick="${() => this.states.count.update((val) => val + 1)}">
${this.states.count}
</button>
<${RedText} content="asd"></${RedText}>
`;
return [
...s,
{
type: 'component',
component: RedText,
props: {
content: this.derived((double) => `doulbed: ${double}`, [this.states.double])
},
children: []
}
]
return s;
}
}

View File

@@ -1,6 +1,6 @@
import { ComponentBase } from "./ComponentBase";
import { ComponentFactory } from "./ComponentFactory";
import { State } from "./State";
import type { PropsBase, StatesBase, Blueprint } from "./ComponentBase";
export abstract class Component<Props extends PropsBase, States extends StatesBase> extends ComponentBase<Props, States> {
declare node: DocumentFragment;
@@ -34,28 +34,4 @@ export abstract class Component<Props extends PropsBase, States extends StatesBa
state.unsubscribe(symbol);
});
};
}
export type StatesBase = Record<string, State<any>>;
export type PropsBase = Record<string, any>;
export type PropState<Props extends PropsBase> = {
[K in keyof Props]: State<Props[K]>
}
export type ComponentBlueprint<Props extends PropsBase, States extends StatesBase> = {
type: 'component',
component: typeof Component<Props, States>,
props: Props,
children: Blueprint[]
}
export type HTMLBlueprint = {
type: 'html',
tag: string;
props: Record<string, any>;
children: Blueprint[]
}
export type TextBlueprint = {
type: 'text',
props: { content: State<any> | any }
}
export type Blueprint = ComponentBlueprint<any, any> | HTMLBlueprint | TextBlueprint;
}

View File

@@ -1,4 +1,4 @@
import { type Blueprint, type PropsBase } from "./Component";
import { type Blueprint, type PropsBase } from "./ComponentBase";
import { ComponentBase } from "./ComponentBase";
import { ComponentFactory } from "./ComponentFactory";
import { State } from "./State";

75
src/module/blueprint.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { Blueprint } from "./ComponentBase";
import type { State } from "./State";
function replacePseudoHtmlWithMarker(splitedHtmls: TemplateStringsArray) {
const markerHeader = `marker-${crypto.randomUUID()}`;
let html = "";
for (let i = 0; i < splitedHtmls.length; i++) {
html += splitedHtmls[i];
if (i !== splitedHtmls.length - 1) {
html += `${markerHeader}-${i}`;
}
};
return { html, markerHeader };
}
function getMarkerIndex(markerString: string) {
let barIndex = markerString.lastIndexOf('-');
return Number(markerString.slice(barIndex + 1));
}
export function html(strings: TemplateStringsArray, ...values: any[]) {
const { html: replaceHtml, markerHeader } = replacePseudoHtmlWithMarker(strings);
const dom = new DOMParser().parseFromString(replaceHtml, 'text/html');
const bluePrints = Array.from(dom.childNodes).map((e) => nodeToBlueprint(e, markerHeader, values)).filter((e) => e !== null);
console.log(bluePrints);
return bluePrints;
}
function nodeToBlueprint(node: Node, markerHeader: string, values: any[]): Blueprint | null {
if (node.nodeType === node.TEXT_NODE) {
let content: State<any> | any;
if (node.textContent?.trim()?.startsWith(markerHeader)) {
content = values[getMarkerIndex(node.textContent)];
}
else {
content = node.textContent;
}
return {
type: 'text',
props: { content }
}
}
else if (node.nodeType === node.ELEMENT_NODE) {
const props: Record<string, any> = {};
for (const attr of (node as Element).attributes) {
if (attr.value.startsWith(markerHeader)) {
props[attr.name] = values[getMarkerIndex(attr.value)];
}
else {
props[attr.name] = attr.value;
}
}
if (node.nodeName.startsWith(markerHeader.toUpperCase())) {
return {
type: 'component',
component: values[getMarkerIndex(node.nodeName)],
children: Array.from(node.childNodes).map((child) => nodeToBlueprint(child, markerHeader, values)).filter((e) => e !== null),
props
}
}
else {
return {
type: 'html',
tag: node.nodeName.toLowerCase(),
children: Array.from(node.childNodes).map((child) => nodeToBlueprint(child, markerHeader, values)).filter((e) => e !== null),
props
}
}
}
else {
return null;
}
}

View File

@@ -1,113 +0,0 @@
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<string, any> = {};
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;
}