@rasamjs/core 0.2.0 → 0.3.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 (162) hide show
  1. package/API.md +14 -4
  2. package/CHANGELOG.md +59 -0
  3. package/COMPONENTS.md +229 -0
  4. package/README.md +39 -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 +22 -4
  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 +16 -2
  21. package/dist/changes/service.js.map +1 -1
  22. package/dist/components/changes.d.ts +34 -0
  23. package/dist/components/changes.d.ts.map +1 -0
  24. package/dist/components/changes.js +119 -0
  25. package/dist/components/changes.js.map +1 -0
  26. package/dist/components/component.d.ts +62 -0
  27. package/dist/components/component.d.ts.map +1 -0
  28. package/dist/components/component.js +355 -0
  29. package/dist/components/component.js.map +1 -0
  30. package/dist/components/decorators.d.ts +7 -0
  31. package/dist/components/decorators.d.ts.map +1 -0
  32. package/dist/components/decorators.js +81 -0
  33. package/dist/components/decorators.js.map +1 -0
  34. package/dist/components/metadata.d.ts +10 -0
  35. package/dist/components/metadata.d.ts.map +1 -0
  36. package/dist/components/metadata.js +85 -0
  37. package/dist/components/metadata.js.map +1 -0
  38. package/dist/components/registry.d.ts +24 -0
  39. package/dist/components/registry.d.ts.map +1 -0
  40. package/dist/components/registry.js +78 -0
  41. package/dist/components/registry.js.map +1 -0
  42. package/dist/components/types.d.ts +54 -0
  43. package/dist/components/types.d.ts.map +1 -0
  44. package/dist/components/types.js +2 -0
  45. package/dist/components/types.js.map +1 -0
  46. package/dist/components/view.d.ts +9 -0
  47. package/dist/components/view.d.ts.map +1 -0
  48. package/dist/components/view.js +104 -0
  49. package/dist/components/view.js.map +1 -0
  50. package/dist/components.d.ts +6 -0
  51. package/dist/components.d.ts.map +1 -0
  52. package/dist/components.js +5 -0
  53. package/dist/components.js.map +1 -0
  54. package/dist/core/node.d.ts +7 -0
  55. package/dist/core/node.d.ts.map +1 -1
  56. package/dist/core/node.js +30 -0
  57. package/dist/core/node.js.map +1 -1
  58. package/dist/interchange/clone.d.ts.map +1 -1
  59. package/dist/interchange/clone.js +3 -2
  60. package/dist/interchange/clone.js.map +1 -1
  61. package/dist/interchange/reconstruct.d.ts.map +1 -1
  62. package/dist/interchange/reconstruct.js +16 -12
  63. package/dist/interchange/reconstruct.js.map +1 -1
  64. package/dist/interchange/references.d.ts.map +1 -1
  65. package/dist/interchange/references.js +10 -4
  66. package/dist/interchange/references.js.map +1 -1
  67. package/dist/mixins/parent-internals.d.ts +6 -0
  68. package/dist/mixins/parent-internals.d.ts.map +1 -1
  69. package/dist/mixins/parent-internals.js +34 -0
  70. package/dist/mixins/parent-internals.js.map +1 -1
  71. package/dist/mixins/parent.d.ts.map +1 -1
  72. package/dist/mixins/parent.js +6 -1
  73. package/dist/mixins/parent.js.map +1 -1
  74. package/dist/mixins/types.d.ts +2 -2
  75. package/dist/mixins/types.d.ts.map +1 -1
  76. package/dist/resources/references.d.ts +2 -0
  77. package/dist/resources/references.d.ts.map +1 -1
  78. package/dist/resources/references.js +3 -3
  79. package/dist/resources/references.js.map +1 -1
  80. package/dist/services/catalog.d.ts.map +1 -1
  81. package/dist/services/catalog.js +5 -3
  82. package/dist/services/catalog.js.map +1 -1
  83. package/dist/services/defs-manager.d.ts +2 -0
  84. package/dist/services/defs-manager.d.ts.map +1 -1
  85. package/dist/services/defs-manager.js +4 -0
  86. package/dist/services/defs-manager.js.map +1 -1
  87. package/dist/services/element-key.d.ts +4 -0
  88. package/dist/services/element-key.d.ts.map +1 -0
  89. package/dist/services/element-key.js +12 -0
  90. package/dist/services/element-key.js.map +1 -0
  91. package/dist/services/initialization.d.ts +7 -0
  92. package/dist/services/initialization.d.ts.map +1 -0
  93. package/dist/services/initialization.js +27 -0
  94. package/dist/services/initialization.js.map +1 -0
  95. package/dist/services/registry.d.ts +8 -0
  96. package/dist/services/registry.d.ts.map +1 -1
  97. package/dist/services/registry.js +45 -10
  98. package/dist/services/registry.js.map +1 -1
  99. package/dist/services/types.d.ts +17 -0
  100. package/dist/services/types.d.ts.map +1 -1
  101. package/dist/template/parts.d.ts +12 -6
  102. package/dist/template/parts.d.ts.map +1 -1
  103. package/dist/template/parts.js +36 -29
  104. package/dist/template/parts.js.map +1 -1
  105. package/dist/template/render.d.ts +6 -7
  106. package/dist/template/render.d.ts.map +1 -1
  107. package/dist/template/render.js +188 -44
  108. package/dist/template/render.js.map +1 -1
  109. package/dist/template/svg-tag.d.ts +5 -1
  110. package/dist/template/svg-tag.d.ts.map +1 -1
  111. package/dist/template/svg-tag.js.map +1 -1
  112. package/dist/text/internals.d.ts +1 -0
  113. package/dist/text/internals.d.ts.map +1 -1
  114. package/dist/types/common.d.ts +27 -0
  115. package/dist/types/common.d.ts.map +1 -1
  116. package/package.json +156 -148
  117. package/reports/adoption.json +20 -20
  118. package/reports/animation-benchmark.json +612 -612
  119. package/reports/benchmark.json +40 -40
  120. package/reports/components-benchmark-before.json +10 -0
  121. package/reports/components-benchmark.json +10 -0
  122. package/reports/creation.json +26 -26
  123. package/reports/interchange-benchmark.json +85 -85
  124. package/reports/public-api-contract.json +334 -17
  125. package/reports/size-components.json +102 -0
  126. package/reports/size-parity.json +47 -34
  127. package/reports/size.json +6 -4
  128. package/reports/svgjs-comparison.json +3100 -3100
  129. package/reports/svgjs-comparison.md +16 -16
  130. package/reports/svgjs-parity-current.json +135 -42
  131. package/src/advanced.ts +1 -1
  132. package/src/catalog/fragment.ts +2 -2
  133. package/src/changes/derived.ts +19 -0
  134. package/src/changes/restore.ts +27 -4
  135. package/src/changes/service.ts +21 -6
  136. package/src/components/changes.ts +131 -0
  137. package/src/components/component.ts +401 -0
  138. package/src/components/decorators.ts +104 -0
  139. package/src/components/metadata.ts +111 -0
  140. package/src/components/registry.ts +79 -0
  141. package/src/components/types.ts +66 -0
  142. package/src/components/view.ts +123 -0
  143. package/src/components.ts +15 -0
  144. package/src/core/node.ts +32 -1
  145. package/src/interchange/clone.ts +3 -2
  146. package/src/interchange/reconstruct.ts +16 -12
  147. package/src/interchange/references.ts +12 -4
  148. package/src/mixins/parent-internals.ts +37 -0
  149. package/src/mixins/parent.ts +13 -4
  150. package/src/mixins/types.ts +2 -2
  151. package/src/resources/references.ts +5 -5
  152. package/src/services/catalog.ts +5 -3
  153. package/src/services/defs-manager.ts +5 -0
  154. package/src/services/element-key.ts +14 -0
  155. package/src/services/initialization.ts +25 -0
  156. package/src/services/registry.ts +47 -10
  157. package/src/services/types.ts +21 -0
  158. package/src/template/parts.ts +54 -39
  159. package/src/template/render.ts +211 -54
  160. package/src/template/svg-tag.ts +6 -1
  161. package/src/text/internals.ts +1 -0
  162. package/src/types/common.ts +29 -0
@@ -1,5 +1,5 @@
1
1
  import type { AnyNode } from '../core/types.js';
2
- import { classifyReference, resolveReference } from '../resources/references.js';
2
+ import { classifyReference, isLink, resolveReference } from '../resources/references.js';
3
3
  import { referenceValue } from '../services/reference-value.js';
4
4
  import { InterchangeDom } from './dom.js';
5
5
  import { XLINK_NAMESPACE } from '../catalog/namespaces.js';
@@ -10,10 +10,13 @@ export function dependencyRoots(source: AnyNode, references: 'typed' | 'local'):
10
10
  const pending = [...included];
11
11
  for (let index = 0; index < pending.length; index++) {
12
12
  const node = pending[index];
13
- const targets = new Set(node.references.values());
13
+ // Links are not dependencies: copying a connector never copies the shapes it joins.
14
+ const targets = new Set(
15
+ [...node.references].filter(([name]) => !isLink(name)).map(([, target]) => target),
16
+ );
14
17
  if (references === 'local')
15
18
  for (const [name, value] of node.attributes.entries()) {
16
- if (!classifyReference(name, value)) continue;
19
+ if (!classifyReference(name, value) || isLink(name)) continue;
17
20
  // Hyperlinks navigate; they are not rendering dependencies.
18
21
  if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
19
22
  const target = resolveReference(node, name);
@@ -87,10 +90,15 @@ export function restoreLocalReferences(nodes: readonly AnyNode[]): void {
87
90
  .map((node) => [String(node.getAttributeValue('id')), node]),
88
91
  );
89
92
  for (const node of all)
90
- for (const [name, value] of node.attributes.entries()) {
93
+ for (const [name, value] of [...node.attributes.entries()]) {
91
94
  const reference = classifyReference(name, value);
92
95
  if (!reference) continue;
93
96
  const target = ids.get(reference.id);
97
+ // A connector pasted without its target keeps that end loose.
98
+ if (!target && isLink(name)) {
99
+ node.setAttributeValue(name, undefined);
100
+ continue;
101
+ }
94
102
  if (!target)
95
103
  throw new TypeError(`Local reference is outside the imported forest: ${reference.id}`);
96
104
  if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
@@ -4,11 +4,48 @@ import { assertNotDestroying, pendingDestruction } from '../core/destruction.js'
4
4
  /** Structural preflight shared by Parent and the opt-in tree operations. */
5
5
  export const validateChild = Symbol('validateChild');
6
6
 
7
+ let internalStructure = 0;
8
+ const unlocked = new Set<AnyNode>();
9
+
10
+ /** @internal Core's own exact-tree paths (adoption, replay, rollback) bypass child ownership. */
11
+ export function withInternalStructure<Result>(callback: () => Result): Result {
12
+ internalStructure++;
13
+ try {
14
+ return callback();
15
+ } finally {
16
+ internalStructure--;
17
+ }
18
+ }
19
+
20
+ /** @internal Lets a node whose class owns its children change them inside the callback. */
21
+ export function withOwnedChildren<Result>(parent: AnyNode, callback: () => Result): Result {
22
+ if (unlocked.has(parent)) return callback();
23
+ unlocked.add(parent);
24
+ try {
25
+ return callback();
26
+ } finally {
27
+ unlocked.delete(parent);
28
+ }
29
+ }
30
+
31
+ /** Throws for outside structural edits of a node whose class owns its children. */
32
+ export function assertChildrenEditable(parent: AnyNode | null | undefined): void {
33
+ if (
34
+ parent &&
35
+ (parent.constructor as { readonly ownsChildren?: boolean }).ownsChildren &&
36
+ !internalStructure &&
37
+ !unlocked.has(parent) &&
38
+ !pendingDestruction.has(parent)
39
+ )
40
+ throw new Error('This node owns its children');
41
+ }
42
+
7
43
  export function assertCanAddChildren(parent: AnyNode): void {
8
44
  parent.assertLive();
9
45
  // Whole-drawing teardown already cleans late subscriptions and registrations.
10
46
  const drawingCloses = parent.context.root && pendingDestruction.has(parent.context.root);
11
47
  if (!drawingCloses) assertNotDestroying(parent);
48
+ assertChildrenEditable(parent);
12
49
  }
13
50
 
14
51
  export function validateIndex(index: number, length: number): void {
@@ -1,11 +1,16 @@
1
1
  import { mutation } from '../changes/mutation.js';
2
2
  import type { Node } from '../core/node.js';
3
3
  import type { AnyNode } from '../core/types.js';
4
- import type { ElementClass } from '../services/types.js';
4
+ import type { ElementClass, CreationOptions } from '../services/types.js';
5
5
  import { BBox } from '../math/bbox.js';
6
6
  import type { Bounds } from '../types/common.js';
7
7
  import type { MixinParent, NodeSelector } from './types.js';
8
- import { assertCanAddChildren, validateChild, validateIndex } from './parent-internals.js';
8
+ import {
9
+ assertCanAddChildren,
10
+ assertChildrenEditable,
11
+ validateChild,
12
+ validateIndex,
13
+ } from './parent-internals.js';
9
14
  import { assertNotDestroying } from '../core/destruction.js';
10
15
 
11
16
  export const Parent: MixinParent = (BaseClass) => {
@@ -50,6 +55,7 @@ export const Parent: MixinParent = (BaseClass) => {
50
55
  adopt(element: SVGElement): AnyNode {
51
56
  return mutation(this, () => {
52
57
  this.assertLive();
58
+ assertChildrenEditable(this);
53
59
  const siblings = this.context.dom.children(this.el);
54
60
  if (!siblings.includes(element))
55
61
  throw new Error('Element is not a native child of this parent');
@@ -93,6 +99,7 @@ export const Parent: MixinParent = (BaseClass) => {
93
99
  [validateChild](child: AnyNode): void {
94
100
  assertCanAddChildren(this);
95
101
  child.assertLive();
102
+ if (child.parent !== this) assertChildrenEditable(child.parent);
96
103
  assertNotDestroying(child);
97
104
  this.validateChildTag(this.context.dom.tag(child.el));
98
105
  if (child.context !== this.context) throw new Error('Cross-root moves are unsupported');
@@ -110,13 +117,13 @@ export const Parent: MixinParent = (BaseClass) => {
110
117
 
111
118
  create<Options, Child extends AnyNode>(
112
119
  Constructor: ElementClass<Options, Child>,
113
- options: Options,
120
+ options: NoInfer<CreationOptions<Options, Child>>,
114
121
  ): Child {
115
122
  return mutation(this, () => {
116
123
  assertCanAddChildren(this);
117
124
  this.validateChildTag(Constructor.tag);
118
125
  this.context.registry.register(Constructor);
119
- return this.add(new Constructor(this.context, options));
126
+ return this.add(new Constructor(this.context, options as Options));
120
127
  });
121
128
  }
122
129
 
@@ -128,6 +135,7 @@ export const Parent: MixinParent = (BaseClass) => {
128
135
 
129
136
  detachChild(child: AnyNode): void {
130
137
  return mutation(this, () => {
138
+ assertChildrenEditable(this);
131
139
  const index = this.#children.indexOf(child);
132
140
  if (index >= 0) {
133
141
  this.context.changes.touch(child);
@@ -139,6 +147,7 @@ export const Parent: MixinParent = (BaseClass) => {
139
147
  }
140
148
 
141
149
  private childrenChanged(): void {
150
+ this.context.changes.invalidateOrder(this);
142
151
  this.context.bounds.invalidate();
143
152
  this.context.defs.invalidateHost(this);
144
153
  }
@@ -16,7 +16,7 @@ import type { Text } from '../elements/text.js';
16
16
  import type { Use } from '../elements/use.js';
17
17
  import type { Matrix } from '../math/matrix.js';
18
18
  import type { Vec2 } from '../math/vec2.js';
19
- import type { ElementClass } from '../services/types.js';
19
+ import type { ElementClass, CreationOptions } from '../services/types.js';
20
20
  import type {
21
21
  CommonAttributes,
22
22
  Bounds,
@@ -62,7 +62,7 @@ export interface ParentFeature {
62
62
  detachChild(child: AnyNode): void;
63
63
  create<Options, Child extends AnyNode>(
64
64
  Constructor: ElementClass<Options, Child>,
65
- options: Options,
65
+ options: NoInfer<CreationOptions<Options, Child>>,
66
66
  ): Child;
67
67
  remove(): this;
68
68
  remove(child: AnyNode): this;
@@ -30,16 +30,16 @@ function localReference(
30
30
  }
31
31
  }
32
32
 
33
+ /** Connector ends link to their targets; the targets are not rendering resources. */
34
+ export const isLink = (attribute: string): boolean =>
35
+ attribute === 'data-rasam-connector-start' || attribute === 'data-rasam-connector-end';
36
+
33
37
  /** Classifies local fragments for lookup/remapping; it never acquires a typed binding. */
34
38
  export function classifyReference(attribute: string, value: unknown): LocalReference | null {
35
39
  attribute = Attributes.normalizeName(attribute);
36
40
  if (typeof value !== 'string') return null;
37
41
  const source = value.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '');
38
- if (
39
- attribute === 'href' ||
40
- attribute === 'data-rasam-connector-start' ||
41
- attribute === 'data-rasam-connector-end'
42
- ) {
42
+ if (attribute === 'href' || isLink(attribute)) {
43
43
  const match = /^#([^\s#<>"'()\\\u0000-\u001f\u007f]+)$/.exec(source);
44
44
  return localReference('href', match?.[1]);
45
45
  }
@@ -1,14 +1,16 @@
1
1
  import type { Registry } from './registry.js';
2
+ import { elementKey } from './element-key.js';
2
3
  import type { ElementClass } from './types.js';
3
4
 
4
5
  /** Preflight a per-drawing catalog before changing the registry. */
5
6
  export function registerElements(registry: Registry, elements: readonly ElementClass[]): void {
6
7
  const pending = new Map<string, ElementClass>();
7
8
  for (const Constructor of elements) {
8
- const previous = pending.get(Constructor.tag) ?? registry.get(Constructor.tag);
9
+ const key = elementKey(Constructor);
10
+ const previous = pending.get(key) ?? registry.registered(Constructor);
9
11
  if (previous && previous !== Constructor)
10
- throw new Error(`Tag already registered: ${Constructor.tag}`);
11
- pending.set(Constructor.tag, Constructor);
12
+ throw new Error(`${Constructor.variant ? 'Variant' : 'Tag'} already registered: ${key}`);
13
+ pending.set(key, Constructor);
12
14
  }
13
15
  for (const Constructor of pending.values()) registry.register(Constructor);
14
16
  }
@@ -56,6 +56,11 @@ export class DefsManager {
56
56
  return count;
57
57
  }
58
58
 
59
+ /** Nodes holding a typed reference to `target`, detached ones included, from the index. */
60
+ consumersOf(target: AnyNode): AnyNode[] {
61
+ return [...(this.consumersByTarget.get(target)?.keys() ?? [])];
62
+ }
63
+
59
64
  *geometryConsumers(target: AnyNode): IterableIterator<AnyNode> {
60
65
  for (const [consumer, attributes] of this.consumersByTarget.get(target) ?? [])
61
66
  if (attributes.has('href')) yield consumer;
@@ -0,0 +1,14 @@
1
+ import type { ElementClass } from './types.js';
2
+
3
+ /** A tag, or `tag[attribute="value"]` for a variant. Variants share the constructor map. */
4
+ export function elementKey(Constructor: ElementClass): string {
5
+ const variant = Constructor.variant;
6
+ if (!variant) return Constructor.tag;
7
+ if (
8
+ !/^data-[a-z0-9_.-]+$/.test(variant.attribute) ||
9
+ typeof variant.value != 'string' ||
10
+ !variant.value
11
+ )
12
+ throw new TypeError('Variants need a lowercase data-* attribute and a value');
13
+ return `${Constructor.tag}[${variant.attribute}="${variant.value}"]`;
14
+ }
@@ -0,0 +1,25 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+
3
+ /** Internal extension hook, after the entire native/reconstructed tree is available. */
4
+ export const initializeElement = Symbol('init');
5
+ type Initializable = AnyNode & { [initializeElement]?: () => void };
6
+ let pending: Initializable[] | undefined;
7
+
8
+ export function initializeNode(node: AnyNode): void {
9
+ if (pending) pending.push(node);
10
+ else (node as Initializable)[initializeElement]?.();
11
+ }
12
+
13
+ /** Delay hooks until clone/import has restored references and copied model features. */
14
+ export function withInitialization<Result>(callback: () => Result): Result {
15
+ if (pending) return callback();
16
+ pending = [];
17
+ try {
18
+ const result = callback();
19
+ // Initializers are idempotent; nested adoptions can append work while hooks run.
20
+ for (const node of pending) if (!node.isDestroyed) node[initializeElement]?.();
21
+ return result;
22
+ } finally {
23
+ pending = undefined;
24
+ }
25
+ }
@@ -4,6 +4,9 @@ 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';
9
12
 
@@ -11,6 +14,8 @@ export class Registry {
11
14
  private static readonly identity = new WeakMap<SVGElement, AnyNode>();
12
15
  private readonly wrappers = new Map<SVGElement, AnyNode>();
13
16
  private readonly constructors = new Map<string, ElementClass>();
17
+ /** Tags that have variants, with the attributes that select them. */
18
+ private readonly variantAttributes = new Map<string, Set<string>>();
14
19
 
15
20
  constructor(private readonly context: DrawingContext) {}
16
21
 
@@ -19,18 +24,45 @@ export class Registry {
19
24
  }
20
25
 
21
26
  register<Options, Result extends AnyNode>(Constructor: ElementClass<Options, Result>): this {
22
- const previous = this.constructors.get(Constructor.tag);
27
+ const key = elementKey(Constructor);
28
+ const previous = this.constructors.get(key);
23
29
  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);
30
+ if (previous)
31
+ throw new Error(`${Constructor.variant ? 'Variant' : 'Tag'} already registered: ${key}`);
32
+ this.constructors.set(key, Constructor);
33
+ const variant = Constructor.variant;
34
+ if (variant) {
35
+ const attributes = this.variantAttributes.get(Constructor.tag) ?? new Set<string>();
36
+ this.variantAttributes.set(Constructor.tag, attributes.add(variant.attribute));
37
+ }
27
38
  return this;
28
39
  }
29
40
 
41
+ /** The tag's base constructor, ignoring variants. */
30
42
  get(tag: string): ElementClass | undefined {
31
43
  return this.constructors.get(tag);
32
44
  }
33
45
 
46
+ /** The constructor registered for the same tag or variant as `Constructor`, if any. */
47
+ registered(Constructor: ElementClass): ElementClass | undefined {
48
+ return this.constructors.get(elementKey(Constructor));
49
+ }
50
+
51
+ variant(tag: string, attribute: string, value: string): ElementClass | undefined {
52
+ return this.constructors.get(`${tag}[${attribute}="${value}"]`);
53
+ }
54
+
55
+ /** The constructor for a native element: a matching variant, else the tag's base. */
56
+ resolve(element: SVGElement): ElementClass | undefined {
57
+ const tag = this.context.dom.tag(element);
58
+ for (const attribute of this.variantAttributes.get(tag) ?? []) {
59
+ const value = element.getAttribute(attribute);
60
+ const Constructor = value === null ? undefined : this.variant(tag, attribute, value);
61
+ if (Constructor) return Constructor;
62
+ }
63
+ return this.constructors.get(tag);
64
+ }
65
+
34
66
  /** Creates a fresh wrapper for binding initialization, without constructor defaults or mounting. */
35
67
  createEmpty(tag: string, context = this.context): AnyNode {
36
68
  const Constructor = this.get(tag);
@@ -61,6 +93,7 @@ export class Registry {
61
93
  for (const node of this.wrappers.values()) this.forget(node);
62
94
  this.wrappers.clear();
63
95
  this.constructors.clear();
96
+ this.variantAttributes.clear();
64
97
  }
65
98
 
66
99
  adopt(element: SVGElement, parent?: ParentNode): AnyNode {
@@ -77,14 +110,17 @@ export class Registry {
77
110
  ancestor;
78
111
  ancestor = this.context.dom.svgParent(ancestor)
79
112
  ) {
80
- const Constructor = this.get(this.context.dom.tag(ancestor));
113
+ const Constructor = this.resolve(ancestor);
81
114
  if (Constructor?.opaqueContent)
82
115
  throw new Error('Cannot adopt descendants across an opaque native boundary');
83
116
  for (const tag of Constructor?.forbiddenDescendantTags ?? []) forbidden.add(tag);
84
117
  }
85
118
  this.validateTree(element, forbidden);
86
- const wrapped = this.wrapTree(element);
87
- return parent ? parent.add(wrapped) : wrapped;
119
+ // Adoption reproduces a native tree exactly, so it may fill owned children.
120
+ return withInitialization(() => {
121
+ const wrapped = withInternalStructure(() => this.wrapTree(element));
122
+ return parent ? parent.add(wrapped) : wrapped;
123
+ });
88
124
  }
89
125
 
90
126
  private assertAdoptionParent(parent: ParentNode | undefined): void {
@@ -103,7 +139,7 @@ export class Registry {
103
139
  if (existing.context !== this.context) throw new Error('Cross-root adoption is unsupported');
104
140
  return;
105
141
  }
106
- const Constructor = this.get(tag);
142
+ const Constructor = this.resolve(element);
107
143
  if (!this.context.dom.isSvg(element) || !Constructor)
108
144
  throw new TypeError(`Unsupported SVG tag: ${tag}`);
109
145
  Constructor[validateNativeElement]?.(element);
@@ -130,16 +166,17 @@ export class Registry {
130
166
  private wrapTree(element: SVGElement): AnyNode {
131
167
  const existing = Registry.wrapper(element);
132
168
  if (existing) return existing;
133
- const Constructor = this.get(this.context.dom.tag(element));
169
+ const Constructor = this.resolve(element)!;
134
170
  const children = this.managedChildren(element);
135
171
  const node = new Constructor(this.context, {}, element);
136
172
  if (Constructor.isContainer)
137
173
  (node as ParentNode).adoptChildren(children.map((child) => this.wrapTree(child)));
174
+ initializeNode(node);
138
175
  return node;
139
176
  }
140
177
 
141
178
  private managedChildren(element: SVGElement): SVGElement[] {
142
- const Constructor = this.get(this.context.dom.tag(element));
179
+ const Constructor = this.resolve(element);
143
180
  if (Constructor?.opaqueContent) return [];
144
181
  return this.context.dom
145
182
  .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 {