@oscarpalmer/abydon 0.13.0 → 0.15.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.
Files changed (44) hide show
  1. package/dist/abydon.full.js +790 -763
  2. package/dist/constants.js +10 -0
  3. package/dist/fragment.js +38 -17
  4. package/dist/fragments.js +74 -0
  5. package/dist/helpers/dom.js +4 -4
  6. package/dist/helpers/index.js +4 -1
  7. package/dist/index.js +5 -1
  8. package/dist/node/attribute/index.js +19 -14
  9. package/dist/node/attribute/value.js +10 -12
  10. package/dist/node/event.js +7 -9
  11. package/dist/node/index.js +9 -4
  12. package/dist/node/value.js +84 -64
  13. package/dist/parse.js +7 -7
  14. package/package.json +14 -16
  15. package/src/constants.ts +20 -0
  16. package/src/fragment.ts +92 -29
  17. package/src/fragments.ts +134 -0
  18. package/src/helpers/dom.ts +3 -3
  19. package/src/helpers/index.ts +10 -0
  20. package/src/index.ts +16 -1
  21. package/src/models.ts +18 -4
  22. package/src/node/attribute/index.ts +24 -19
  23. package/src/node/attribute/value.ts +31 -28
  24. package/src/node/event.ts +11 -12
  25. package/src/node/index.ts +15 -6
  26. package/src/node/value.ts +186 -116
  27. package/src/parse.ts +10 -3
  28. package/types/constants.d.ts +9 -0
  29. package/types/fragment.d.ts +26 -8
  30. package/types/fragments.d.ts +13 -0
  31. package/types/helpers/index.d.ts +2 -0
  32. package/types/index.d.ts +5 -1
  33. package/types/models.d.ts +16 -4
  34. package/types/fragment.d.cts +0 -27
  35. package/types/helpers/dom.d.cts +0 -7
  36. package/types/helpers/index.d.cts +0 -29
  37. package/types/index.d.cts +0 -302
  38. package/types/models.d.cts +0 -107
  39. package/types/node/attribute/index.d.cts +0 -109
  40. package/types/node/attribute/value.d.cts +0 -109
  41. package/types/node/event.d.cts +0 -7
  42. package/types/node/index.d.cts +0 -108
  43. package/types/node/value.d.cts +0 -108
  44. package/types/parse.d.cts +0 -108
package/package.json CHANGED
@@ -4,25 +4,24 @@
4
4
  "url": "https://oscarpalmer.se"
5
5
  },
6
6
  "dependencies": {
7
- "@oscarpalmer/atoms": "^0.108",
8
- "@oscarpalmer/mora": "^0.21",
9
- "@oscarpalmer/toretto": "^0.22"
7
+ "@oscarpalmer/atoms": "^0.114",
8
+ "@oscarpalmer/mora": "^0.23",
9
+ "@oscarpalmer/toretto": "^0.25"
10
10
  },
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.2",
13
- "@oscarpalmer/oui": "^0.17",
12
+ "@biomejs/biome": "^2.3",
13
+ "@oscarpalmer/oui": "^0.18",
14
14
  "@rollup/plugin-node-resolve": "^16",
15
- "@rollup/plugin-typescript": "^12.1",
16
- "@types/node": "^24.3",
17
- "@vitest/coverage-istanbul": "^3.2",
18
- "dts-bundle-generator": "^9.5",
15
+ "@rollup/plugin-typescript": "^12.3",
16
+ "@types/node": "^24.9",
17
+ "@vitest/coverage-istanbul": "^4",
19
18
  "glob": "^11",
20
- "jsdom": "^26.1",
21
- "rollup": "^4.50",
19
+ "jsdom": "^27.1",
20
+ "rollup": "^4.52",
22
21
  "tslib": "^2.8",
23
22
  "typescript": "^5.9",
24
23
  "vite": "npm:rolldown-vite@latest",
25
- "vitest": "^3.2"
24
+ "vitest": "^4"
26
25
  },
27
26
  "exports": {
28
27
  "./package.json": "./package.json",
@@ -50,15 +49,14 @@
50
49
  "url": "git+https://github.com/oscarpalmer/abydon.git"
51
50
  },
52
51
  "scripts": {
53
- "build": "npm run clean && npx vite build && npm run rollup:build && npm run types",
52
+ "build": "npm run clean && npx vite build && npm run rollup:build && npx tsc",
54
53
  "clean": "rm -rf ./dist && rm -rf ./types && rm -f ./tsconfig.tsbuildinfo",
55
54
  "rollup:build": "npx rollup -c",
56
55
  "rollup:watch": "npx rollup -c --watch",
57
56
  "test": "npx vitest --coverage",
58
- "types": "npx tsc && npx dts-bundle-generator --config ./dts.config.cts --silent",
59
57
  "watch": "npx vite build --watch"
60
58
  },
61
59
  "type": "module",
62
- "types": "types/index.d.cts",
63
- "version": "0.13.0"
60
+ "types": "types/index.d.ts",
61
+ "version": "0.15.0"
64
62
  }
@@ -0,0 +1,20 @@
1
+ export const ABORT_CONTROLLERS: WeakMap<HTMLOrSVGElement, AbortController> =
2
+ new WeakMap();
3
+
4
+ export const ATTRIBUTE_CLASS_PREFIX_LENGTH = 6;
5
+
6
+ export const EXPRESSION_ATTRIBUTE_CLASS = /^class\./;
7
+
8
+ export const EXPRESSION_ATTRIBUTE_STYLE_FULL =
9
+ /^style\.([\w-]+)(?:\.([\w-]+))?$/;
10
+
11
+ export const EXPRESSION_ATTRIBUTE_STYLE_PREFIX = /^style\./;
12
+
13
+ export const EXPRESSION_COMMENT_FULL = /^<!--abydon\.(\d+)-->$/;
14
+
15
+ export const EXPRESSION_COMMENT_CONTENT = /^abydon\.(\d+)$/;
16
+
17
+ export const EXPRESSION_EVENT_NAME = /^@([\w-]+)(?::([a-z:]+))?$/i;
18
+
19
+ export const REASON_EVENT_REMOVED =
20
+ 'Event removed as element was removed from document by Abydon';
package/src/fragment.ts CHANGED
@@ -1,20 +1,33 @@
1
- import type {Key} from '@oscarpalmer/atoms/models';
1
+ import {isPlainObject} from '@oscarpalmer/atoms/is';
2
2
  import {html} from '@oscarpalmer/toretto/html';
3
+ import {handleFragments} from './fragments';
4
+ import {isFragments} from './helpers';
3
5
  import {removeNodes} from './helpers/dom';
4
- import type {FragmentData} from './models';
6
+ import type {FragmentConfiguration, FragmentData} from './models';
5
7
  import {mapNodes} from './node';
6
8
  import {parse} from './parse';
7
9
 
8
10
  export class Fragment {
9
- private readonly $fragment = true;
10
- private readonly data: FragmentData;
11
+ readonly #data: FragmentData;
11
12
 
12
- get identifier(): Key | undefined {
13
- return this.data.identifier;
13
+ readonly #configuration: Required<FragmentConfiguration> = {
14
+ identifier: undefined,
15
+ ignoreCache: false,
16
+ };
17
+
18
+ /**
19
+ * Fragment identifier
20
+ */
21
+ get identifier(): unknown {
22
+ return this.#configuration.identifier;
14
23
  }
15
24
 
16
25
  constructor(strings: TemplateStringsArray, expressions: unknown[]) {
17
- this.data = {
26
+ Object.defineProperty(this, '$fragment', {
27
+ value: true,
28
+ });
29
+
30
+ this.#data = {
18
31
  expressions,
19
32
  strings,
20
33
  items: [],
@@ -27,79 +40,129 @@ export class Fragment {
27
40
  }
28
41
 
29
42
  /**
30
- * Appends the fragment to the given element
43
+ * Append the fragment to the given element
44
+ * @param element Element to append to
31
45
  */
32
46
  appendTo(element: Element): void {
33
47
  element.append(...this.get());
34
48
  }
35
49
 
36
50
  /**
37
- * Gets a list of the fragment's nodes
51
+ * Configure the fragment
52
+ * @param configuration Configuration options
53
+ * @returns Fragment
54
+ */
55
+ configure(configuration: FragmentConfiguration): Fragment {
56
+ const actual = isPlainObject(configuration) ? configuration : {};
57
+
58
+ if (actual.identifier !== undefined) {
59
+ this.#configuration.identifier = actual.identifier;
60
+ }
61
+
62
+ if (typeof actual.ignoreCache === 'boolean') {
63
+ this.#configuration.ignoreCache = actual.ignoreCache;
64
+ }
65
+
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * Get a list of the fragment's nodes
71
+ * @returns List of nodes
38
72
  */
39
73
  get(): ChildNode[] {
40
- if (this.data.items.length === 0) {
41
- const parsed = parse(this.data);
74
+ const data = this.#data;
75
+
76
+ if (data.items.length === 0) {
77
+ const parsed = parse(data);
42
78
 
43
79
  const templated = html(parsed, {
80
+ ignoreCache: this.#configuration.ignoreCache,
44
81
  sanitizeBooleanAttributes: false,
45
82
  });
46
83
 
47
- this.data.items.splice(
84
+ data.items.splice(
48
85
  0,
49
- this.data.items.length,
86
+ data.items.length,
50
87
  ...templated.map(node => ({
51
88
  nodes: [node as ChildNode],
52
89
  })),
53
90
  );
54
91
 
55
92
  mapNodes(
56
- this.data,
57
- this.data.items.flatMap(
93
+ data,
94
+ data.items.flatMap(
58
95
  item =>
59
- item.fragments?.flatMap(fragment => fragment.get()) ?? item.nodes,
96
+ item.fragments?.flatMap(fragment => fragment.get()) ??
97
+ item.nodes ??
98
+ [],
60
99
  ),
61
100
  );
62
101
  }
63
102
 
64
103
  return [
65
- ...this.data.items.flatMap(
104
+ ...data.items.flatMap(
66
105
  item =>
67
- item.fragments?.flatMap(fragment => fragment.get()) ?? item.nodes,
106
+ item.fragments?.flatMap(fragment => fragment.get()) ??
107
+ item.nodes ??
108
+ [],
68
109
  ),
69
110
  ];
70
111
  }
71
112
 
72
- identify(identifier: Key): Fragment {
73
- this.data.identifier = identifier;
113
+ /**
114
+ * Set an identifier for the fragment
115
+ *
116
+ * _An identifier can be used to uniquely identify a fragment,
117
+ * which helps prevent re-rendering in certain scenarios._
118
+ * @param identifier Identifier
119
+ * @returns Fragment
120
+ */
121
+ identify(identifier: unknown): Fragment {
122
+ this.#configuration.identifier = identifier;
74
123
 
75
124
  return this;
76
125
  }
77
126
 
78
127
  /**
79
- * Removes the fragment from the DOM
128
+ * Remove the fragment from the DOM
80
129
  */
81
130
  remove(): void {
82
- removeFragment(this.data);
131
+ removeFragment(this.#data);
83
132
  }
84
133
  }
85
134
 
86
135
  function removeFragment(data: FragmentData): void {
87
136
  removeMora(data);
88
137
 
89
- const {length} = data.items;
138
+ let {length} = data.items;
90
139
 
91
140
  for (let index = 0; index < length; index += 1) {
92
141
  const {fragments, nodes} = data.items[index];
93
- const {length} = fragments ?? [];
94
-
95
- for (let index = 0; index < length; index += 1) {
96
- fragments?.[index]?.remove();
142
+ const fragmentsLength = fragments?.length ?? 0;
143
+
144
+ for (
145
+ let fragmentIndex = 0;
146
+ fragmentIndex < fragmentsLength;
147
+ fragmentIndex += 1
148
+ ) {
149
+ fragments?.[fragmentIndex]?.remove();
97
150
  }
98
151
 
99
- removeNodes(nodes);
152
+ removeNodes(nodes ?? []);
100
153
  }
101
154
 
102
- data.items.splice(0, length);
155
+ data.items.length = 0;
156
+
157
+ length = data.values.length;
158
+
159
+ for (let index = 0; index < length; index += 1) {
160
+ const value = data.values[index];
161
+
162
+ if (isFragments(value)) {
163
+ handleFragments(value, true);
164
+ }
165
+ }
103
166
  }
104
167
 
105
168
  function removeMora(data: FragmentData): void {
@@ -0,0 +1,134 @@
1
+ import {getString} from '@oscarpalmer/atoms/string';
2
+ import {array, type ReactiveArray} from '@oscarpalmer/mora';
3
+ import type {Fragment} from './fragment';
4
+ import {isFragment, isFragments} from './helpers';
5
+ import type {FragmentsState} from './models';
6
+
7
+ export class Fragments {
8
+ readonly #state: FragmentsState;
9
+
10
+ /**
11
+ * Fragment items
12
+ */
13
+ get items(): ReactiveArray<Fragment> {
14
+ return this.#state.mapped;
15
+ }
16
+
17
+ constructor(
18
+ items: ReactiveArray<unknown>,
19
+ identify: (item: unknown) => unknown,
20
+ fragment: (item: unknown) => Fragment,
21
+ ) {
22
+ Object.defineProperty(this, '$fragments', {
23
+ value: true,
24
+ });
25
+
26
+ this.#state = {
27
+ fragment,
28
+ identify,
29
+ array: items,
30
+ instances: {},
31
+ mapped: array<Fragment>([]),
32
+ subscriber: undefined,
33
+ };
34
+
35
+ states.set(this, this.#state);
36
+
37
+ initializeFragments(this.#state);
38
+ }
39
+ }
40
+
41
+ export function handleFragments(
42
+ item: Fragments | FragmentsState,
43
+ remove: boolean,
44
+ ): void {
45
+ const state = isFragments(item) ? states.get(item) : item;
46
+
47
+ if (state != null) {
48
+ (remove ? removeFragments : initializeFragments)(state);
49
+ }
50
+ }
51
+
52
+ function handleItems(state: FragmentsState, items: unknown[]): void {
53
+ const keys = new Set<string>();
54
+ const mapped: Fragment[] = [];
55
+
56
+ const {length} = items;
57
+
58
+ for (let index = 0; index < length; index += 1) {
59
+ const item = items[index];
60
+ const identifier = state.identify(item);
61
+
62
+ if (identifier == null) {
63
+ throw new Error('Identifier cannot be null or undefined');
64
+ }
65
+
66
+ const key = getString(identifier);
67
+
68
+ if (keys.has(key)) {
69
+ throw new Error(`Duplicate identifier found: "${key}"`);
70
+ }
71
+
72
+ let instance = state.instances[key];
73
+
74
+ if (instance == null) {
75
+ instance = state.fragment(item);
76
+
77
+ if (!isFragment(instance)) {
78
+ throw new Error('Fragment function must return a Fragment instance');
79
+ }
80
+ }
81
+
82
+ instance.identify(key);
83
+
84
+ state.instances[key] = instance;
85
+
86
+ keys.add(key);
87
+
88
+ mapped.push(instance);
89
+ }
90
+
91
+ state.mapped.set(mapped);
92
+
93
+ updateFragments(state, keys);
94
+ }
95
+
96
+ export function initializeFragments(state: FragmentsState): void {
97
+ state.subscriber ??= state.array.subscribe(items => {
98
+ handleItems(state, items);
99
+ });
100
+ }
101
+
102
+ function removeFragments(state: FragmentsState): void {
103
+ state.subscriber?.();
104
+
105
+ updateFragments(state);
106
+
107
+ state.subscriber = undefined;
108
+ }
109
+
110
+ function updateFragments(state: FragmentsState, active?: Set<string>): void {
111
+ const next: Record<string, Fragment> = {};
112
+
113
+ const previous = {...state.instances};
114
+ const keys = Object.keys(previous);
115
+ const {length} = keys;
116
+
117
+ for (let index = 0; index < length; index += 1) {
118
+ const key = keys[index];
119
+
120
+ if (active?.has(key)) {
121
+ next[key] = previous[key];
122
+ } else {
123
+ previous[key].remove();
124
+ }
125
+ }
126
+
127
+ state.instances = next;
128
+
129
+ active?.clear();
130
+ }
131
+
132
+ //
133
+
134
+ const states: WeakMap<Fragments, FragmentsState> = new WeakMap();
@@ -16,7 +16,7 @@ export function createNodes(value: unknown): ChildNode[] {
16
16
  }
17
17
 
18
18
  export function removeNodes(nodes: ChildNode[]): void {
19
- sanitiseNodes(nodes);
19
+ sanitizeNodes(nodes);
20
20
 
21
21
  const {length} = nodes;
22
22
 
@@ -35,7 +35,7 @@ export function replaceNodes(from: ChildNode[], to: ChildNode[]): void {
35
35
  }
36
36
  }
37
37
 
38
- function sanitiseNodes(nodes: ChildNode[]): void {
38
+ function sanitizeNodes(nodes: ChildNode[]): void {
39
39
  const {length} = nodes;
40
40
 
41
41
  for (let index = 0; index < length; index += 1) {
@@ -46,7 +46,7 @@ function sanitiseNodes(nodes: ChildNode[]): void {
46
46
  }
47
47
 
48
48
  if (node.hasChildNodes()) {
49
- sanitiseNodes([...node.childNodes] as ChildNode[]);
49
+ sanitizeNodes([...node.childNodes] as ChildNode[]);
50
50
  }
51
51
  }
52
52
  }
@@ -1,4 +1,5 @@
1
1
  import type {Fragment} from '../fragment';
2
+ import type {Fragments} from '../fragments';
2
3
 
3
4
  export function compareArrays(
4
5
  first: unknown[],
@@ -27,3 +28,12 @@ export function isFragment(value: unknown): value is Fragment {
27
28
  value.$fragment === true
28
29
  );
29
30
  }
31
+
32
+ export function isFragments(value: unknown): value is Fragments {
33
+ return (
34
+ typeof value === 'object' &&
35
+ value != null &&
36
+ '$fragments' in value &&
37
+ value.$fragments === true
38
+ );
39
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,27 @@
1
1
  import '@oscarpalmer/mora';
2
+ import type {ReactiveArray} from '@oscarpalmer/mora';
2
3
  import {Fragment} from './fragment';
4
+ import {Fragments} from './fragments';
5
+
6
+ export function fragments<Item>(
7
+ array: ReactiveArray<Item>,
8
+ identify: (item: Item) => unknown,
9
+ fragment: (item: Item) => Fragment,
10
+ ): Fragments {
11
+ return new Fragments(
12
+ array as ReactiveArray<unknown>,
13
+ identify as never,
14
+ fragment as never,
15
+ );
16
+ }
3
17
 
4
18
  export function html(
5
19
  strings: TemplateStringsArray,
6
20
  ...values: unknown[]
7
- ): Fragment {
21
+ ): unknown {
8
22
  return new Fragment(strings, values);
9
23
  }
10
24
 
11
25
  export * from '@oscarpalmer/mora';
12
26
  export type {Fragment} from './fragment';
27
+ export type {Fragments} from './fragments';
package/src/models.ts CHANGED
@@ -1,19 +1,33 @@
1
- import type {Key} from '@oscarpalmer/atoms/models';
2
- import type {Reactive} from '@oscarpalmer/mora';
1
+ import type {Reactive, ReactiveArray, Unsubscribe} from '@oscarpalmer/mora';
3
2
  import type {Fragment} from './fragment';
4
3
 
4
+ export type FragmentConfiguration = {
5
+ identifier?: unknown;
6
+ ignoreCache?: boolean;
7
+ };
8
+
5
9
  export type FragmentData = {
6
10
  expressions: unknown[];
7
- identifier?: Key;
8
11
  items: FragmentItem[];
9
12
  mora: MoraData;
10
13
  strings: TemplateStringsArray;
14
+ template?: string;
11
15
  values: unknown[];
12
16
  };
13
17
 
14
18
  export type FragmentItem = {
15
19
  fragments?: Fragment[];
16
- nodes: ChildNode[];
20
+ nodes?: ChildNode[];
21
+ text?: Text;
22
+ };
23
+
24
+ export type FragmentsState = {
25
+ array: ReactiveArray<unknown>;
26
+ fragment: (item: unknown) => Fragment;
27
+ identify: (item: unknown) => unknown;
28
+ instances: Record<string, Fragment>;
29
+ mapped: ReactiveArray<Fragment>;
30
+ subscriber: Unsubscribe | undefined;
17
31
  };
18
32
 
19
33
  type MoraData = {
@@ -1,14 +1,14 @@
1
1
  import type {GenericCallback} from '@oscarpalmer/atoms/models';
2
2
  import {computed, isReactive} from '@oscarpalmer/mora';
3
+ import {isBooleanAttribute, setProperty} from '@oscarpalmer/toretto/attribute';
3
4
  import type {HTMLOrSVGElement} from '@oscarpalmer/toretto/models';
5
+ import {EXPRESSION_COMMENT_FULL} from '../../constants';
4
6
  import type {FragmentData} from '../../models';
5
7
  import {mapEvent} from '../event';
6
8
  import {setAttribute} from './value';
7
9
 
8
- const commentExpression = /^<!--abydon\.(\d+)-->$/;
9
-
10
10
  function getValue(data: FragmentData, original: string): unknown {
11
- const matches = commentExpression.exec(original ?? '');
11
+ const matches = EXPRESSION_COMMENT_FULL.exec(original ?? '');
12
12
 
13
13
  return matches == null ? original : data.values[+matches[1]];
14
14
  }
@@ -25,14 +25,23 @@ export function mapAttributes(
25
25
 
26
26
  const actual = getValue(data, value);
27
27
 
28
- if (name.startsWith('@')) {
29
- mapEvent(element, name, actual);
30
- } else if (
31
- name.includes('.') ||
32
- typeof value === 'function' ||
33
- isReactive(actual)
34
- ) {
35
- mapValue(data, element, name, actual);
28
+ switch (true) {
29
+ case name.startsWith('@'):
30
+ mapEvent(element, name, actual);
31
+ break;
32
+
33
+ case name.includes('.') ||
34
+ typeof actual === 'function' ||
35
+ isReactive(actual):
36
+ mapValue(data, element, name, actual);
37
+ break;
38
+
39
+ case isBooleanAttribute(name):
40
+ setProperty(element, name, value);
41
+ break;
42
+
43
+ default:
44
+ break;
36
45
  }
37
46
  }
38
47
  }
@@ -43,14 +52,10 @@ function mapValue(
43
52
  name: string,
44
53
  value: unknown,
45
54
  ): void {
46
- switch (true) {
47
- case typeof value === 'function':
48
- setComputedAttribute(data, element, name, value as GenericCallback);
49
- break;
50
-
51
- default:
52
- setAttribute(data, element, name, value);
53
- break;
55
+ if (typeof value === 'function') {
56
+ setComputedAttribute(data, element, name, value as GenericCallback);
57
+ } else {
58
+ setAttribute(data, element, name, value);
54
59
  }
55
60
  }
56
61