creo 0.0.3-dev → 0.0.4-dev

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.
Files changed (48) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/main.yml +24 -0
  3. package/README.md +1 -1
  4. package/TODOS.md +2 -0
  5. package/index.ts +1 -0
  6. package/package.json +7 -2
  7. package/src/DOM/Context.ts +36 -0
  8. package/src/DOM/DomEngine.ts +106 -0
  9. package/src/DOM/IRenderCycle.ts +9 -0
  10. package/src/DOM/Key.ts +1 -0
  11. package/src/DOM/Node.ts +472 -0
  12. package/src/DOM/Registry.ts +53 -0
  13. package/src/creo.ts +134 -0
  14. package/src/data-structures/assert/assert.ts +12 -0
  15. package/src/data-structures/indexed-map/IndexedMap.ts +281 -0
  16. package/src/data-structures/linked-map/LinkedMap.spec.ts +67 -0
  17. package/src/data-structures/linked-map/LinkedMap.ts +198 -0
  18. package/src/data-structures/list/List.spec.ts +181 -0
  19. package/src/data-structures/list/List.ts +195 -0
  20. package/src/data-structures/maybe/Maybe.ts +25 -0
  21. package/src/data-structures/null/null.ts +3 -0
  22. package/src/{tools/isRecordLike.spec.ts → data-structures/record/IsRecordLike.spec.ts} +1 -1
  23. package/src/{tools/isRecordLike.ts → data-structures/record/IsRecordLike.ts} +1 -1
  24. package/src/{record/record.spec.ts → data-structures/record/Record.spec.ts} +96 -2
  25. package/src/data-structures/record/Record.ts +145 -0
  26. package/src/data-structures/shalllowEqual/shallowEqual.ts +26 -0
  27. package/src/data-structures/simpleKey/simpleKey.ts +8 -0
  28. package/src/data-structures/wildcard/wildcard.ts +1 -0
  29. package/src/examples/SimpleTodoList/SimpleTodoList.ts +53 -0
  30. package/src/globals.d.ts +1 -0
  31. package/src/main.ts +22 -11
  32. package/src/style.css +24 -79
  33. package/src/ui/html/Block.ts +10 -0
  34. package/src/ui/html/Button.ts +12 -0
  35. package/src/ui/html/HStack.ts +10 -0
  36. package/src/ui/html/Inline.ts +12 -0
  37. package/src/ui/html/List.ts +10 -0
  38. package/src/ui/html/Text.ts +9 -0
  39. package/src/ui/html/VStack.ts +11 -0
  40. package/tsconfig.json +2 -2
  41. package/vite.config.js +10 -0
  42. package/bun.lockb +0 -0
  43. package/src/record/record.ts +0 -101
  44. package/src/tools/optional.ts +0 -25
  45. package/src/ui/component.ts +0 -1
  46. package/src/ui/prop.ts +0 -13
  47. package/src/ui/state.ts +0 -0
  48. /package/src/{ui/index.ts → examples/simple.ts} +0 -0
@@ -1,14 +1,43 @@
1
- import { expect, test } from "bun:test";
2
- import { record, onDidUpdate } from "./record";
1
+ import { expect, test, mock } from "bun:test";
2
+ import { record, RecordOf, onDidUpdate, isRecord } from "./Record";
3
3
 
4
4
  test("Can define objects", () => {
5
5
  const obj = record({
6
6
  hello: "world",
7
7
  });
8
8
 
9
+ // @ts-ignore
9
10
  expect(obj).toEqual({ hello: "world" });
10
11
  });
11
12
 
13
+ test("Refers to the same objects", () => {
14
+ const test = record({
15
+ foo: {
16
+ bar: "baz",
17
+ },
18
+ });
19
+
20
+ expect(test.foo).toBe(test.foo);
21
+ });
22
+
23
+ test("Updates fields correctly", () => {
24
+ const test: RecordOf<any> = record({
25
+ foo: {
26
+ bar: "baz",
27
+ },
28
+ });
29
+
30
+ expect(test.foo.bar).toBe("baz");
31
+
32
+ test.foo.bar = "123";
33
+
34
+ expect(test.foo.bar).toBe("123");
35
+
36
+ test.foo = { hello: "world" };
37
+ expect(test.foo.bar).toBe(undefined);
38
+ expect(test.foo.hello).toBe("world");
39
+ });
40
+
12
41
  test("Notifies on object updates", async () => {
13
42
  const obj = record({
14
43
  hello: "world",
@@ -48,6 +77,7 @@ test("Implies updates immediately", async () => {
48
77
  },
49
78
  });
50
79
 
80
+ // @ts-ignore
51
81
  expect(obj).toEqual({
52
82
  hello: {
53
83
  world: "foo",
@@ -56,6 +86,7 @@ test("Implies updates immediately", async () => {
56
86
 
57
87
  obj.hello.world = "new";
58
88
 
89
+ // @ts-ignore
59
90
  expect(obj).toEqual({
60
91
  hello: {
61
92
  world: "new",
@@ -144,3 +175,66 @@ test("Supports iterable", async () => {
144
175
 
145
176
  iterate(...obj);
146
177
  });
178
+
179
+ test("`has` works with records", async () => {
180
+ const originalObject = {
181
+ foo: "bar",
182
+ baz: "test",
183
+ nested: {
184
+ support: "exist",
185
+ },
186
+ };
187
+ const wrapped = record(originalObject);
188
+
189
+ expect(isRecord(originalObject)).toBe(false);
190
+ expect(isRecord(wrapped)).toBe(true);
191
+ expect("foo" in wrapped).toBe(true);
192
+ expect("test" in wrapped).toBe(false);
193
+ expect("support" in wrapped.nested).toBe(true);
194
+ expect("foo" in wrapped.nested).toBe(false);
195
+ });
196
+
197
+ test("Double tracked on nested object works correctly", async () => {
198
+ const originalObject = {
199
+ foo: "bar",
200
+ baz: "test",
201
+ nested: {
202
+ support: "exist",
203
+ },
204
+ };
205
+
206
+ // This object is not tracked under the same parent, but might be considered in future to allow better objects and record composition.
207
+ // It would require objects to have multiple parents (so essentially many<=>many concept.)
208
+ const additionalObject = {
209
+ nested: originalObject.nested,
210
+ foo: "123",
211
+ hello: "234",
212
+ };
213
+
214
+ const originalWrappped = record(originalObject);
215
+
216
+ const additionalWrapped = record(additionalObject);
217
+
218
+ const recordedNested = record(originalWrappped.nested);
219
+
220
+ const mockFn = mock();
221
+
222
+ onDidUpdate(originalWrappped, () => {
223
+ mockFn();
224
+ expect(originalObject.nested).toEqual(originalWrappped.nested);
225
+ });
226
+ onDidUpdate(additionalWrapped, () => {
227
+ // We should never hit that path
228
+ expect(1).toBe(0);
229
+ mockFn();
230
+ });
231
+ onDidUpdate(recordedNested, () => {
232
+ mockFn();
233
+ // @ts-ignore
234
+ expect(recordedNested).toEqual({ support: "updated" });
235
+ });
236
+ originalWrappped.nested.support = "updated";
237
+ // Updates are propagated in microtick queue, so we wait single tick
238
+ await Promise.resolve();
239
+ expect(mockFn).toHaveBeenCalledTimes(2);
240
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Ideas:
3
+ * [x] didUpdate support
4
+ * [x] Proxy proxifies all children as well
5
+ * [x] Support nested updates + nested listeners (e.g. only part of the object)
6
+ * [x] Cache records
7
+ * [ ] Keep track on updates, until there are no users on the old state
8
+ * [ ] Support symbol iterator
9
+ * [ ] Add js dispose tracker to automatically close listeners
10
+ * [ ] Allow to "stop propagate" changes if needed (or allow catching changes only on top level)
11
+ */
12
+
13
+ import { Maybe } from "../maybe/Maybe";
14
+ import { Wildcard } from "../wildcard/wildcard";
15
+ import { isRecordLike } from "./IsRecordLike";
16
+
17
+ // #region Record Type
18
+ const ParentRecord = Symbol("parent-record");
19
+ // const example: RecordOf<{foo: 'bar'}> = {
20
+ // foo: 'bar',
21
+ // [ParentRecord]: null // Root record
22
+ // }
23
+ export type RecordOf<T extends object> = T & {
24
+ [ParentRecord]: Maybe<WeakRef<RecordOf<Wildcard>>>;
25
+ };
26
+ type RecordDidChangeListener<T extends object> = (record: RecordOf<T>) => void;
27
+
28
+ export function isRecord<T extends object>(value: T): value is RecordOf<T> {
29
+ return ParentRecord in value;
30
+ }
31
+
32
+ // #region Weak map for listenets
33
+ const didUpdateMap: WeakMap<
34
+ RecordOf<Wildcard>,
35
+ Set<RecordDidChangeListener<Wildcard>>
36
+ > = new WeakMap();
37
+
38
+ // #region Update notifier
39
+ const scheduledUpdatesNotifiers: Set<RecordOf<Wildcard>> = new Set();
40
+ let shouldScheduleMicrotask = true;
41
+ function queuedNotifier() {
42
+ function iterate(record: RecordOf<Wildcard>) {
43
+ const listeners = didUpdateMap.get(record);
44
+ listeners?.forEach((listener) => {
45
+ listener(record);
46
+ });
47
+ const maybeParent: Maybe<RecordOf<Wildcard>> = record[ParentRecord];
48
+ if (maybeParent) {
49
+ iterate(maybeParent);
50
+ }
51
+ }
52
+ shouldScheduleMicrotask = true;
53
+ scheduledUpdatesNotifiers.forEach(iterate);
54
+ }
55
+ function recordDidUpdate<T extends object>(record: RecordOf<T>) {
56
+ scheduledUpdatesNotifiers.add(record);
57
+ shouldScheduleMicrotask && queueMicrotask(queuedNotifier);
58
+ shouldScheduleMicrotask = false;
59
+ }
60
+
61
+ type InternalOnly = never;
62
+
63
+ // #region Record creation, fields wrapper
64
+ function creoRecord<TNode extends object, T extends object>(
65
+ parent: Maybe<RecordOf<TNode>>,
66
+ value: T,
67
+ ): RecordOf<T> {
68
+ const parentWeakRef = parent != null ? new WeakRef(parent) : null;
69
+
70
+ type CacheField<K extends keyof T> = T[K] extends object
71
+ ? Maybe<RecordOf<T[K]>>
72
+ : never;
73
+ type Cache = { [K in keyof T]: CacheField<K> };
74
+ const cache: Cache = {} as Cache;
75
+ const record: RecordOf<T> = new Proxy(value, {
76
+ // @ts-ignore we override `has` to improve typing
77
+ has<K extends keyof T>(target: T, property: K): boolean {
78
+ if (property === ParentRecord) {
79
+ return true;
80
+ }
81
+ return property in target;
82
+ },
83
+ // @ts-ignore we override `get` to improve typing
84
+ get<K extends keyof T>(target: T, property: K): T[K] {
85
+ const val = target[property];
86
+
87
+ if (property === ParentRecord) {
88
+ // Only for internal use
89
+ return parentWeakRef?.deref() as InternalOnly;
90
+ }
91
+ // If the value is cached, return the cached record:
92
+ if (cache[property] != null) {
93
+ return cache[property] as T[K];
94
+ }
95
+ // No cached value:
96
+ if (isRecordLike(val)) {
97
+ // Object / Array, etc.
98
+ // we proxify all nested objects / arrays to ensure correct behaviour
99
+ const childRecord = creoRecord(record, val);
100
+ cache[property] = childRecord as CacheField<K>;
101
+ return childRecord;
102
+ }
103
+
104
+ // Primitive value:
105
+ return val;
106
+ },
107
+ set<K, TNewValue>(target: T, property: K, newValue: TNewValue) {
108
+ // property is actually the keyof K, but TS defines Proxy differently:
109
+ const prop: keyof T = property as keyof T;
110
+ const value: T[typeof prop] = newValue as T[typeof prop];
111
+
112
+ target[prop] = value;
113
+ if (cache[prop] != null) {
114
+ cache[prop] = null as Cache[keyof Cache];
115
+ }
116
+ recordDidUpdate(record);
117
+ return true;
118
+ },
119
+ }) as RecordOf<T>;
120
+ didUpdateMap.set(record, new Set());
121
+ return record;
122
+ }
123
+
124
+ // #region Public API
125
+ export function record<TNode extends object>(value: TNode): RecordOf<TNode> {
126
+ if (isRecord(value)) {
127
+ return value;
128
+ }
129
+ return creoRecord(null, value);
130
+ }
131
+
132
+ export function onDidUpdate<T extends object>(
133
+ record: RecordOf<T>,
134
+ listener: (record: RecordOf<T>) => void,
135
+ ): () => void {
136
+ const listeners = didUpdateMap.get(record);
137
+ if (!listeners) {
138
+ // Safe-guard: Essentialy this path cannot happen
139
+ throw new TypeError(`Record ${record} was created without listener`);
140
+ }
141
+ listeners.add(listener);
142
+ return function unsubscribe() {
143
+ listeners.delete(listener);
144
+ };
145
+ }
@@ -0,0 +1,26 @@
1
+ import { Wildcard } from "../wildcard/wildcard";
2
+
3
+ export function shallowEqual(a: Wildcard, b: Wildcard): boolean {
4
+ if (a === b) return true;
5
+
6
+ if (
7
+ typeof a !== "object" ||
8
+ a === null ||
9
+ typeof b !== "object" ||
10
+ b === null
11
+ ) {
12
+ return false;
13
+ }
14
+
15
+ const keysA = Object.keys(a);
16
+ if (keysA.length !== Object.keys(b).length) return false;
17
+
18
+ for (let i = 0; i < keysA.length; i++) {
19
+ const key = keysA[i];
20
+ if (!Object.is(a[key], b[key])) {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ return true;
26
+ }
@@ -0,0 +1,8 @@
1
+ let i = 0;
2
+
3
+ export function generateNextKey(childrenSize: number): string {
4
+ if (i === Number.MAX_SAFE_INTEGER) {
5
+ i = 0;
6
+ }
7
+ return `c:${i++}:${childrenSize}`;
8
+ }
@@ -0,0 +1 @@
1
+ export type Wildcard = any;
@@ -0,0 +1,53 @@
1
+ import { Block, Button, creo, Inline, Text } from "../../creo";
2
+ import { Maybe } from "../../data-structures/maybe/Maybe";
3
+ import { _ } from "../../data-structures/null/null";
4
+
5
+ type Todo = { text: string };
6
+
7
+ export const SimpleTodoList = creo<{ text: string; todos: Array<Todo> }>(
8
+ (c) => {
9
+ const todos: Array<Todo> = c.tracked(c.p.todos);
10
+ let button: () => Maybe<HTMLElement>;
11
+ return {
12
+ didMount() {
13
+ console.warn("did mount");
14
+ console.log(button());
15
+ button()?.addEventListener("click", () => {
16
+ todos.push({ text: `Task #${todos.length}` });
17
+ });
18
+ // button?.extension.getButton().addEventListener("click", () => {
19
+ // todos.push({ text: `New Todo: ${counter++}` });
20
+ // });
21
+ },
22
+ render() {
23
+ console.log("rendering todo list");
24
+ Inline(_, () => {
25
+ Text(c.p.text);
26
+ });
27
+ Block(_, () => {
28
+ Text("Hello inside container");
29
+ Block(_, () => {
30
+ TodoList({ todos });
31
+ });
32
+ });
33
+ button = Button(_, () => Text("Add todo"));
34
+ },
35
+ };
36
+ },
37
+ );
38
+
39
+ export const TodoList = creo<{ todos: Array<Todo> }>((c) => {
40
+ const todos: Array<Todo> = c.tracked(c.p.todos);
41
+ return {
42
+ render() {
43
+ console.log("rendering todos");
44
+ todos.map((todo) => {
45
+ console.log(todo);
46
+ Block({ class: "todo" }, () => {
47
+ console.log(`Entity: ${todo.text}`);
48
+ Text(`Entity: ${todo.text}`);
49
+ });
50
+ });
51
+ },
52
+ };
53
+ });
@@ -0,0 +1 @@
1
+ declare const __DEV__: boolean;
package/src/main.ts CHANGED
@@ -1,13 +1,24 @@
1
+ import { record } from "./data-structures/record/Record";
2
+ import { DomEngine } from "./DOM/DomEngine";
3
+ import { SimpleTodoList } from "./examples/SimpleTodoList/SimpleTodoList";
1
4
  import "./style.css";
2
5
 
3
- document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
4
- <div>
5
- <h1>Vite + TypeScript</h1>
6
- <div class="card">
7
- <button id="counter" type="button"></button>
8
- </div>
9
- <p class="read-the-docs">
10
- Click on the Vite and TypeScript logos to learn more
11
- </p>
12
- </div>
13
- `;
6
+ const todoList = record([
7
+ { text: "First" },
8
+ { text: "Second" },
9
+ { text: "Third" },
10
+ ]);
11
+
12
+ const engine = new DomEngine(document.querySelector("#app") as HTMLElement);
13
+ engine.render(() => {
14
+ SimpleTodoList({
15
+ text: "Hello world",
16
+ todos: todoList,
17
+ });
18
+ });
19
+
20
+ todoList.push({ text: "New item" });
21
+
22
+ setTimeout(() => {
23
+ todoList[2].text = "123";
24
+ }, 1000);
package/src/style.css CHANGED
@@ -1,96 +1,41 @@
1
1
  :root {
2
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3
- line-height: 1.5;
4
- font-weight: 400;
2
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
5
 
6
- color-scheme: light dark;
7
- color: rgba(255, 255, 255, 0.87);
8
- background-color: #242424;
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
9
 
10
- font-synthesis: none;
11
- text-rendering: optimizeLegibility;
12
- -webkit-font-smoothing: antialiased;
13
- -moz-osx-font-smoothing: grayscale;
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
14
  }
15
15
 
16
16
  a {
17
- font-weight: 500;
18
- color: #646cff;
19
- text-decoration: inherit;
17
+ font-weight: 500;
18
+ color: #646cff;
19
+ text-decoration: inherit;
20
20
  }
21
21
  a:hover {
22
- color: #535bf2;
22
+ color: #535bf2;
23
23
  }
24
24
 
25
25
  body {
26
- margin: 0;
27
- display: flex;
28
- place-items: center;
29
- min-width: 320px;
30
- min-height: 100vh;
26
+ margin: 0;
27
+ display: flex;
28
+ place-items: center;
29
+ min-width: 320px;
30
+ min-height: 100vh;
31
31
  }
32
32
 
33
33
  h1 {
34
- font-size: 3.2em;
35
- line-height: 1.1;
34
+ font-size: 3.2em;
35
+ line-height: 1.1;
36
36
  }
37
37
 
38
- #app {
39
- max-width: 1280px;
40
- margin: 0 auto;
41
- padding: 2rem;
42
- text-align: center;
43
- }
44
-
45
- .logo {
46
- height: 6em;
47
- padding: 1.5em;
48
- will-change: filter;
49
- transition: filter 300ms;
50
- }
51
- .logo:hover {
52
- filter: drop-shadow(0 0 2em #646cffaa);
53
- }
54
- .logo.vanilla:hover {
55
- filter: drop-shadow(0 0 2em #3178c6aa);
56
- }
57
-
58
- .card {
59
- padding: 2em;
60
- }
61
-
62
- .read-the-docs {
63
- color: #888;
64
- }
65
-
66
- button {
67
- border-radius: 8px;
68
- border: 1px solid transparent;
69
- padding: 0.6em 1.2em;
70
- font-size: 1em;
71
- font-weight: 500;
72
- font-family: inherit;
73
- background-color: #1a1a1a;
74
- cursor: pointer;
75
- transition: border-color 0.25s;
76
- }
77
- button:hover {
78
- border-color: #646cff;
79
- }
80
- button:focus,
81
- button:focus-visible {
82
- outline: 4px auto -webkit-focus-ring-color;
83
- }
84
-
85
- @media (prefers-color-scheme: light) {
86
- :root {
87
- color: #213547;
88
- background-color: #ffffff;
89
- }
90
- a:hover {
91
- color: #747bff;
92
- }
93
- button {
94
- background-color: #f9f9f9;
95
- }
38
+ .todo {
39
+ margin: 10px;
40
+ border: 1px solid blue;
96
41
  }
@@ -0,0 +1,10 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const Block = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => ({
7
+ render() {
8
+ ui("div", c.p, c.slot);
9
+ },
10
+ }));
@@ -0,0 +1,12 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const Button = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => {
7
+ return {
8
+ render() {
9
+ ui("button", c.p, c.slot);
10
+ },
11
+ };
12
+ });
@@ -0,0 +1,10 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const HStack = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => ({
7
+ render() {
8
+ ui("div", c.p, c.slot);
9
+ },
10
+ }));
@@ -0,0 +1,12 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const Inline = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => {
7
+ return {
8
+ render() {
9
+ ui("span", c.p, c.slot);
10
+ },
11
+ };
12
+ });
@@ -0,0 +1,10 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const List = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => ({
7
+ render() {
8
+ ui("div", c.p, c.slot);
9
+ },
10
+ }));
@@ -0,0 +1,9 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const Text = creo<string>((c) => {
4
+ return {
5
+ render() {
6
+ ui("text", c.p);
7
+ },
8
+ };
9
+ });
@@ -0,0 +1,11 @@
1
+ import { creo, ui } from "../../creo";
2
+
3
+ export const VStack = creo<{
4
+ // TODO replace with nice UI params
5
+ [key: string]: string;
6
+ }>((c) => ({
7
+ render() {
8
+ ui("div", c.p, c.slot);
9
+ },
10
+ // TODO allow `with` field to enable the `slot`
11
+ }));
package/tsconfig.json CHANGED
@@ -3,8 +3,9 @@
3
3
  "target": "ES2020",
4
4
  "useDefineForClassFields": true,
5
5
  "module": "ESNext",
6
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "lib": ["ESNext", "ES2020", "DOM", "DOM.Iterable"],
7
7
  "skipLibCheck": true,
8
+ "experimentalDecorators": true,
8
9
 
9
10
  /* Bundler mode */
10
11
  "moduleResolution": "bundler",
@@ -13,7 +14,6 @@
13
14
  "isolatedModules": true,
14
15
  "noEmit": true,
15
16
 
16
- /* Linting */
17
17
  "strict": true,
18
18
  "noUnusedLocals": true,
19
19
  "noUnusedParameters": true,
package/vite.config.js ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig, loadEnv } from "vite";
2
+
3
+ export default defineConfig(({ mode }) => {
4
+ const env = loadEnv(mode, process.cwd(), "");
5
+ return {
6
+ define: {
7
+ __DEV__: env.__DEV__,
8
+ },
9
+ };
10
+ });
package/bun.lockb DELETED
Binary file