lunet 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # lunet
2
+ より柔軟なWebフロントエンドライブラリ。
3
+
4
+ ## 使い方のサンプル
5
+ ```jsx
6
+ import { createComponent, render, setBatch } from "lunet";
7
+ import { signal, effect, startBatch, endBatch } from "alien-signals";
8
+
9
+ setBatch(cb => {
10
+ startBatch();
11
+ cb();
12
+ endBatch();
13
+ });
14
+
15
+ const App = createComponent((render, init) => {
16
+ const msg = signal(init.msg);
17
+ const count = signal(0);
18
+
19
+ effect(() => {
20
+ render(<div class="app">
21
+ <button $click={()=>count(count()+1)}>{msg()} {count().toString()}</button>
22
+ </div>)
23
+ });
24
+
25
+ return {
26
+ set msg(value){ msg(value) }
27
+ }
28
+ })
29
+
30
+ render(document.getElementById("root"), <App msg="Count:" />);
31
+
32
+ ```
33
+
34
+ To install dependencies:
35
+
36
+ ```bash
37
+ bun install
38
+ ```
39
+
40
+ To run:
41
+
42
+ ```bash
43
+ bun run src/index.ts
44
+ ```
45
+
46
+ This project was created using `bun init` in bun v1.2.19. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "lunet",
3
+ "version": "0.0.0",
4
+ "author": "TNTSuperMan",
5
+ "license": "MIT",
6
+ "description": "より柔軟なWebフロントエンドライブラリ。",
7
+ "module": "src/index.ts",
8
+ "type": "module",
9
+ "devDependencies": {
10
+ "@types/babel__generator": "^7.27.0",
11
+ "@types/babel__traverse": "^7.28.0",
12
+ "@types/bun": "latest",
13
+ "@types/node": "^24.3.0",
14
+ "@types/rollup": "^0.54.0",
15
+ "alien-signals": "^2.0.6",
16
+ "bunup": "^0.9.3"
17
+ },
18
+ "peerDependencies": {
19
+ "typescript": "^5.9.2"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js",
25
+ "browser": "./dist/index.js",
26
+ "import": "./dist/index.js"
27
+ },
28
+ "./transpiler": {
29
+ "types": "./dist/transpiler/index.d.ts",
30
+ "default": "./dist/transpiler/index.js",
31
+ "require": "./dist/transpiler/index.cjs",
32
+ "import": "./dist/transpiler/index.js",
33
+ "node": "./dist/transpiler/index.cjs"
34
+ }
35
+ },
36
+ "bunup": [{
37
+ "name": "lunet",
38
+ "entry": ["src/index.ts"],
39
+ "format": ["esm"],
40
+ "target": "browser",
41
+ "sourcemap": "linked",
42
+ "minify": true,
43
+ "outDir": "dist"
44
+ }, {
45
+ "name": "lunet-transpiler",
46
+ "entry": ["transpiler/index.ts"],
47
+ "external": ["@babel/generator", "@babel/parser", "@babel/traverse", "@babel/types"],
48
+ "format": ["esm", "cjs"],
49
+ "target": "node",
50
+ "minify": true,
51
+ "outDir": "dist/transpiler"
52
+ }],
53
+ "dependencies": {
54
+ "@babel/generator": "^7.28.3",
55
+ "@babel/parser": "^7.28.3",
56
+ "@babel/traverse": "^7.28.3",
57
+ "@babel/types": "^7.28.2"
58
+ }
59
+ }
package/src/batch.ts ADDED
@@ -0,0 +1,3 @@
1
+ export let batch: ((cb: () => void) => void) = cb => cb();
2
+
3
+ export const setBatch = (fn: typeof batch) => batch = fn;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import "./jsx/type/jsx";
2
+
3
+ export * from "./jsx";
4
+ export * from "./mount";
5
+ export { setBatch } from "./batch";
@@ -0,0 +1,7 @@
1
+ import type { JSXComponent, JSXNode } from ".";
2
+
3
+ export type ComponentFunction<T extends object> = (render: (jsx: JSXNode) => void, initProps: T) => T;
4
+ export type Component<T extends object> = (props: T) => JSXComponent;
5
+
6
+ export const createComponent = <T extends object>(component: ComponentFunction<T>): Component<T> =>
7
+ (props: T): JSXComponent => [component, props];
@@ -0,0 +1,4 @@
1
+ import type { JSXFragment, JSXNode } from ".";
2
+
3
+ export const fragment = (...children: JSXNode[]): JSXFragment =>
4
+ [null, {}, ...children];
@@ -0,0 +1,12 @@
1
+ import type { ComponentFunction } from "./component";
2
+
3
+ export type Key = { key?: unknown };
4
+
5
+ export type JSXNode = JSXElement | JSXComponent | JSXFragment | string;
6
+ export type JSXElement<T extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap> = [T, JSX.IntrinsicElements[T], ...JSXNode[]];
7
+ export type JSXComponent = [ComponentFunction<any>, object & Key, ...JSXNode[]];
8
+ export type JSXFragment = [null, Key, ...JSXNode[]];
9
+
10
+ export { type Component, createComponent } from "./component";
11
+ export { fragment } from "./fragment";
12
+ export { h } from "./likereact";
@@ -0,0 +1,16 @@
1
+ import type { JSXNode } from ".";
2
+ import type { Component } from "./component";
3
+ import { fragment } from "./fragment";
4
+
5
+ type JSXFactoryFunction = <T extends keyof HTMLElementTagNameMap | Component<any> | typeof fragment>(
6
+ type: T,
7
+ props: null | ( T extends Component<infer P> ? P : { [key: string]: unknown } ),
8
+ ...children: JSXNode[]
9
+ ) => JSXNode;
10
+
11
+ export const h: JSXFactoryFunction = (type, props, ...children) =>
12
+ typeof type === "string"
13
+ ? [type, props ?? {}, ...children]
14
+ : type === fragment
15
+ ? [null, props ?? {}, ...children]
16
+ : type(props);
@@ -0,0 +1,36 @@
1
+ import type { Key } from "../index";
2
+ import type { HTMLElAttrEvMap } from "./eventmap";
3
+
4
+ export type Attributes<N extends keyof HTMLElementTagNameMap, T extends object = {}> = Partial<
5
+ HTMLAttributes & T & Key & {
6
+ [key in keyof HTMLElAttrEvMap]:
7
+ (this: HTMLElement, ev: HTMLElAttrEvMap[key]) => unknown;
8
+ } & {
9
+ $beforeMount: () => unknown;
10
+ $mount: (this: HTMLElement, ev: CustomEvent<HTMLElementTagNameMap[N]>) => unknown;
11
+ $beforeUpdate: (this: HTMLElement, ev: CustomEvent<HTMLElementTagNameMap[N]>) => unknown;
12
+ $update: (this: HTMLElement, ev: CustomEvent<HTMLElementTagNameMap[N]>) => unknown;
13
+ $beforeUnmount: (this: HTMLElement, ev: CustomEvent<HTMLElementTagNameMap[N]>) => unknown;
14
+ $unmount: () => unknown;
15
+ }
16
+ >;
17
+
18
+ type HTMLAttributes = {
19
+ accesskey: string;
20
+ autocapitalize: "off" | "none" | "on" | "sentences" | "words" | "characters";
21
+ class: string;
22
+ contenteditable: boolean | "" | "plaintext-only";
23
+ dir: "ltr" | "rtl";
24
+ draggable: boolean;
25
+ hidden: "" | "hidden" | "until-found";
26
+ id: string;
27
+ itemprop: string;
28
+ lang: string;
29
+ role: string;
30
+ slot: string;
31
+ spellcheck: boolean;
32
+ style: string;
33
+ tabindex: number;
34
+ title: string;
35
+ translate: "yes" | "no";
36
+ }
@@ -0,0 +1,107 @@
1
+ export interface HTMLElAttrEvMap{
2
+ "$fullscreenchange": Event;
3
+ "$fullscreenerror": Event;
4
+ "$abort": UIEvent;
5
+ "$animationcancel": AnimationEvent;
6
+ "$animationend": AnimationEvent;
7
+ "$animationiteration": AnimationEvent;
8
+ "$animationstart": AnimationEvent;
9
+ "$auxclick": PointerEvent;
10
+ "$beforeinput": InputEvent;
11
+ "$beforetoggle": ToggleEvent;
12
+ "$blur": FocusEvent;
13
+ "$cancel": Event;
14
+ "$canplay": Event;
15
+ "$canplaythrough": Event;
16
+ "$change": Event;
17
+ "$click": MouseEvent;
18
+ "$close": Event;
19
+ "$compositionend": CompositionEvent;
20
+ "$compositionstart": CompositionEvent;
21
+ "$compositionupdate": CompositionEvent;
22
+ "$contextlost": Event;
23
+ "$contextmenu": PointerEvent;
24
+ "$contextrestored": Event;
25
+ "$copy": ClipboardEvent;
26
+ "$cuechange": Event;
27
+ "$cut": ClipboardEvent;
28
+ "$dblclick": MouseEvent;
29
+ "$drag": DragEvent;
30
+ "$dragend": DragEvent;
31
+ "$dragenter": DragEvent;
32
+ "$dragleave": DragEvent;
33
+ "$dragover": DragEvent;
34
+ "$dragstart": DragEvent;
35
+ "$drop": DragEvent;
36
+ "$durationchange": Event;
37
+ "$emptied": Event;
38
+ "$ended": Event;
39
+ "$error": ErrorEvent;
40
+ "$focus": FocusEvent;
41
+ "$focusin": FocusEvent;
42
+ "$focusout": FocusEvent;
43
+ "$formdata": FormDataEvent;
44
+ "$gotpointercapture": PointerEvent;
45
+ "$input": Event;
46
+ "$invalid": Event;
47
+ "$keydown": KeyboardEvent;
48
+ "$keypress": KeyboardEvent;
49
+ "$keyup": KeyboardEvent;
50
+ "$load": Event;
51
+ "$loadeddata": Event;
52
+ "$loadedmetadata": Event;
53
+ "$loadstart": Event;
54
+ "$lostpointercapture": PointerEvent;
55
+ "$mousedown": MouseEvent;
56
+ "$mouseenter": MouseEvent;
57
+ "$mouseleave": MouseEvent;
58
+ "$mousemove": MouseEvent;
59
+ "$mouseout": MouseEvent;
60
+ "$mouseover": MouseEvent;
61
+ "$mouseup": MouseEvent;
62
+ "$paste": ClipboardEvent;
63
+ "$pause": Event;
64
+ "$play": Event;
65
+ "$playing": Event;
66
+ "$pointercancel": PointerEvent;
67
+ "$pointerdown": PointerEvent;
68
+ "$pointerenter": PointerEvent;
69
+ "$pointerleave": PointerEvent;
70
+ "$pointermove": PointerEvent;
71
+ "$pointerout": PointerEvent;
72
+ "$pointerover": PointerEvent;
73
+ "$pointerup": PointerEvent;
74
+ "$progress": ProgressEvent;
75
+ "$ratechange": Event;
76
+ "$reset": Event;
77
+ "$resize": UIEvent;
78
+ "$scroll": Event;
79
+ "$scrollend": Event;
80
+ "$securitypolicyviolation": SecurityPolicyViolationEvent;
81
+ "$seeked": Event;
82
+ "$seeking": Event;
83
+ "$select": Event;
84
+ "$selectionchange": Event;
85
+ "$selectstart": Event;
86
+ "$slotchange": Event;
87
+ "$stalled": Event;
88
+ "$submit": SubmitEvent;
89
+ "$suspend": Event;
90
+ "$timeupdate": Event;
91
+ "$toggle": ToggleEvent;
92
+ "$touchcancel": TouchEvent;
93
+ "$touchend": TouchEvent;
94
+ "$touchmove": TouchEvent;
95
+ "$touchstart": TouchEvent;
96
+ "$transitioncancel": TransitionEvent;
97
+ "$transitionend": TransitionEvent;
98
+ "$transitionrun": TransitionEvent;
99
+ "$transitionstart": TransitionEvent;
100
+ "$volumechange": Event;
101
+ "$waiting": Event;
102
+ "$webkitanimationend": Event;
103
+ "$webkitanimationiteration": Event;
104
+ "$webkitanimationstart": Event;
105
+ "$webkittransitionend": Event;
106
+ "$wheel": WheelEvent;
107
+ }
@@ -0,0 +1,62 @@
1
+ import type { Attributes } from "./attributes";
2
+ import type { JSXNode } from "../index";
3
+
4
+ type Target = "_self" | "_blank" | "_parent" | "_top";
5
+
6
+ declare global {
7
+ namespace JSX {
8
+ type Element = JSXNode;
9
+ type IntrinsicElements = {
10
+ [key in keyof HTMLElementTagNameMap]: Attributes<key>;
11
+ } & {
12
+ div: Attributes<"div">;
13
+ ul: Attributes<"ul">;
14
+ ol: Attributes<"ol">;
15
+ li: Attributes<"li">;
16
+ br: Attributes<"br">;
17
+ button: Attributes<"button">;
18
+ a: Attributes<"a", {
19
+ href: string;
20
+ download: string;
21
+ target: Target;
22
+ hreflang: string;
23
+ media: string;
24
+ ping: string;
25
+ referrerpolicy: string;
26
+ rel: string;
27
+ shape: string;
28
+ }>;
29
+ input: Attributes<"input", {
30
+ accept: string;
31
+ alt: string;
32
+ autocomplete: "off" | "on" | string;
33
+ checked: boolean;
34
+ disabled: boolean;
35
+ formonvalidate: boolean;
36
+ formtarget: Target;
37
+ type: "button" | "checkbox" | "color" | "date" | "datetime-local" | "email" | "file" | "hidden" | "image" | "month" | "number" | "password" | "radio" | "range" | "reset" | "search" | "submit" | "tel" | "text" | "time" | "url" | "week";
38
+ value: string;
39
+ }>;
40
+ form: Attributes<"form", {
41
+ "accept-charset": "UTF-8";
42
+ autocomplete: "off" | "on";
43
+ name: string;
44
+ enctype: "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain";
45
+ method: "post" | "get" | "dalog";
46
+ target: Target | "_unfencedTop";
47
+ }>;
48
+ iframe: Attributes<"iframe", {
49
+ allow: string;
50
+ allowfullscreen: boolean;
51
+ width: number;
52
+ height: number;
53
+ loading: "eager" | "lazy";
54
+ name: string;
55
+ referrerpolicy: "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-orgin" | "usafe-url";
56
+ sandbox: string;
57
+ src: string;
58
+ srcdoc: string;
59
+ }>;
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,38 @@
1
+ import type { JSXNode } from "../jsx";
2
+
3
+ export const isCompatibleNode = (before: JSXNode, after: JSXNode): boolean =>
4
+ typeof before === "string" || typeof after === "string"
5
+ ? typeof before === typeof after
6
+ : before[0] === after[0] && before[1].key === after[1].key;
7
+
8
+ export type Patch =
9
+ | [0, number, JSXNode] // 更新
10
+ | [1, number, JSXNode] // 挿入
11
+ | [2, number, JSXNode] // 削除
12
+
13
+ export const diff = (
14
+ before_nodes: JSXNode[],
15
+ after_nodes: JSXNode[],
16
+ ): Patch[] => {
17
+ const patches: Patch[] = [];
18
+ const max = Math.max(before_nodes.length, after_nodes.length);
19
+
20
+ for(let i = 0;i < max;i++){
21
+ const before = before_nodes[i];
22
+ const after = after_nodes[i];
23
+
24
+ if(!before && after)
25
+ patches.push([1, i, after]);
26
+ else if(before && !after)
27
+ patches.push([2, i, after]);
28
+ else if(before && after)
29
+ if(isCompatibleNode(before, after))
30
+ patches.push([0, i, after]);
31
+ else{
32
+ patches.push([2, i, after]);
33
+ patches.push([1, i, after]);
34
+ }
35
+ }
36
+
37
+ return patches;
38
+ }
@@ -0,0 +1,47 @@
1
+ import { type RenderedDOM } from ".";
2
+ import { batch } from "../../batch";
3
+ import type { JSXComponent, JSXFragment, JSXNode } from "../../jsx";
4
+ import { renderFragment } from "./fragment";
5
+
6
+ export const renderComponent = (jsx: JSXComponent): RenderedDOM<JSXComponent> => {
7
+ let currentJSX = jsx;
8
+
9
+ let rendered_dom = null as RenderedDOM<JSXFragment> | null;
10
+
11
+ let props: { [key: string]: unknown } | null;
12
+
13
+ const render = (jsx: JSXNode) => {
14
+ if(rendered_dom) rendered_dom.update([null, {}, jsx]);
15
+ else rendered_dom = renderFragment([null, {}, jsx]);
16
+ }
17
+
18
+ return {
19
+ type: 3,
20
+ flat: () => rendered_dom?.flat() ?? [],
21
+ update(jsx){
22
+ const [, afterProps/*, ...children*/] = currentJSX = jsx;
23
+
24
+ batch(() => {
25
+ for (const [key, value] of Object.entries(afterProps))
26
+ if (props![key] !== value)
27
+ props![key] = value;
28
+ });
29
+ },
30
+ render(){
31
+ rendered_dom?.revoke();
32
+ rendered_dom = null;
33
+
34
+ const [component, init_props/*, ...children*/] = currentJSX;
35
+
36
+ props = component(render, { ...init_props });
37
+
38
+ if(!rendered_dom) {
39
+ console.error("never rendered Initial render.");
40
+ rendered_dom = renderFragment([null, {}]);
41
+ }
42
+
43
+ return rendered_dom.render();
44
+ },
45
+ revoke(){ rendered_dom?.revoke() },
46
+ }
47
+ }
@@ -0,0 +1,142 @@
1
+ import { renderNode, type RenderedDOM, type UnknownRenderedDOM } from ".";
2
+ import type { JSXElement, JSXNode } from "../../jsx";
3
+ import { revokerMap } from "../revokerMap";
4
+ import { diff } from "../diff";
5
+
6
+ const elementEvents: WeakMap<HTMLElement, Record<string, Function>> = new WeakMap;
7
+
8
+ function handleEvent(this: HTMLElement, ev: Event){
9
+ return elementEvents.get(this)?.[ev.type]?.();
10
+ }
11
+
12
+ const setAttribute = (el: HTMLElement, name: string, value: unknown) => {
13
+ if(el instanceof HTMLInputElement) {
14
+ switch(name){
15
+ case "checked":
16
+ if(typeof value === "boolean"){
17
+ el.checked = value;
18
+ return;
19
+ }
20
+ break;
21
+ case "value":
22
+ if(typeof value === "string"){
23
+ el.value = value;
24
+ return;
25
+ }
26
+ break;
27
+ }
28
+ }
29
+ switch(typeof value){
30
+ case "string":
31
+ el.setAttribute(name, value);
32
+ break;
33
+ case "function":
34
+ if(name.startsWith("$")){
35
+ if(!["$beforeMount", "$mount", "$beforeUnmount", "$unmount"].includes(name)){
36
+ const ev_name = name.substring(1);
37
+ const events = elementEvents.get(el)!;
38
+ if(!(ev_name in events))
39
+ el.addEventListener(ev_name, handleEvent);
40
+ events[ev_name] = value;
41
+ }
42
+ }else
43
+ console.error("function values cannot mount on attributes.");
44
+ break;
45
+ case "object":
46
+ if(value === null)
47
+ el.removeAttribute(name);
48
+ else
49
+ console.error(`${typeof value} values cannot mount on attributes.`);
50
+ break;
51
+ default:
52
+ if(value === undefined)
53
+ el.removeAttribute(name);
54
+ else
55
+ el.setAttribute(name, String(value));
56
+ break;
57
+ }
58
+ }
59
+
60
+ export const renderElement = (jsx: JSXElement): RenderedDOM<JSXElement> => {
61
+ let currentJSX = jsx;
62
+
63
+ let rendered_children: UnknownRenderedDOM[] = [];
64
+ let element: HTMLElement | void;
65
+
66
+ const render = () => {
67
+ const [tag, props, ...children] = currentJSX;
68
+
69
+ props.$beforeMount?.();
70
+ const el = document.createElement(tag);
71
+ elementEvents.set(el, {});
72
+ props.$mount?.call(el, new CustomEvent("mount", { detail: el as any }));
73
+
74
+ for (const [name, value] of Object.entries(props))
75
+ setAttribute(el, name, value);
76
+
77
+ rendered_children = children.map(renderNode);
78
+ el.append(...rendered_children.map(e=>e.render()));
79
+
80
+ revokerMap.set(el, () => {
81
+ props.$beforeUnmount?.call(el, new CustomEvent("beforeunmount", { detail: el as any }));
82
+
83
+ for (const child of rendered_children)
84
+ child.revoke();
85
+ revokerMap.delete(el);
86
+ elementEvents.delete(el);
87
+ el.remove();
88
+
89
+ props.$unmount?.();
90
+ });
91
+
92
+ return element = el;
93
+ }
94
+
95
+ return {
96
+ type: 1,
97
+ flat: () => [currentJSX],
98
+ update(jsx){
99
+ const [,, ...old_children] = currentJSX;
100
+ const [, props, ...new_children] = jsx;
101
+
102
+ for (const [name, value] of Object.entries(props))
103
+ if((currentJSX[1] as any)[name] !== value)
104
+ setAttribute(element!, name, value);
105
+
106
+ const patches = diff(old_children, new_children);
107
+ let removes = 0;
108
+ for (const [type, idx_, jsx] of patches) {
109
+ const idx = idx_ - removes;
110
+ switch(type){
111
+ case 0:
112
+ rendered_children[idx].update(jsx as any);
113
+ break;
114
+ case 1:
115
+ const rendered = renderNode(jsx);
116
+ rendered_children.splice(idx, 0, rendered);
117
+
118
+ const dom_index = rendered_children.reduce((p,c)=>p+c.flat().length, 0);
119
+ const el = rendered.render();
120
+
121
+ if(dom_index >= element!.childNodes.length)
122
+ element!.append(el);
123
+ else
124
+ element!.childNodes[dom_index]!.before(el);
125
+
126
+ break;
127
+ case 2:
128
+ removes++;
129
+ rendered_children.splice(idx, 1)[0].revoke();
130
+ break;
131
+ }
132
+ }
133
+
134
+ currentJSX = jsx;
135
+ },
136
+ render(){
137
+ element && revokerMap.get(element)?.();
138
+ return render();
139
+ },
140
+ revoke(){ element && revokerMap.get(element)?.() },
141
+ }
142
+ }
@@ -0,0 +1,76 @@
1
+ import { renderNode, type RenderedDOM, type UnknownRenderedDOM } from ".";
2
+ import type { JSXFragment } from "../../jsx";
3
+ import { diff } from "../diff";
4
+
5
+ export const renderFragment = (jsx: JSXFragment): RenderedDOM<JSXFragment> => {
6
+ let currentJSX = jsx;
7
+
8
+ let rendered_children: UnknownRenderedDOM[] = [];
9
+ let mark: Comment | void;
10
+
11
+ return {
12
+ type: 2,
13
+ flat: () => rendered_children.flatMap(e=>e.flat()),
14
+ update(jsx){
15
+ const [,, ...old_children] = currentJSX;
16
+ const [,, ...new_children] = jsx;
17
+
18
+ const patches = diff(old_children, new_children);
19
+ let removes = 0;
20
+
21
+ for (const [type, idx_, jsx] of patches) {
22
+ const idx = idx_ - removes;
23
+ switch(type){
24
+ case 0:
25
+ rendered_children[idx].update(jsx as any);
26
+ break;
27
+ case 1:
28
+ const rendered = renderNode(jsx);
29
+ rendered_children.splice(idx, 0, rendered);
30
+ const dom = rendered.render();
31
+ let refNode: ChildNode | null = null; // TODO: ここら辺を最適化する
32
+ if (mark && mark.parentNode) {
33
+ let node: ChildNode | null = mark.nextSibling;
34
+ let count = 0;
35
+ while (node) {
36
+ if (count === idx) {
37
+ refNode = node;
38
+ break;
39
+ }
40
+ node = node.nextSibling;
41
+ count++;
42
+ }
43
+ if (refNode) {
44
+ mark.parentNode.insertBefore(dom, refNode);
45
+ } else {
46
+ mark.parentNode.appendChild(dom);
47
+ }
48
+ }
49
+ break;
50
+ case 2:
51
+ removes++;
52
+ const [removed] = rendered_children.splice(idx, 1);
53
+ removed.revoke();
54
+ break;
55
+ }
56
+ }
57
+
58
+ currentJSX = jsx;
59
+ },
60
+ render(){
61
+ const [,, ...children] = currentJSX;
62
+
63
+ const el = document.createDocumentFragment();
64
+ mark = document.createComment("");
65
+
66
+ rendered_children = children.map(renderNode);
67
+ el.append(mark, ...rendered_children.map(e=>e.render()));
68
+
69
+ return el;
70
+ },
71
+ revoke(){
72
+ for (const child of rendered_children)
73
+ child.revoke();
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,27 @@
1
+ import type { JSXComponent, JSXElement, JSXFragment, JSXNode } from "../../jsx";
2
+ import { renderComponent } from "./component";
3
+ import { renderElement } from "./element";
4
+ import { renderFragment } from "./fragment";
5
+ import { renderText } from "./text";
6
+
7
+ export type RenderedDOM<T extends JSXNode> = {
8
+ type: T extends string ? 0 : T extends JSXElement ? 1 : T extends JSXFragment ? 2 : T extends JSXComponent ? 3 : never, // 0 種類
9
+ flat(): (JSXElement | string)[], // 1 差分比較用のフラットJSX出力関数
10
+ update(jsx: T): void, // 2 差分更新関数
11
+ render(): Node, // 3 初回・トラブル時にフル描画をする関数
12
+ revoke(): void, // 4 破棄関数
13
+ }
14
+
15
+ export type UnknownRenderedDOM = RenderedDOM<string> | RenderedDOM<JSXElement> | RenderedDOM<JSXFragment> | RenderedDOM<JSXComponent>;
16
+
17
+ export const renderNode = (jsx: JSXNode): UnknownRenderedDOM => {
18
+ if(typeof jsx === "string"){
19
+ return renderText(jsx);
20
+ }else if(typeof jsx[0] === "string"){
21
+ return renderElement(jsx);
22
+ }else if(jsx[0] === null){
23
+ return renderFragment(jsx);
24
+ }else{
25
+ return renderComponent(jsx);
26
+ }
27
+ }
@@ -0,0 +1,14 @@
1
+ import type { RenderedDOM } from ".";
2
+
3
+ export const renderText = (jsx: string): RenderedDOM<string> => {
4
+ let currentText = jsx;
5
+ const node = new Text(currentText);
6
+
7
+ return {
8
+ type: 0,
9
+ flat: () => [currentText],
10
+ update(jsx){ currentText !== jsx && (node.textContent = currentText = jsx) },
11
+ render: () => node,
12
+ revoke(){ node.remove() },
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { JSXNode } from "../jsx";
2
+ import { renderNode } from "./dom";
3
+ import { revokerMap } from "./revokerMap";
4
+
5
+ type RenderFunction = (el: HTMLElement, jsx: JSXNode) => void;
6
+
7
+ export const render: RenderFunction = (el, jsx) => {
8
+ el.childNodes.forEach(e=>{
9
+ revokerMap.get(e)?.();
10
+ e.remove();
11
+ });
12
+
13
+ el.append(renderNode(jsx).render());
14
+ }
@@ -0,0 +1 @@
1
+ export const revokerMap = new WeakMap<Node, () => void>();
@@ -0,0 +1,50 @@
1
+ import { readFile as NodeReadFile } from "fs/promises";
2
+ import { readFileSync } from "fs";
3
+ import { transpile } from "./transpile";
4
+
5
+ import type { BunPlugin } from "bun";
6
+ import type { Plugin as RollupPlugin } from "rollup";
7
+
8
+ const readFile: (path: string) => Promise<string> =
9
+ process.isBun
10
+ ? path => Bun.file(path).text()
11
+ : path => NodeReadFile(path).then(e=>e.toString());
12
+
13
+ export const bun_lunet = (): BunPlugin => ({
14
+ name: "bun-lunet",
15
+ setup(build) {
16
+ build.onLoad({ filter: /\.jsx$/ }, async args => {
17
+ const code = await readFile(args.path);
18
+ return ({
19
+ contents: transpile(code, false),
20
+ loader: "jsx"
21
+ });
22
+ });
23
+ build.onLoad({ filter: /\.tsx$/ }, async args => {
24
+ const code = await readFile(args.path);
25
+ return ({
26
+ contents: transpile(code, true),
27
+ loader: "tsx"
28
+ });
29
+ });
30
+ },
31
+ })
32
+
33
+ export const rollup_lunet = (): RollupPlugin => ({
34
+ name: "rollup-lunet",
35
+ load(id){
36
+ if(id.endsWith(".jsx")){
37
+ const code = readFileSync(id).toString();
38
+ return {
39
+ code: transpile(code, false)
40
+ };
41
+ }else if(id.endsWith(".tsx")){
42
+ const code = readFileSync(id).toString();
43
+ return {
44
+ code: transpile(code, true)
45
+ };
46
+ }
47
+ }
48
+ })
49
+
50
+ export { transpile };
@@ -0,0 +1,122 @@
1
+ import { parse } from "@babel/parser";
2
+ import traverse from "@babel/traverse";
3
+ import generate from "@babel/generator";
4
+ import * as t from "@babel/types";
5
+
6
+ const jsxAttr2Object = (
7
+ ast: (t.JSXAttribute | t.JSXSpreadAttribute)[],
8
+ ): t.ObjectExpression => t.objectExpression(ast.map(e=>
9
+ e.type === "JSXAttribute"
10
+ ? t.objectProperty(
11
+ t.identifier(
12
+ e.name.type === "JSXIdentifier"
13
+ ? e.name.name
14
+ : e.name.name.name),
15
+ e.value
16
+ ? e.value.type === "StringLiteral"
17
+ ? e.value
18
+ : jsx2Expression(e.value, true)
19
+ : t.booleanLiteral(true))
20
+ : t.spreadElement(e.argument)
21
+ ));
22
+
23
+ const jsxMemberToMemberExpression = (ast: t.JSXMemberExpression | t.JSXIdentifier): t.MemberExpression | t.Identifier =>
24
+ ast.type === "JSXIdentifier"
25
+ ? t.identifier(ast.name)
26
+ : t.memberExpression(jsxMemberToMemberExpression(ast.object), jsxMemberToMemberExpression(ast.property));
27
+
28
+ const filterEmptyString = (ast: t.Expression | t.SpreadElement) => !(ast.type === "StringLiteral" && !ast.value);
29
+
30
+ const jsx2Elements = (
31
+ ast: ReturnType<typeof t.jsxFragment>["children"] extends (infer P)[] ? P : never,
32
+ dontTrim?: boolean
33
+ ): t.Expression | t.SpreadElement => {
34
+ if(ast.type === "JSXSpreadChild")
35
+ return t.spreadElement(ast.expression);
36
+ else
37
+ return jsx2Expression(ast, dontTrim);
38
+ }
39
+
40
+ const jsx2Expression = (
41
+ ast: ReturnType<typeof t.jsxFragment>["children"] extends (infer P)[] ? P : never,
42
+ dontTrim?: boolean
43
+ ): t.Expression => {
44
+ switch(ast.type){
45
+ case "JSXText": return t.stringLiteral(dontTrim ? ast.value : ast.value.trim());
46
+ case "JSXFragment": return t.arrayExpression([
47
+ t.nullLiteral(),
48
+ t.objectExpression([]),
49
+ ...ast.children.map(child=>jsx2Elements(child)).filter(filterEmptyString)
50
+ ]);
51
+ case "JSXElement":
52
+ const tag = ast.openingElement.name;
53
+ if(tag.type === "JSXIdentifier" && /^[a-z]/.test(tag.name)){
54
+ return t.arrayExpression([
55
+ t.stringLiteral(tag.name),
56
+ t.objectExpression(ast.openingElement.attributes.map(attr => {
57
+ if (attr.type === "JSXAttribute") {
58
+ if (attr.name.type === "JSXNamespacedName")
59
+ console.warn("Warning: JSXNamespacedName is not supported");
60
+ const { name } = attr.name;
61
+ return t.objectProperty(
62
+ t.identifier(typeof name === "string" ? name : name.name),
63
+ attr.value
64
+ ? attr.value.type === "StringLiteral"
65
+ ? attr.value
66
+ : jsx2Expression(attr.value)
67
+ : t.booleanLiteral(true)
68
+ );
69
+ } else {
70
+ return t.spreadElement(attr.argument);
71
+ }
72
+ })),
73
+ ...ast.children.map(child => jsx2Elements(child)).filter(filterEmptyString)
74
+ ])
75
+ }else switch(tag.type){
76
+ case "JSXIdentifier":
77
+ return t.callExpression(t.identifier(tag.name), [
78
+ jsxAttr2Object(ast.openingElement.attributes)
79
+ ]);
80
+ case "JSXMemberExpression":
81
+ return t.callExpression(jsxMemberToMemberExpression(tag), [
82
+ jsxAttr2Object(ast.openingElement.attributes)
83
+ ]);
84
+ case "JSXNamespacedName":
85
+ console.warn("Warning: JSXNamespacedName is not supported");
86
+ return t.callExpression(t.identifier(tag.name.name), [
87
+ jsxAttr2Object(ast.openingElement.attributes)
88
+ ]);
89
+ }
90
+ case "JSXExpressionContainer": switch(ast.expression.type){
91
+ case "JSXEmptyExpression":
92
+ return t.booleanLiteral(true);
93
+ case "JSXElement": case "JSXFragment":
94
+ return jsx2Expression(ast.expression);
95
+ default:
96
+ return ast.expression;
97
+ }
98
+ case "JSXSpreadChild": return t.arrayExpression([
99
+ t.nullLiteral(),
100
+ t.objectExpression([]),
101
+ t.spreadElement(ast.expression)
102
+ ]);
103
+ }
104
+ }
105
+
106
+ export const transpile = (code: string, isTypeScript: boolean): string => {
107
+ const ast = parse(code, {
108
+ sourceType: "module",
109
+ plugins: isTypeScript ? ["typescript", "jsx"] : ["jsx"]
110
+ });
111
+
112
+ traverse(ast, {
113
+ JSXElement(ast){
114
+ ast.replaceWith(jsx2Expression(ast.node));
115
+ },
116
+ JSXFragment(ast){
117
+ ast.replaceWith(jsx2Expression(ast.node));
118
+ }
119
+ });
120
+
121
+ return generate(ast).code;
122
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react",
9
+ "jsxFactory": "h",
10
+ "jsxFragmentFactory": "fragment",
11
+ "allowJs": true,
12
+
13
+ // Bundler mode
14
+ "moduleResolution": "bundler",
15
+ "allowImportingTsExtensions": true,
16
+ "verbatimModuleSyntax": true,
17
+ "noEmit": true,
18
+
19
+ // Best practices
20
+ "strict": true,
21
+ "skipLibCheck": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noImplicitOverride": true,
24
+
25
+ // Some stricter flags (disabled by default)
26
+ "noUnusedLocals": false,
27
+ "noUnusedParameters": false,
28
+ "noPropertyAccessFromIndexSignature": false
29
+ },
30
+ "exclude": ["dist"]
31
+ }