@rasamjs/core 0.2.0 → 0.3.1

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 (178) hide show
  1. package/API.md +14 -4
  2. package/CHANGELOG.md +71 -0
  3. package/COMPONENTS.md +229 -0
  4. package/README.md +46 -6
  5. package/RELEASING.md +4 -2
  6. package/dist/advanced.d.ts +1 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/catalog/fragment.d.ts +2 -2
  9. package/dist/catalog/fragment.d.ts.map +1 -1
  10. package/dist/catalog/fragment.js.map +1 -1
  11. package/dist/changes/derived.d.ts +10 -0
  12. package/dist/changes/derived.d.ts.map +1 -0
  13. package/dist/changes/derived.js +14 -0
  14. package/dist/changes/derived.js.map +1 -0
  15. package/dist/changes/restore.d.ts.map +1 -1
  16. package/dist/changes/restore.js +76 -28
  17. package/dist/changes/restore.js.map +1 -1
  18. package/dist/changes/service.d.ts +5 -0
  19. package/dist/changes/service.d.ts.map +1 -1
  20. package/dist/changes/service.js +18 -4
  21. package/dist/changes/service.js.map +1 -1
  22. package/dist/changes/state.d.ts.map +1 -1
  23. package/dist/changes/state.js +33 -27
  24. package/dist/changes/state.js.map +1 -1
  25. package/dist/changes.d.ts.map +1 -1
  26. package/dist/changes.js +20 -4
  27. package/dist/changes.js.map +1 -1
  28. package/dist/components/changes.d.ts +34 -0
  29. package/dist/components/changes.d.ts.map +1 -0
  30. package/dist/components/changes.js +119 -0
  31. package/dist/components/changes.js.map +1 -0
  32. package/dist/components/component.d.ts +62 -0
  33. package/dist/components/component.d.ts.map +1 -0
  34. package/dist/components/component.js +355 -0
  35. package/dist/components/component.js.map +1 -0
  36. package/dist/components/decorators.d.ts +7 -0
  37. package/dist/components/decorators.d.ts.map +1 -0
  38. package/dist/components/decorators.js +83 -0
  39. package/dist/components/decorators.js.map +1 -0
  40. package/dist/components/metadata.d.ts +10 -0
  41. package/dist/components/metadata.d.ts.map +1 -0
  42. package/dist/components/metadata.js +85 -0
  43. package/dist/components/metadata.js.map +1 -0
  44. package/dist/components/registry.d.ts +24 -0
  45. package/dist/components/registry.d.ts.map +1 -0
  46. package/dist/components/registry.js +78 -0
  47. package/dist/components/registry.js.map +1 -0
  48. package/dist/components/types.d.ts +54 -0
  49. package/dist/components/types.d.ts.map +1 -0
  50. package/dist/components/types.js +2 -0
  51. package/dist/components/types.js.map +1 -0
  52. package/dist/components/view.d.ts +9 -0
  53. package/dist/components/view.d.ts.map +1 -0
  54. package/dist/components/view.js +104 -0
  55. package/dist/components/view.js.map +1 -0
  56. package/dist/components.d.ts +6 -0
  57. package/dist/components.d.ts.map +1 -0
  58. package/dist/components.js +5 -0
  59. package/dist/components.js.map +1 -0
  60. package/dist/core/node.d.ts +7 -0
  61. package/dist/core/node.d.ts.map +1 -1
  62. package/dist/core/node.js +30 -0
  63. package/dist/core/node.js.map +1 -1
  64. package/dist/interchange/clone.d.ts.map +1 -1
  65. package/dist/interchange/clone.js +3 -2
  66. package/dist/interchange/clone.js.map +1 -1
  67. package/dist/interchange/reconstruct.d.ts.map +1 -1
  68. package/dist/interchange/reconstruct.js +16 -12
  69. package/dist/interchange/reconstruct.js.map +1 -1
  70. package/dist/interchange/references.d.ts.map +1 -1
  71. package/dist/interchange/references.js +10 -4
  72. package/dist/interchange/references.js.map +1 -1
  73. package/dist/mixins/parent-internals.d.ts +6 -0
  74. package/dist/mixins/parent-internals.d.ts.map +1 -1
  75. package/dist/mixins/parent-internals.js +34 -0
  76. package/dist/mixins/parent-internals.js.map +1 -1
  77. package/dist/mixins/parent.d.ts.map +1 -1
  78. package/dist/mixins/parent.js +6 -1
  79. package/dist/mixins/parent.js.map +1 -1
  80. package/dist/mixins/types.d.ts +2 -2
  81. package/dist/mixins/types.d.ts.map +1 -1
  82. package/dist/resources/references.d.ts +2 -0
  83. package/dist/resources/references.d.ts.map +1 -1
  84. package/dist/resources/references.js +3 -3
  85. package/dist/resources/references.js.map +1 -1
  86. package/dist/services/catalog.d.ts.map +1 -1
  87. package/dist/services/catalog.js +5 -3
  88. package/dist/services/catalog.js.map +1 -1
  89. package/dist/services/claims.d.ts +7 -0
  90. package/dist/services/claims.d.ts.map +1 -0
  91. package/dist/services/claims.js +11 -0
  92. package/dist/services/claims.js.map +1 -0
  93. package/dist/services/defs-manager.d.ts +2 -0
  94. package/dist/services/defs-manager.d.ts.map +1 -1
  95. package/dist/services/defs-manager.js +4 -0
  96. package/dist/services/defs-manager.js.map +1 -1
  97. package/dist/services/element-key.d.ts +4 -0
  98. package/dist/services/element-key.d.ts.map +1 -0
  99. package/dist/services/element-key.js +12 -0
  100. package/dist/services/element-key.js.map +1 -0
  101. package/dist/services/initialization.d.ts +7 -0
  102. package/dist/services/initialization.d.ts.map +1 -0
  103. package/dist/services/initialization.js +27 -0
  104. package/dist/services/initialization.js.map +1 -0
  105. package/dist/services/registry.d.ts +8 -0
  106. package/dist/services/registry.d.ts.map +1 -1
  107. package/dist/services/registry.js +47 -10
  108. package/dist/services/registry.js.map +1 -1
  109. package/dist/services/types.d.ts +17 -0
  110. package/dist/services/types.d.ts.map +1 -1
  111. package/dist/template/parts.d.ts +12 -6
  112. package/dist/template/parts.d.ts.map +1 -1
  113. package/dist/template/parts.js +36 -29
  114. package/dist/template/parts.js.map +1 -1
  115. package/dist/template/render.d.ts +6 -7
  116. package/dist/template/render.d.ts.map +1 -1
  117. package/dist/template/render.js +188 -44
  118. package/dist/template/render.js.map +1 -1
  119. package/dist/template/svg-tag.d.ts +5 -1
  120. package/dist/template/svg-tag.d.ts.map +1 -1
  121. package/dist/template/svg-tag.js.map +1 -1
  122. package/dist/text/internals.d.ts +1 -0
  123. package/dist/text/internals.d.ts.map +1 -1
  124. package/dist/types/common.d.ts +27 -0
  125. package/dist/types/common.d.ts.map +1 -1
  126. package/package.json +10 -1
  127. package/reports/adoption.json +20 -20
  128. package/reports/animation-benchmark.json +613 -613
  129. package/reports/benchmark.json +27 -27
  130. package/reports/change-groups-benchmark.json +471 -0
  131. package/reports/components-benchmark-before.json +10 -0
  132. package/reports/components-benchmark.json +10 -0
  133. package/reports/creation.json +26 -26
  134. package/reports/interchange-benchmark.json +85 -85
  135. package/reports/phase-3-core-after.json +40 -0
  136. package/reports/phase-3-core-before.json +40 -0
  137. package/reports/public-api-contract.json +335 -17
  138. package/reports/size-components.json +103 -0
  139. package/reports/size-parity.json +50 -34
  140. package/reports/size.json +7 -4
  141. package/reports/svgjs-comparison.json +3049 -3049
  142. package/reports/svgjs-comparison.md +15 -15
  143. package/reports/svgjs-parity-current.json +133 -46
  144. package/src/advanced.ts +1 -1
  145. package/src/catalog/fragment.ts +2 -2
  146. package/src/changes/derived.ts +19 -0
  147. package/src/changes/restore.ts +78 -36
  148. package/src/changes/service.ts +23 -8
  149. package/src/changes/state.ts +37 -28
  150. package/src/changes.ts +24 -5
  151. package/src/components/changes.ts +131 -0
  152. package/src/components/component.ts +401 -0
  153. package/src/components/decorators.ts +106 -0
  154. package/src/components/metadata.ts +111 -0
  155. package/src/components/registry.ts +79 -0
  156. package/src/components/types.ts +66 -0
  157. package/src/components/view.ts +123 -0
  158. package/src/components.ts +15 -0
  159. package/src/core/node.ts +32 -1
  160. package/src/interchange/clone.ts +3 -2
  161. package/src/interchange/reconstruct.ts +16 -12
  162. package/src/interchange/references.ts +12 -4
  163. package/src/mixins/parent-internals.ts +37 -0
  164. package/src/mixins/parent.ts +13 -4
  165. package/src/mixins/types.ts +2 -2
  166. package/src/resources/references.ts +5 -5
  167. package/src/services/catalog.ts +5 -3
  168. package/src/services/claims.ts +14 -0
  169. package/src/services/defs-manager.ts +5 -0
  170. package/src/services/element-key.ts +14 -0
  171. package/src/services/initialization.ts +25 -0
  172. package/src/services/registry.ts +49 -10
  173. package/src/services/types.ts +21 -0
  174. package/src/template/parts.ts +54 -39
  175. package/src/template/render.ts +211 -54
  176. package/src/template/svg-tag.ts +6 -1
  177. package/src/text/internals.ts +1 -0
  178. package/src/types/common.ts +29 -0
@@ -4,13 +4,19 @@ import { validateAttributeValue } from '../core/attribute-validation.js';
4
4
  import { validateNativeElement } from '../core/native-validation.js';
5
5
  import type { AnyNode } from '../core/types.js';
6
6
  import type { ParentNode } from '../mixins/types.js';
7
+ import { withInternalStructure } from '../mixins/parent-internals.js';
8
+ import { elementKey } from './element-key.js';
9
+ import { initializeNode, withInitialization } from './initialization.js';
7
10
  import type { DrawingContext } from './context.js';
8
11
  import type { ElementClass } from './types.js';
12
+ import { recordClaim } from './claims.js';
9
13
 
10
14
  export class Registry {
11
15
  private static readonly identity = new WeakMap<SVGElement, AnyNode>();
12
16
  private readonly wrappers = new Map<SVGElement, AnyNode>();
13
17
  private readonly constructors = new Map<string, ElementClass>();
18
+ /** Tags that have variants, with the attributes that select them. */
19
+ private readonly variantAttributes = new Map<string, Set<string>>();
14
20
 
15
21
  constructor(private readonly context: DrawingContext) {}
16
22
 
@@ -19,18 +25,45 @@ export class Registry {
19
25
  }
20
26
 
21
27
  register<Options, Result extends AnyNode>(Constructor: ElementClass<Options, Result>): this {
22
- const previous = this.constructors.get(Constructor.tag);
28
+ const key = elementKey(Constructor);
29
+ const previous = this.constructors.get(key);
23
30
  if (previous === Constructor) return this;
24
- if (previous && previous !== Constructor)
25
- throw new Error(`Tag already registered: ${Constructor.tag}`);
26
- this.constructors.set(Constructor.tag, Constructor);
31
+ if (previous)
32
+ throw new Error(`${Constructor.variant ? 'Variant' : 'Tag'} already registered: ${key}`);
33
+ this.constructors.set(key, Constructor);
34
+ const variant = Constructor.variant;
35
+ if (variant) {
36
+ const attributes = this.variantAttributes.get(Constructor.tag) ?? new Set<string>();
37
+ this.variantAttributes.set(Constructor.tag, attributes.add(variant.attribute));
38
+ }
27
39
  return this;
28
40
  }
29
41
 
42
+ /** The tag's base constructor, ignoring variants. */
30
43
  get(tag: string): ElementClass | undefined {
31
44
  return this.constructors.get(tag);
32
45
  }
33
46
 
47
+ /** The constructor registered for the same tag or variant as `Constructor`, if any. */
48
+ registered(Constructor: ElementClass): ElementClass | undefined {
49
+ return this.constructors.get(elementKey(Constructor));
50
+ }
51
+
52
+ variant(tag: string, attribute: string, value: string): ElementClass | undefined {
53
+ return this.constructors.get(`${tag}[${attribute}="${value}"]`);
54
+ }
55
+
56
+ /** The constructor for a native element: a matching variant, else the tag's base. */
57
+ resolve(element: SVGElement): ElementClass | undefined {
58
+ const tag = this.context.dom.tag(element);
59
+ for (const attribute of this.variantAttributes.get(tag) ?? []) {
60
+ const value = element.getAttribute(attribute);
61
+ const Constructor = value === null ? undefined : this.variant(tag, attribute, value);
62
+ if (Constructor) return Constructor;
63
+ }
64
+ return this.constructors.get(tag);
65
+ }
66
+
34
67
  /** Creates a fresh wrapper for binding initialization, without constructor defaults or mounting. */
35
68
  createEmpty(tag: string, context = this.context): AnyNode {
36
69
  const Constructor = this.get(tag);
@@ -51,6 +84,7 @@ export class Registry {
51
84
  if (!this.context.dom.owns(node.el)) throw new Error('Element belongs to another document');
52
85
  Registry.identity.set(node.el, node);
53
86
  this.wrappers.set(node.el, node);
87
+ recordClaim(node);
54
88
  }
55
89
 
56
90
  forget(node: AnyNode): void {
@@ -61,6 +95,7 @@ export class Registry {
61
95
  for (const node of this.wrappers.values()) this.forget(node);
62
96
  this.wrappers.clear();
63
97
  this.constructors.clear();
98
+ this.variantAttributes.clear();
64
99
  }
65
100
 
66
101
  adopt(element: SVGElement, parent?: ParentNode): AnyNode {
@@ -77,14 +112,17 @@ export class Registry {
77
112
  ancestor;
78
113
  ancestor = this.context.dom.svgParent(ancestor)
79
114
  ) {
80
- const Constructor = this.get(this.context.dom.tag(ancestor));
115
+ const Constructor = this.resolve(ancestor);
81
116
  if (Constructor?.opaqueContent)
82
117
  throw new Error('Cannot adopt descendants across an opaque native boundary');
83
118
  for (const tag of Constructor?.forbiddenDescendantTags ?? []) forbidden.add(tag);
84
119
  }
85
120
  this.validateTree(element, forbidden);
86
- const wrapped = this.wrapTree(element);
87
- return parent ? parent.add(wrapped) : wrapped;
121
+ // Adoption reproduces a native tree exactly, so it may fill owned children.
122
+ return withInitialization(() => {
123
+ const wrapped = withInternalStructure(() => this.wrapTree(element));
124
+ return parent ? parent.add(wrapped) : wrapped;
125
+ });
88
126
  }
89
127
 
90
128
  private assertAdoptionParent(parent: ParentNode | undefined): void {
@@ -103,7 +141,7 @@ export class Registry {
103
141
  if (existing.context !== this.context) throw new Error('Cross-root adoption is unsupported');
104
142
  return;
105
143
  }
106
- const Constructor = this.get(tag);
144
+ const Constructor = this.resolve(element);
107
145
  if (!this.context.dom.isSvg(element) || !Constructor)
108
146
  throw new TypeError(`Unsupported SVG tag: ${tag}`);
109
147
  Constructor[validateNativeElement]?.(element);
@@ -130,16 +168,17 @@ export class Registry {
130
168
  private wrapTree(element: SVGElement): AnyNode {
131
169
  const existing = Registry.wrapper(element);
132
170
  if (existing) return existing;
133
- const Constructor = this.get(this.context.dom.tag(element));
171
+ const Constructor = this.resolve(element)!;
134
172
  const children = this.managedChildren(element);
135
173
  const node = new Constructor(this.context, {}, element);
136
174
  if (Constructor.isContainer)
137
175
  (node as ParentNode).adoptChildren(children.map((child) => this.wrapTree(child)));
176
+ initializeNode(node);
138
177
  return node;
139
178
  }
140
179
 
141
180
  private managedChildren(element: SVGElement): SVGElement[] {
142
- const Constructor = this.get(this.context.dom.tag(element));
181
+ const Constructor = this.resolve(element);
143
182
  if (Constructor?.opaqueContent) return [];
144
183
  return this.context.dom
145
184
  .children(element)
@@ -7,6 +7,23 @@ import { validateNativeElement } from '../core/native-validation.js';
7
7
 
8
8
  // Registry
9
9
 
10
+ /** Type-only capability for constructors whose options come from instance fields. */
11
+ export declare const creationOptions: unique symbol;
12
+ export type CreationOptions<Options, Instance> = Instance extends {
13
+ readonly [creationOptions]: infer Fields;
14
+ }
15
+ ? Fields
16
+ : Options;
17
+
18
+ /**
19
+ * Selects a subclass for elements that share a tag, by the exact value of one `data-*`
20
+ * attribute. Instances always carry that value, and it can't change.
21
+ */
22
+ export interface ElementVariant {
23
+ readonly attribute: string;
24
+ readonly value: string;
25
+ }
26
+
10
27
  export interface ElementClass<Options = any, Result extends AnyNode = AnyNode> {
11
28
  readonly tag: string;
12
29
  readonly attributeNames: readonly string[];
@@ -15,6 +32,10 @@ export interface ElementClass<Options = any, Result extends AnyNode = AnyNode> {
15
32
  readonly childTags?: readonly string[] | undefined;
16
33
  readonly forbiddenDescendantTags?: readonly string[] | undefined;
17
34
  readonly opaqueContent?: boolean;
35
+ /** Registers the class for one attribute value of its tag instead of the whole tag. */
36
+ readonly variant?: ElementVariant | undefined;
37
+ /** Children belong to the class: outside structural edits throw. */
38
+ readonly ownsChildren?: boolean;
18
39
  [validateAttributeValue]?(name: string, value: AttributeValue): void;
19
40
  [validateNativeElement]?(element: SVGElement): void;
20
41
  new (context: DrawingContext, options: Options, existing?: any): Result;
@@ -1,9 +1,10 @@
1
1
  import type { AnyNode } from '../core/types.js';
2
2
  import { Attributes } from '../dom/attributes.js';
3
3
  import type { DomFactory } from '../dom/create.js';
4
- import type { MarkupNode } from '../dom/types.js';
4
+ import type { MarkupElement } from '../dom/types.js';
5
5
  import { Guards } from '../mixins/guards.js';
6
6
  import type { Registry } from '../services/registry.js';
7
+ import type { ElementClass } from '../services/types.js';
7
8
  import type { TemplateValue } from './svg-tag.js';
8
9
  import type { TextBinding } from '../text/internals.js';
9
10
  import { classifyReference } from '../resources/references.js';
@@ -15,21 +16,36 @@ export interface TemplateDescription {
15
16
  readonly attributes: Readonly<Record<string, readonly PartValue[]>>;
16
17
  readonly children: readonly TemplateDescription[];
17
18
  readonly text: readonly PartValue[];
18
- readonly content: readonly (TemplateDescription | { readonly parts: readonly PartValue[] })[];
19
+ readonly content: readonly ContentItem[];
19
20
  }
20
21
 
22
+ /** An element, or a text run whose number parts are hole indexes (child holes outside text). */
23
+ export type ContentItem = TemplateDescription | { readonly parts: readonly PartValue[] };
24
+
21
25
  export class TemplateCompiler {
22
- private static readonly cache = new WeakMap<
23
- TemplateStringsArray,
24
- readonly TemplateDescription[]
25
- >();
26
+ private static readonly cache = new WeakMap<TemplateStringsArray, readonly ContentItem[]>();
26
27
  private readonly counts: number[];
27
28
 
28
29
  private constructor(strings: TemplateStringsArray) {
29
30
  this.counts = new Array<number>(strings.length - 1).fill(0);
30
31
  }
31
32
 
33
+ /** Top-level elements; templates with top-level child holes need `content()`. */
32
34
  static compile(dom: DomFactory, strings: TemplateStringsArray): readonly TemplateDescription[] {
35
+ const content = TemplateCompiler.content(dom, strings);
36
+ if (
37
+ content.some(
38
+ (item) =>
39
+ !('tag' in item) &&
40
+ item.parts.some((part) => part !== '' && (typeof part == 'number' || part.trim())),
41
+ )
42
+ )
43
+ throw new TypeError('Template text must be inside a text element');
44
+ return content.filter((item): item is TemplateDescription => 'tag' in item);
45
+ }
46
+
47
+ /** Top-level content in order: elements and text runs that may hold child holes. */
48
+ static content(dom: DomFactory, strings: TemplateStringsArray): readonly ContentItem[] {
33
49
  const cached = TemplateCompiler.cache.get(strings);
34
50
  if (cached) return cached;
35
51
  if (strings.some((part) => /rasamHole\d+End/.test(part)))
@@ -38,51 +54,55 @@ export class TemplateCompiler {
38
54
  const source = strings
39
55
  .map((part, index) => part + (index < strings.length - 1 ? `rasamHole${index}End` : ''))
40
56
  .join('');
41
- const result: TemplateDescription[] = [];
42
- for (const node of dom.parse(source)) {
43
- const description = compiler.describe(node);
44
- if (description) result.push(description);
45
- }
57
+ const result: ContentItem[] = [];
58
+ for (const node of dom.parse(source))
59
+ result.push('text' in node ? { parts: compiler.split(node.text) } : compiler.describe(node));
46
60
  if (compiler.counts.some((count) => count !== 1))
47
61
  throw new TypeError('Bindings must appear in attribute values or text elements');
48
62
  TemplateCompiler.cache.set(strings, result);
49
63
  return result;
50
64
  }
51
65
 
66
+ /** `parent` is the container class that receives `content`, for its child rules. */
52
67
  static validate(
53
- descriptions: readonly TemplateDescription[],
68
+ content: readonly ContentItem[],
54
69
  registry: Registry,
55
70
  forbidden: ReadonlySet<string> = new Set(),
71
+ parent?: ElementClass,
56
72
  ): void {
57
- for (const description of descriptions) {
73
+ for (const description of content) {
74
+ if (!('tag' in description)) {
75
+ if (description.parts.some((part) => typeof part == 'string' && part.trim()))
76
+ throw new TypeError('Template text must be inside a text element');
77
+ continue;
78
+ }
58
79
  const Constructor = registry.get(description.tag);
59
80
  if (forbidden.has(description.tag))
60
81
  throw new TypeError('Nested links or forbidden descendants are unsupported');
61
82
  if (!Constructor) throw new TypeError(`Unsupported template tag: ${description.tag}`);
83
+ if (parent?.childTags && !parent.childTags.includes(description.tag))
84
+ throw new TypeError(`${parent.tag} only accepts ${parent.childTags.join(', ')} children`);
62
85
  for (const name of Object.keys(description.attributes)) {
63
86
  if (!Constructor.attributeNames.includes(name) && !/^(?:data|aria)-[\w-]+$/.test(name))
64
87
  throw new TypeError(`Unsupported template attribute: ${name}`);
65
88
  }
66
89
  if (description.children.length > 0 && !Constructor.isContainer)
67
90
  throw new TypeError('Only containers may have element children');
91
+ // Outside text elements, holes between children are child holes, so only containers take them.
68
92
  if (
69
- Constructor.childTags &&
70
- description.children.some((child) => !Constructor.childTags.includes(child.tag))
71
- )
72
- throw new TypeError(
73
- `${description.tag} only accepts ${Constructor.childTags.join(', ')} children`,
74
- );
75
- if (
76
- description.text.some((part) => typeof part === 'number' || part.trim()) &&
77
- !Constructor.isText
93
+ !Constructor.isText &&
94
+ description.text.some((part) =>
95
+ typeof part == 'number' ? !Constructor.isContainer : part.trim(),
96
+ )
78
97
  )
79
98
  throw new TypeError('Text bindings require a text element');
80
99
  TemplateCompiler.validate(
81
- description.children,
100
+ Constructor.isText ? description.children : description.content,
82
101
  registry,
83
102
  Constructor.forbiddenDescendantTags
84
103
  ? new Set([...forbidden, ...Constructor.forbiddenDescendantTags])
85
104
  : forbidden,
105
+ Constructor,
86
106
  );
87
107
  }
88
108
  }
@@ -101,11 +121,7 @@ export class TemplateCompiler {
101
121
  return parts;
102
122
  }
103
123
 
104
- private describe(node: MarkupNode): TemplateDescription | null {
105
- if ('text' in node) {
106
- if (node.text.trim()) throw new TypeError('Template text must be inside a text element');
107
- return null;
108
- }
124
+ private describe(node: MarkupElement): TemplateDescription {
109
125
  if (/rasamHole\d+End/.test(node.tag)) throw new TypeError('Dynamic tag names are unsupported');
110
126
  const attributes: Record<string, readonly PartValue[]> = {};
111
127
  for (const [rawName, value] of Object.entries(node.attributes)) {
@@ -121,7 +137,7 @@ export class TemplateCompiler {
121
137
  }
122
138
  const text: PartValue[] = [];
123
139
  const children: TemplateDescription[] = [];
124
- const content: (TemplateDescription | { readonly parts: readonly PartValue[] })[] = [];
140
+ const content: ContentItem[] = [];
125
141
  for (const child of node.children) {
126
142
  if ('text' in child) {
127
143
  const parts = this.split(child.text);
@@ -129,10 +145,8 @@ export class TemplateCompiler {
129
145
  content.push({ parts });
130
146
  } else {
131
147
  const description = this.describe(child);
132
- if (description) {
133
- children.push(description);
134
- content.push(description);
135
- }
148
+ children.push(description);
149
+ content.push(description);
136
150
  }
137
151
  }
138
152
  return { tag: node.tag, attributes, children, text, content };
@@ -140,8 +154,6 @@ export class TemplateCompiler {
140
154
  }
141
155
 
142
156
  export class BindingPart {
143
- private previous: string | undefined;
144
-
145
157
  constructor(
146
158
  private readonly node: AnyNode,
147
159
  private readonly name: string | null,
@@ -183,15 +195,18 @@ export class BindingPart {
183
195
  }
184
196
  }
185
197
 
186
- commit(value: string | undefined, initial = false): void {
187
- if (!initial && value === this.previous) return;
198
+ commit(value: string | undefined): void {
188
199
  if (this.name === null) {
189
200
  if (!Guards.isTextual(this.node))
190
201
  throw new TypeError('Text binding requires the Textual mixin');
202
+ if ((this.textBinding ? this.textBinding.value : this.node.text()) === (value ?? '')) return;
191
203
  if (this.textBinding) this.textBinding.set(value ?? '');
192
204
  else this.node.text(value ?? '');
193
- } else this.node.setAttributeValue(this.name, value);
194
- this.previous = value;
205
+ } else {
206
+ const current = this.node.getAttributeValue(this.name);
207
+ if ((current === undefined ? undefined : String(current)) === value) return;
208
+ this.node.setAttributeValue(this.name, value);
209
+ }
195
210
  }
196
211
 
197
212
  private static primitive(value: TemplateValue): string {
@@ -1,73 +1,180 @@
1
1
  import { mutation } from '../changes/mutation.js';
2
2
  import type { AnyNode } from '../core/types.js';
3
+ import { destroySubtrees } from '../core/destruction.js';
3
4
  import type { ParentNode } from '../mixins/types.js';
4
5
  import { Guards } from '../mixins/guards.js';
5
6
  import { Group } from '../elements/group.js';
6
7
  import type { Registry } from '../services/registry.js';
8
+ import type { ElementClass } from '../services/types.js';
7
9
  import { BindingPart, TemplateCompiler } from './parts.js';
8
- import type { TemplateDescription } from './parts.js';
9
- import type { SvgTemplate } from './svg-tag.js';
10
+ import type { ContentItem, TemplateDescription } from './parts.js';
11
+ import { SvgTemplate } from './svg-tag.js';
12
+ import type { TemplateValue } from './svg-tag.js';
10
13
  import { appendTextSlot, type TextBinding } from '../text/internals.js';
11
14
 
12
- export class TemplateInstance {
13
- readonly host: Group;
14
- readonly nodes: readonly AnyNode[];
15
- private readonly parts: BindingPart[] = [];
15
+ type Segment = AnyNode | ChildPart;
16
+ /** The nodes and child holes one template places in one parent, from index `base()`. */
17
+ interface Sequence {
18
+ parent: ParentNode;
19
+ readonly segments: Segment[];
20
+ base(): number;
21
+ }
22
+
23
+ const size = (segments: readonly Segment[]) =>
24
+ segments.reduce((total, segment) => total + (segment instanceof ChildPart ? segment.size : 1), 0);
25
+ const flat = (segments: readonly Segment[]): AnyNode[] =>
26
+ segments.flatMap((segment) => (segment instanceof ChildPart ? segment.nodes : [segment]));
27
+ /** Semantic disposal: unlike `destroy()`, undo can restore it and history isn't cleared. */
28
+ const dispose = (nodes: readonly AnyNode[]) => {
29
+ const live = nodes.filter((node) => !node.isDestroyed);
30
+ if (live.length) live[0][destroySubtrees](live);
31
+ };
32
+
33
+ /** Flattens a child hole's value into templates. */
34
+ function templates(value: TemplateValue, out: SvgTemplate[] = []): SvgTemplate[] {
35
+ if (value instanceof SvgTemplate) out.push(value);
36
+ else if (typeof value == 'object' && value && Symbol.iterator in value)
37
+ for (const item of value) templates(item, out);
38
+ else if (value != null && typeof value != 'boolean' && value !== '')
39
+ throw new TypeError('Child bindings take templates, iterables of them, or null');
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Validates and builds an update without changing the drawing: new items are built in
45
+ * detached `stagings` groups. The returned commit applies it.
46
+ */
47
+ type Prepare = (values: readonly TemplateValue[], stagings: Group[]) => () => void;
48
+
49
+ /** Prepares and commits, disposing the staging groups whether or not it succeeds. */
50
+ function commit(prepare: Prepare, values: readonly TemplateValue[]): void {
51
+ const stagings: Group[] = [];
52
+ try {
53
+ prepare(values, stagings)();
54
+ } finally {
55
+ dispose(stagings);
56
+ }
57
+ }
58
+
59
+ /** A hole between elements: the items it renders, reused by index while their literal matches. */
60
+ class ChildPart {
61
+ private items: Mount[] = [];
62
+ constructor(
63
+ private readonly sequence: Sequence,
64
+ private readonly hole: number,
65
+ private readonly registry: Registry,
66
+ ) {}
67
+
68
+ get size(): number {
69
+ return this.items.reduce((total, item) => total + item.size, 0);
70
+ }
71
+ get nodes(): AnyNode[] {
72
+ return this.items.flatMap((item) => item.nodes);
73
+ }
74
+ /** The index of this part's first node, or of `item`'s first node. */
75
+ private start(item?: Mount): number {
76
+ const { segments } = this.sequence;
77
+ let index = this.sequence.base() + size(segments.slice(0, segments.indexOf(this)));
78
+ for (const other of item ? this.items : []) {
79
+ if (other === item) break;
80
+ index += other.size;
81
+ }
82
+ return index;
83
+ }
84
+
85
+ readonly prepare: Prepare = (values, stagings) => {
86
+ const parent = this.sequence.parent;
87
+ const forbidden = new Set<string>();
88
+ for (let node: AnyNode | null = parent; node; node = node.parent)
89
+ for (const tag of (node.constructor as ElementClass).forbiddenDescendantTags ?? [])
90
+ forbidden.add(tag);
91
+ const plans = templates(values[this.hole]).map((template, index) => {
92
+ const item = this.items[index];
93
+ if (item?.template.strings === template.strings)
94
+ return { item, commit: item.prepare(template.values, stagings) };
95
+ const content = TemplateCompiler.content(parent.context.dom, template.strings);
96
+ TemplateCompiler.validate(
97
+ content,
98
+ this.registry,
99
+ forbidden,
100
+ parent.constructor as ElementClass,
101
+ );
102
+ const staging = new Group(parent.context);
103
+ stagings.push(staging);
104
+ return { item: new Mount(template, staging, this.registry, content) };
105
+ });
106
+ return () => {
107
+ const items = plans.map((plan) => plan.item);
108
+ dispose(this.items.filter((item) => !items.includes(item)).flatMap((item) => item.nodes));
109
+ this.items = items;
110
+ for (const { item, commit } of plans)
111
+ if (!commit) {
112
+ // Detach first, so history records new nodes rather than a move from staging.
113
+ for (const node of item.nodes) parent.add(node.remove());
114
+ item.sequence.parent = parent;
115
+ item.sequence.base = () => this.start(item);
116
+ }
117
+ for (const plan of plans) plan.commit?.();
118
+ let index = this.start();
119
+ for (const node of this.nodes) parent.add(node, index++);
120
+ };
121
+ };
122
+ }
123
+
124
+ /** One template's nodes and parts, mounted as a sequence inside a parent. */
125
+ class Mount {
126
+ readonly sequence: Sequence;
127
+ private readonly parts: (BindingPart | ChildPart)[] = [];
16
128
 
17
129
  constructor(
18
- parent: ParentNode,
19
130
  readonly template: SvgTemplate,
20
- registry: Registry,
21
- descriptions: readonly TemplateDescription[],
131
+ parent: ParentNode,
132
+ private readonly registry: Registry,
133
+ content: readonly ContentItem[],
22
134
  ) {
23
- this.host = new Group(parent.context);
24
- try {
25
- this.nodes = descriptions.map((description) =>
26
- this.instantiate(description, this.host, registry),
27
- );
28
- } catch (error) {
29
- this.host.destroy();
30
- throw error;
31
- }
135
+ this.sequence = { parent, segments: [], base: () => 0 };
136
+ this.fill(content, this.sequence);
32
137
  }
33
138
 
34
- get isDestroyed(): boolean {
35
- return this.host.isDestroyed;
139
+ get size(): number {
140
+ return size(this.sequence.segments);
141
+ }
142
+ get nodes(): AnyNode[] {
143
+ return flat(this.sequence.segments);
36
144
  }
37
145
 
38
- update(template: SvgTemplate): this {
39
- return mutation(this, () => {
40
- this.host.assertLive();
41
- if (template.strings !== this.template.strings)
42
- throw new TypeError('An instance can only update its original template');
43
- const changes = this.parts.map((part) => {
44
- const value = part.evaluate(template.values);
45
- part.validate(value);
46
- return { part, value };
47
- });
48
- this.host.batch(() => {
49
- for (const { part, value } of changes) part.commit(value);
50
- });
51
- return this;
146
+ /** Validates every binding first, so an update changes all or nothing. */
147
+ readonly prepare: Prepare = (values, stagings) => {
148
+ const commits = this.parts.map((part) => {
149
+ if (part instanceof ChildPart) return part.prepare(values, stagings);
150
+ const value = part.evaluate(values);
151
+ part.validate(value);
152
+ return () => part.commit(value);
52
153
  });
53
- }
154
+ return () => commits.forEach((commit) => commit());
155
+ };
54
156
 
55
- destroy(): void {
56
- this.host.destroy();
157
+ private fill(content: readonly ContentItem[], sequence: Sequence): void {
158
+ for (const item of content) {
159
+ if ('tag' in item) sequence.segments.push(this.element(item, sequence.parent));
160
+ else
161
+ for (const hole of item.parts)
162
+ if (typeof hole == 'number') {
163
+ const part = new ChildPart(sequence, hole, this.registry);
164
+ sequence.segments.push(part);
165
+ this.parts.push(part);
166
+ commit(part.prepare, this.template.values);
167
+ }
168
+ }
57
169
  }
58
-
59
- private instantiate(
60
- description: TemplateDescription,
61
- parent: ParentNode,
62
- registry: Registry,
63
- ): AnyNode {
64
- const node = registry.createEmpty(description.tag, parent.context);
170
+ private element(description: TemplateDescription, parent: ParentNode): AnyNode {
171
+ const node = this.registry.createEmpty(description.tag, parent.context);
65
172
  parent.add(node);
66
173
  for (const [name, values] of Object.entries(description.attributes))
67
174
  this.bind(new BindingPart(node, name, values));
68
175
  if (appendTextSlot in node && Guards.isParent(node)) {
69
176
  for (const part of description.content) {
70
- if ('tag' in part) this.instantiate(part, node, registry);
177
+ if ('tag' in part) this.element(part, node);
71
178
  else {
72
179
  const binding = (node[appendTextSlot] as (value: string) => TextBinding)('');
73
180
  this.bind(new BindingPart(node, null, part.parts, binding));
@@ -75,22 +182,69 @@ export class TemplateInstance {
75
182
  }
76
183
  return node;
77
184
  }
78
- if (description.text.length > 0 && Guards.isTextual(node))
79
- this.bind(new BindingPart(node, null, description.text));
185
+ if (Guards.isTextual(node)) {
186
+ if (description.text.length > 0) this.bind(new BindingPart(node, null, description.text));
187
+ } else if (Guards.isParent(node)) {
188
+ this.fill(description.content, { parent: node, segments: [], base: () => 0 });
189
+ return node;
190
+ }
80
191
  if (description.children.length > 0 && !Guards.isParent(node))
81
192
  throw new TypeError('Children require the Parent mixin');
82
- for (const child of description.children) this.instantiate(child, node as ParentNode, registry);
193
+ for (const child of description.children) this.element(child, node as ParentNode);
83
194
  return node;
84
195
  }
85
196
 
86
197
  private bind(part: BindingPart): void {
87
198
  const value = part.evaluate(this.template.values);
88
199
  part.validate(value);
89
- part.commit(value, true);
200
+ part.commit(value);
90
201
  this.parts.push(part);
91
202
  }
92
203
  }
93
204
 
205
+ export class TemplateInstance {
206
+ readonly host: Group;
207
+ private readonly mount: Mount;
208
+
209
+ constructor(
210
+ parent: ParentNode,
211
+ readonly template: SvgTemplate,
212
+ registry: Registry,
213
+ content: readonly ContentItem[],
214
+ ) {
215
+ this.host = new Group(parent.context);
216
+ try {
217
+ this.mount = new Mount(template, this.host, registry, content);
218
+ } catch (error) {
219
+ this.host.destroy();
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ /** The current top-level nodes, including those that child holes render. */
225
+ get nodes(): readonly AnyNode[] {
226
+ return this.mount.nodes;
227
+ }
228
+
229
+ get isDestroyed(): boolean {
230
+ return this.host.isDestroyed;
231
+ }
232
+
233
+ update(template: SvgTemplate): this {
234
+ return mutation(this, () => {
235
+ this.host.assertLive();
236
+ if (template.strings !== this.template.strings)
237
+ throw new TypeError('An instance can only update its original template');
238
+ this.host.batch(() => commit(this.mount.prepare, template.values));
239
+ return this;
240
+ });
241
+ }
242
+
243
+ destroy(): void {
244
+ this.host.destroy();
245
+ }
246
+ }
247
+
94
248
  export class Renderer {
95
249
  private static readonly renderers = new WeakMap<ParentNode, Renderer>();
96
250
  private instance: TemplateInstance | null = null;
@@ -126,12 +280,15 @@ export class Renderer {
126
280
  this.instance.template.strings === template.strings
127
281
  )
128
282
  return this.instance.update(template);
129
- const descriptions = TemplateCompiler.compile(this.parent.context.dom, template.strings);
130
- TemplateCompiler.validate(descriptions, this.registry);
131
- const instance = new TemplateInstance(this.parent, template, this.registry, descriptions);
283
+ const content = TemplateCompiler.content(this.parent.context.dom, template.strings);
284
+ TemplateCompiler.validate(content, this.registry);
285
+ const instance = new TemplateInstance(this.parent, template, this.registry, content);
132
286
  try {
133
- this.instance?.destroy();
134
- this.parent.add(instance.host);
287
+ // Replacing a template disposes the old one semantically, so undo restores it.
288
+ this.parent.context.changes.run(this.parent, () => {
289
+ if (this.instance) dispose([this.instance.host]);
290
+ this.parent.add(instance.host);
291
+ });
135
292
  } catch (error) {
136
293
  instance.destroy();
137
294
  throw error;