@b9g/crank 0.7.0 → 0.7.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.
package/crank.d.ts CHANGED
@@ -193,17 +193,81 @@ declare class Retainer<TNode, TScope = unknown> {
193
193
  constructor(el: Element);
194
194
  }
195
195
  /**
196
- * Interface for adapting the rendering process to a specific target
197
- * environment. This interface is implemented by Renderer subclasses and passed
198
- * to the Renderer constructor.
196
+ * Interface for adapting the rendering process to a specific target environment.
197
+ *
198
+ * The RenderAdapter defines how Crank elements are mapped to nodes in your target
199
+ * rendering environment (DOM, Canvas, WebGL, Terminal, etc.). Each method handles
200
+ * a specific part of the element lifecycle, from creation to removal.
201
+ *
202
+ * @template TNode - The type representing a node in your target environment
203
+ * @template TScope - Additional context data passed down the component tree
204
+ * @template TRoot - The type of the root container (defaults to TNode)
205
+ * @template TResult - The type returned when reading element values (defaults to ElementValue<TNode>)
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const adapter: RenderAdapter<MyNode, MyScope> = {
210
+ * create: ({ tag, props }) => new MyNode(tag, props),
211
+ * patch: ({ node, props }) => node.update(props),
212
+ * arrange: ({ node, children }) => node.replaceChildren(children),
213
+ * // ... other methods
214
+ * };
215
+ * ```
199
216
  */
200
217
  export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined = TNode, TResult = ElementValue<TNode>> {
218
+ /**
219
+ * Creates a new node for the given element tag and props.
220
+ *
221
+ * This method is called when Crank encounters a new element that needs to be
222
+ * rendered for the first time. You should create and return a node appropriate
223
+ * for your target environment.
224
+ *
225
+ * @param data.tag - The element tag (e.g., "div", "sprite", or a symbol)
226
+ * @param data.tagName - String representation of the tag for debugging
227
+ * @param data.props - The element's props object
228
+ * @param data.scope - Current scope context (can be undefined)
229
+ * @returns A new node instance
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * create: ({ tag, props, scope }) => {
234
+ * if (tag === "sprite") {
235
+ * return new PIXI.Sprite(props.texture);
236
+ * }
237
+ * throw new Error(`Unknown tag: ${tag}`);
238
+ * }
239
+ * ```
240
+ */
201
241
  create(data: {
202
242
  tag: string | symbol;
203
243
  tagName: string;
204
244
  props: Record<string, any>;
205
245
  scope: TScope | undefined;
206
246
  }): TNode;
247
+ /**
248
+ * Adopts existing nodes during hydration.
249
+ *
250
+ * Called when hydrating server-rendered content or reusing existing nodes.
251
+ * Should return an array of child nodes if the provided node matches the
252
+ * expected tag, or undefined if hydration should fail.
253
+ *
254
+ * @param data.tag - The element tag being hydrated
255
+ * @param data.tagName - String representation of the tag
256
+ * @param data.props - The element's props
257
+ * @param data.node - The existing node to potentially adopt
258
+ * @param data.scope - Current scope context
259
+ * @returns Array of child nodes to hydrate, or undefined if adoption fails
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * adopt: ({ tag, node }) => {
264
+ * if (node && node.tagName.toLowerCase() === tag) {
265
+ * return Array.from(node.children);
266
+ * }
267
+ * return undefined; // Hydration mismatch
268
+ * }
269
+ * ```
270
+ */
207
271
  adopt(data: {
208
272
  tag: string | symbol;
209
273
  tagName: string;
@@ -211,23 +275,124 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
211
275
  node: TNode | undefined;
212
276
  scope: TScope | undefined;
213
277
  }): Array<TNode> | undefined;
278
+ /**
279
+ * Creates or updates a text node.
280
+ *
281
+ * Called when rendering text content. Should create a new text node or
282
+ * update an existing one with the provided value.
283
+ *
284
+ * @param data.value - The text content to render
285
+ * @param data.scope - Current scope context
286
+ * @param data.oldNode - Previous text node to potentially reuse
287
+ * @param data.hydrationNodes - Nodes available during hydration
288
+ * @returns A text node containing the given value
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * text: ({ value, oldNode }) => {
293
+ * if (oldNode && oldNode.text !== value) {
294
+ * oldNode.text = value;
295
+ * return oldNode;
296
+ * }
297
+ * return new TextNode(value);
298
+ * }
299
+ * ```
300
+ */
214
301
  text(data: {
215
302
  value: string;
216
303
  scope: TScope | undefined;
217
304
  oldNode: TNode | undefined;
218
305
  hydrationNodes: Array<TNode> | undefined;
219
306
  }): TNode;
307
+ /**
308
+ * Computes scope context for child elements.
309
+ *
310
+ * Called to determine what scope context should be passed to child elements.
311
+ * The scope can be used to pass rendering context like theme, coordinate systems,
312
+ * or namespaces down the component tree.
313
+ *
314
+ * @param data.tag - The element tag
315
+ * @param data.tagName - String representation of the tag
316
+ * @param data.props - The element's props
317
+ * @param data.scope - Current scope context
318
+ * @returns New scope for children, or undefined to inherit current scope
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * scope: ({ tag, props, scope }) => {
323
+ * if (tag === "svg") {
324
+ * return { ...scope, namespace: "http://www.w3.org/2000/svg" };
325
+ * }
326
+ * return scope;
327
+ * }
328
+ * ```
329
+ */
220
330
  scope(data: {
221
331
  tag: string | symbol;
222
332
  tagName: string;
223
333
  props: Record<string, any>;
224
334
  scope: TScope | undefined;
225
335
  }): TScope | undefined;
336
+ /**
337
+ * Handles raw values (strings or nodes) that bypass normal element processing.
338
+ *
339
+ * Called when rendering Raw elements or other direct node insertions.
340
+ * Should convert string values to appropriate nodes for your environment.
341
+ *
342
+ * @param data.value - Raw string or node value to render
343
+ * @param data.scope - Current scope context
344
+ * @param data.hydrationNodes - Nodes available during hydration
345
+ * @returns ElementValue that can be handled by arrange()
346
+ *
347
+ * @example
348
+ * ```typescript
349
+ * raw: ({ value, scope }) => {
350
+ * if (typeof value === "string") {
351
+ * const container = new Container();
352
+ * container.innerHTML = value;
353
+ * return Array.from(container.children);
354
+ * }
355
+ * return value;
356
+ * }
357
+ * ```
358
+ */
226
359
  raw(data: {
227
360
  value: string | TNode;
228
361
  scope: TScope | undefined;
229
362
  hydrationNodes: Array<TNode> | undefined;
230
363
  }): ElementValue<TNode>;
364
+ /**
365
+ * Updates a node's properties.
366
+ *
367
+ * Called when element props change. Should efficiently update only the
368
+ * properties that have changed. This is where you implement prop-to-attribute
369
+ * mapping, event listener binding, and other property synchronization.
370
+ *
371
+ * @param data.tag - The element tag
372
+ * @param data.tagName - String representation of the tag
373
+ * @param data.node - The node to update
374
+ * @param data.props - New props object
375
+ * @param data.oldProps - Previous props object (undefined for initial render)
376
+ * @param data.scope - Current scope context
377
+ * @param data.copyProps - Props to skip (used for copying between renderers)
378
+ * @param data.isHydrating - Whether currently hydrating
379
+ * @param data.quietProps - Props to not warn about during hydration
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * patch: ({ node, props, oldProps }) => {
384
+ * for (const [key, value] of Object.entries(props)) {
385
+ * if (oldProps?.[key] !== value) {
386
+ * if (key.startsWith("on")) {
387
+ * node.addEventListener(key.slice(2), value);
388
+ * } else {
389
+ * node[key] = value;
390
+ * }
391
+ * }
392
+ * }
393
+ * }
394
+ * ```
395
+ */
231
396
  patch(data: {
232
397
  tag: string | symbol;
233
398
  tagName: string;
@@ -239,6 +404,32 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
239
404
  isHydrating: boolean;
240
405
  quietProps: Set<string> | undefined;
241
406
  }): void;
407
+ /**
408
+ * Arranges child nodes within their parent.
409
+ *
410
+ * Called after child elements are rendered to organize them within their
411
+ * parent node. Should efficiently insert, move, or remove child nodes to
412
+ * match the provided children array.
413
+ *
414
+ * @param data.tag - The parent element tag
415
+ * @param data.tagName - String representation of the tag
416
+ * @param data.node - The parent node
417
+ * @param data.props - The parent element's props
418
+ * @param data.children - Array of child nodes in correct order
419
+ * @param data.oldProps - Previous props (for reference)
420
+ *
421
+ * @example
422
+ * ```typescript
423
+ * arrange: ({ node, children }) => {
424
+ * // Remove existing children
425
+ * node.removeChildren();
426
+ * // Add new children in order
427
+ * for (const child of children) {
428
+ * node.addChild(child);
429
+ * }
430
+ * }
431
+ * ```
432
+ */
242
433
  arrange(data: {
243
434
  tag: string | symbol;
244
435
  tagName: string;
@@ -247,12 +438,73 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
247
438
  children: Array<TNode>;
248
439
  oldProps: Record<string, any> | undefined;
249
440
  }): void;
441
+ /**
442
+ * Removes a node from its parent.
443
+ *
444
+ * Called when an element is being unmounted. Should clean up the node
445
+ * and remove it from its parent if appropriate.
446
+ *
447
+ * @param data.node - The node to remove
448
+ * @param data.parentNode - The parent node
449
+ * @param data.isNested - Whether this is a nested removal (child of removed element)
450
+ *
451
+ * @example
452
+ * ```typescript
453
+ * remove: ({ node, parentNode, isNested }) => {
454
+ * // Clean up event listeners, resources, etc.
455
+ * node.cleanup?.();
456
+ * // Remove from parent unless it's a nested removal
457
+ * if (!isNested && parentNode.contains(node)) {
458
+ * parentNode.removeChild(node);
459
+ * }
460
+ * }
461
+ * ```
462
+ */
250
463
  remove(data: {
251
464
  node: TNode;
252
465
  parentNode: TNode;
253
466
  isNested: boolean;
254
467
  }): void;
468
+ /**
469
+ * Reads the final rendered value from an ElementValue.
470
+ *
471
+ * Called to extract the final result from rendered elements. This allows
472
+ * you to transform the internal node representation into the public API
473
+ * that users of your renderer will see.
474
+ *
475
+ * @param value - The ElementValue to read (array, single node, or undefined)
476
+ * @returns The public representation of the rendered value
477
+ *
478
+ * @example
479
+ * ```typescript
480
+ * read: (value) => {
481
+ * if (Array.isArray(value)) {
482
+ * return value.map(node => node.publicAPI);
483
+ * }
484
+ * return value?.publicAPI;
485
+ * }
486
+ * ```
487
+ */
255
488
  read(value: ElementValue<TNode>): TResult;
489
+ /**
490
+ * Performs final rendering to the root container.
491
+ *
492
+ * Called after the entire render cycle is complete. This is where you
493
+ * trigger the actual rendering/presentation in your target environment
494
+ * (e.g., calling render() on a canvas, flushing to the screen, etc.).
495
+ *
496
+ * @param root - The root container
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * finalize: (root) => {
501
+ * // Trigger actual rendering
502
+ * if (root instanceof PIXIApplication) {
503
+ * root.render();
504
+ * }
505
+ * }
506
+ * ```
507
+ */
256
508
  finalize(root: TRoot): void;
257
509
  }
258
510
  /**
package/crank.js CHANGED
@@ -1,102 +1,6 @@
1
1
  /// <reference types="crank.d.ts" />
2
2
  import { removeEventTargetDelegates, addEventTargetDelegates, clearEventListeners, CustomEventTarget } from './event-target.js';
3
-
4
- function wrap(value) {
5
- return value === undefined ? [] : Array.isArray(value) ? value : [value];
6
- }
7
- function unwrap(arr) {
8
- return arr.length === 0 ? undefined : arr.length === 1 ? arr[0] : arr;
9
- }
10
- /**
11
- * Ensures a value is an array.
12
- *
13
- * This function does the same thing as wrap() above except it handles nulls
14
- * and iterables, so it is appropriate for wrapping user-provided element
15
- * children.
16
- */
17
- function arrayify(value) {
18
- return value == null
19
- ? []
20
- : Array.isArray(value)
21
- ? value
22
- : typeof value === "string" ||
23
- typeof value[Symbol.iterator] !== "function"
24
- ? [value]
25
- : [...value];
26
- }
27
- function isIteratorLike(value) {
28
- return value != null && typeof value.next === "function";
29
- }
30
- function isPromiseLike(value) {
31
- return value != null && typeof value.then === "function";
32
- }
33
- function createRaceRecord(contender) {
34
- const deferreds = new Set();
35
- const record = { deferreds, settled: false };
36
- // This call to `then` happens once for the lifetime of the value.
37
- Promise.resolve(contender).then((value) => {
38
- for (const { resolve } of deferreds) {
39
- resolve(value);
40
- }
41
- deferreds.clear();
42
- record.settled = true;
43
- }, (err) => {
44
- for (const { reject } of deferreds) {
45
- reject(err);
46
- }
47
- deferreds.clear();
48
- record.settled = true;
49
- });
50
- return record;
51
- }
52
- // Promise.race is memory unsafe. This is alternative which is. See:
53
- // https://github.com/nodejs/node/issues/17469#issuecomment-685235106
54
- // Keys are the values passed to race.
55
- // Values are a record of data containing a set of deferreds and whether the
56
- // value has settled.
57
- const wm = new WeakMap();
58
- function safeRace(contenders) {
59
- let deferred;
60
- const result = new Promise((resolve, reject) => {
61
- deferred = { resolve, reject };
62
- for (const contender of contenders) {
63
- if (!isPromiseLike(contender)) {
64
- // If the contender is a not a then-able, attempting to use it as a key
65
- // in the weakmap would throw an error. Luckily, it is safe to call
66
- // `Promise.resolve(contender).then` on regular values multiple
67
- // times because the promise fulfills immediately.
68
- Promise.resolve(contender).then(resolve, reject);
69
- continue;
70
- }
71
- let record = wm.get(contender);
72
- if (record === undefined) {
73
- record = createRaceRecord(contender);
74
- record.deferreds.add(deferred);
75
- wm.set(contender, record);
76
- }
77
- else if (record.settled) {
78
- // If the value has settled, it is safe to call
79
- // `Promise.resolve(contender).then` on it.
80
- Promise.resolve(contender).then(resolve, reject);
81
- }
82
- else {
83
- record.deferreds.add(deferred);
84
- }
85
- }
86
- });
87
- // The finally callback executes when any value settles, preventing any of
88
- // the unresolved values from retaining a reference to the resolved value.
89
- return result.finally(() => {
90
- for (const contender of contenders) {
91
- if (isPromiseLike(contender)) {
92
- const record = wm.get(contender);
93
- if (record) {
94
- record.deferreds.delete(deferred);
95
- }
96
- }
97
- }
98
- });
99
- }
3
+ import { isPromiseLike, unwrap, wrap, arrayify, safeRace, isIteratorLike } from './_utils.js';
100
4
 
101
5
  const NOOP = () => { };
102
6
  function getTagName(tag) {