diff --git a/package.json b/package.json index 34d3afc3..b586b5e1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ ".": "./src/index.js", "./fn": "./src/index-fn.js", "./plugins": "./src/plugins/index.js", - "./plugins/fn": "./src/plugins/index-fn.js" + "./plugins/fn": "./src/plugins/index-fn.js", + "./props": "./src/plugins/props/index.js" }, "repository": { "type": "git", diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index d539e664..00c5f90d 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -133,14 +133,19 @@ The `type` property can also take an object that sets both the type (via the `is listed below. All type options are optional. -| Property | Type | Applies to | Description | -| -------------- | ------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| `is` | `Function` | `string` | `object` | _(All)_ | The type of the property. | -| `values` | `Function` | Lists (`Array`, `Set`), Dictionaries (`Object`, `Map`) | The type of the items in the list. | -| `keys` | `Function` | `Map` | The type of the keys in the dictionary. | -| `defaultKey` | `Function` | Dictionaries (`Object`, `Map`) | Default key for entries with no label. | -| `defaultValue` | (any) | Dictionaries (`Object`, `Map`) | Default value for entries with no label. Ignored if `defaultKey` is set. Default: `true` | -| `arguments` | `string[]` | `Function` | The names of the arguments of the function. Default: `[]` (no arguments) | +| Property | Type | Applies to | Description | +| -------------- | ------------------------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `is` | `Function` | `string` | `object` | _(All)_ | The type of the property. | +| `values` | `Function` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | The type of the items in the list. | +| `keys` | `Function` | Dictionaries (`Object`, `Map`) | The type of the keys in the dictionary. | +| `separator` | `string` | `RegExp` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | Separator between items when parsing strings. Default: `,` (pair-aware). | +| `joiner` | `string` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | String used between items when stringifying. Defaults to a normalized form of `separator`. | +| `pairs` | `object` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | Override the pair-aware splitter's bracket/quote table. | +| `defaultKey` | `Function` | Dictionaries (`Object`, `Map`) | Default key for entries with no label. | +| `defaultValue` | (any) | Dictionaries (`Object`, `Map`) | Default value for entries with no label. Ignored if `defaultKey` is set. Default: `true` | +| `arguments` | `string[]` | `Function` | The names of the arguments of the function. Default: `[]` (no arguments) | + +See the [PropTypes reference](./types/README.md#built-in-types) for the full per-type breakdown. #### Default key/value in dictionaries @@ -153,6 +158,64 @@ While `defaultKey` _can_ be a non-function, this is almost never what you want, If `defaultValue` is provided, singular entries are considered keys, and `defaultValue` is used to generate the values. It can be either a constant (e.g. `true`) or a function, in which case it’s passed the key and the index as arguments. +#### Custom types + +Types are _instances_ of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any `equals` / `parse` / `stringify` overrides, and any additional type options they may use (e.g. Iterables use a `separator` option as well). + +Types without an `is` property are _abstract_ — they don't correspond to a specific JS constructor, but just define behavior that concrete types can inherit via the JS prototype chain. `Iterable` is a current example (though in the future it may use `is: Iterator`). + +Most constructors do not actually need registering. +For example, consider [Color.js](https://colorjs.io/) `Color` objects. + +It may be tempting to do something like this: + +```js +import { PropType } from "nude-element/props"; + +// ❌ Don't do this +PropType.register({ + is: Color, + parse: value => (value instanceof Color ? value : new Color(value)), + equals: (a, b) => a === b || a?.equals?.(b), + stringify: value => value?.toString(), +}); +``` + +However, none of this is needed: + +- `parse()` automatically constructs an object of type `type.is` and the `Color` constructor already accepts strings +- `Color` objects already have a good `toString()` method, which is called automatically +- `equals()` already checks `a === b` and uses `a.equals(b)` if such a method is available. + +Using `type: Color` in the prop definition is enough to get all the benefits of type-aware parsing, stringifying, and equality checking for free. + +For custom types that represent more complex objects, you may want to register them as extending an existing type, e.g. `Iterable` for any list of values, or `Map` for any key→value mapping. + +```js +import { PropType } from "nude-element/props"; + +PropType.register({ + is: Tuple, + extends: "Iterable", +}); +``` + +**Derivative types.** A type spec with options beyond `is` produces a _derivative_ — a new `PropType` instance whose prototype chain points to the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. + +```js +import { PropType } from "nude-element/props"; + +const NumberArray = PropType.for({ is: Array, values: Number }); + +static props = { + points: { type: NumberArray }, +}; +``` + +Inline specs in prop definitions work the same way — each occurrence produces its own derivative. Hoist a derivative into a `const` (as above) if you want every prop using it to share the same instance. + +For the full spec-key reference, the abstract-type helper methods (`parseItems`, `parseEntries`), and the public API surface, see [`types`](./types/README.md). + ### Attribute-property reflection The `reflect` property takes the following values: diff --git a/src/plugins/props/index.js b/src/plugins/props/index.js index 47a193c7..4b7a1541 100644 --- a/src/plugins/props/index.js +++ b/src/plugins/props/index.js @@ -1,11 +1,17 @@ import Props from "./util/Props.js"; +import Prop from "./util/Prop.js"; import ElementProps from "./util/ElementProps.js"; +import ElementProp from "./util/ElementProp.js"; import { symbols } from "xtensible"; import { defineOwnProperty, getSuperMethod } from "xtensible/util"; import { defineLazyProperty } from "../../util/lazy.js"; +import PropType from "./util/PropType.js"; +import "./types/index.js"; export const { props } = symbols.known; +export { PropType, Props, Prop, ElementProps, ElementProp }; + const hooks = { setup () { if (Object.hasOwn(this, "props")) { diff --git a/src/plugins/props/types/README.md b/src/plugins/props/types/README.md new file mode 100644 index 00000000..ff25c48e --- /dev/null +++ b/src/plugins/props/types/README.md @@ -0,0 +1,81 @@ +# PropTypes — reference + +The high-level "what's a PropType / how do I register one" walkthrough lives in [the props README](../README.md#custom-types). This document is the reference: built-in types, spec-key catalog, abstract internals, and the public API surface. + +## Built-in types + +| Type | Spec keys (besides `is`) | Notes | +| ---------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `Boolean` | — | Presence-based: `null` → `null`, any non-null → `true`. | +| `Number` | — | Parses via `Number(value)`. `equals` treats `NaN` as equal to `NaN`. | +| `Function` | `arguments` | Parses to a `Function` constructed from the string body. Stringify throws. | +| `Array` | `values`, `separator`, `joiner`, `pairs` | Splits on `,` (pair-aware: parens, brackets, braces, quotes). | +| `Set` | `values`, `separator`, `joiner`, `pairs` | Same parsing as `Array`, materialized into a `Set`. | +| `Map` | `keys`, `values`, `separator`, `defaultKey`, `defaultValue`, `pairs` | Splits entries on `,` then each on `:`. | +| `Object` | same as `Map` | Same parsing pipeline, materialized into a plain object. | + +All built-ins can be accessed via `PropType.for(name)` (see [props README](../README.md#custom-types) for usage). + +## Spec keys + +| Key | Role | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `is` | JS constructor this type produces. Doubles as registry key. Optional for abstracts. | +| `extends` | Explicit chain parent (a `PropType` instance, or a `name` string). Lets the parent differ from `registry.get(is)` — that's what decouples "what JS constructor does this produce" from "what behavior does this share." | +| `name` | Registry key for abstracts that have no `is`. | +| `subTypes` | Spec keys whose values are themselves type specs (`["values"]` for Iterable, `["keys", "values"]` for Map). Resolved to `PropType` instances at construction; unspecified ones default to `PropType.any`. Declared by the abstract; descendants inherit it via the prototype chain, and a descendant that redeclares it replaces (not extends) the inherited list. | +| `equals(a, b)` | Equality. Default short-circuits null and identity, then walks the chain. | +| `parse(value)` | Parse a raw input. Default passes `null` through and walks the chain. | +| `stringify(value)` | Stringify (returns `null` for null/undefined to signal attribute removal). | +| any other method | Auto-wrapped at construction into a super-walking dispatcher, callable as `this.x(…)` from anywhere in the chain. | + +## Abstract helpers + +| Method | Yields | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Iterable.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | +| `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | + +Both are generators. Concrete types consume them with the appropriate terminal container (spread into an array, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. + +To call a parent's method from inside an override, use `this.super.method(…)`. The `super` proxy walks the chain looking for the next implementation while keeping `this` bound to the derivative, so your override still sees its own `this.values` / `this.separator`. It also goes through the same `get_` wrappers as a normal call (e.g. the null-handling in `get_parse`), unlike a direct `ParentType.spec.method.call(this, …args)`. + +## A parametrized custom type + +For something more involved than the simple [`Color` example](../README.md#custom-types), here's a `Length` type that accepts a `unit` option, demonstrating how a single registration becomes the basis for many derivatives: + +```js +import { PropType } from "nude-element/props"; + +PropType.register({ + is: Length, + parse (value) { + let unit = this.unit ?? "px"; + return value instanceof Length ? value : new Length(value, unit); + }, + stringify: value => value?.toString(), +}); + +const Pixels = PropType.for({ is: Length, unit: "px" }); +const Rems = PropType.for({ is: Length, unit: "rem" }); + +static props = { + width: { type: Pixels }, + margin: { type: Rems }, +}; +``` + +`Pixels` and `Rems` are distinct PropType instances sharing the same `parse` (inherited via the prototype chain from the registered `Length` singleton), but each reads its own `this.unit` (every spec key is lifted onto the instance by `init()`). + +## Public API + +| Method | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------ | +| `PropType.for(input, { fallback })` | Universal resolver: `PropType` instance, constructor, string, or spec object → `PropType`. | +| `PropType.register(spec)` | Register a built-in or custom type. Returns the registered instance. | +| `instance.isA(otherType)` | Walk the chain looking for `otherType`. Replaces `instanceof` for abstract-type checks. | +| `PropType.any` | The generic fallback `PropType`. Used as the default for unspecified sub-types. | + +## Architecture in one paragraph + +`PropType` is the sole class. Built-in types are registered singletons stored in a single map keyed on JS constructor (for concretes) or string `name` (for abstracts). Derivatives are `Object.create(parent)`, so option lookup walks the JS prototype chain — no spec merging, no copies. The dispatcher walks `obj.super` for each method (`equals` / `parse` / `stringify` plus any custom helpers), invoking the first `spec[method]` it finds with `this` bound to the original caller. Abstracts publish their helpers (`parseItems`, `parseEntries`) the same way, so descendants invoke them as plain `this.x(…)`. diff --git a/src/plugins/props/types/array.js b/src/plugins/props/types/array.js new file mode 100644 index 00000000..704ea160 --- /dev/null +++ b/src/plugins/props/types/array.js @@ -0,0 +1,10 @@ +import PropType from "../util/PropType.js"; +import Iterable from "./iterable.js"; + +export default PropType.register({ + is: Array, + extends: Iterable, + parse (value) { + return [...this.parsedItems(value)]; + }, +}); diff --git a/src/plugins/props/types/basic.js b/src/plugins/props/types/basic.js deleted file mode 100644 index f9c937c6..00000000 --- a/src/plugins/props/types/basic.js +++ /dev/null @@ -1,67 +0,0 @@ -import { resolve } from "../util/types.js"; - -const callableBuiltins = new Set([ - String, - Number, - Boolean, - Array, - Object, - Function, - Symbol, - BigInt, -]); - -export const generic = { - equals (a, b) { - let simpleEquals = a === b; - if (simpleEquals || a === null || a === undefined || b === null || b === undefined) { - return simpleEquals; - } - - if (typeof a.equals === "function") { - return a.equals(b); - } - - // Roundtrip - return simpleEquals; - }, - parse (value, type) { - let { is: Type, ...typeOptions } = resolve(type); - if (!Type || value instanceof Type) { - return value; - } - - return callableBuiltins.has(Type) ? Type(value) : new Type(value); - }, - - stringify (value) { - return String(value); - }, -}; - -export const boolean = { - type: Boolean, - parse: value => value !== null, - stringify: value => (value ? "" : null), -}; - -export const number = { - type: Number, - equals: (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b)), -}; - -export const fn = { - type: Function, - equals: (a, b) => a === b || a.toString() === b.toString(), - parse (value, options = {}) { - if (typeof value === "function") { - return value; - } - - value = String(value); - - return Function(...(options.arguments ?? []), value); - }, - // Just don’t do that - stringify: false, -}; diff --git a/src/plugins/props/types/boolean.js b/src/plugins/props/types/boolean.js new file mode 100644 index 00000000..a605d3af --- /dev/null +++ b/src/plugins/props/types/boolean.js @@ -0,0 +1,11 @@ +import PropType from "../util/PropType.js"; + +export default PropType.register({ + is: Boolean, + parse (value) { + return value !== null; + }, + stringify (value) { + return value ? "" : null; + }, +}); diff --git a/src/plugins/props/types/dictionaries.js b/src/plugins/props/types/dictionaries.js deleted file mode 100644 index 9fa16832..00000000 --- a/src/plugins/props/types/dictionaries.js +++ /dev/null @@ -1,168 +0,0 @@ -import { resolveValue, split } from "./util.js"; -import { parse, stringify, equals } from "../util/types.js"; - -function parseEntries ( - value, - { values, keys, separator = ", ", defaultValue = true, defaultKey, pairs } = {}, -) { - let entries = value; - - if (typeof value === "string") { - entries = split(value, { separator, pairs }); - - entries = entries.map((entry, index) => { - let parts = entry.split(/(?= 2) { - // Value contains colons - key = parts.shift(); - value = parts.join(":"); - } - else if (parts.length === 1) { - if (defaultKey) { - value = parts[0]; - key = resolveValue(defaultKey, [null, value, index]); - } - else { - key = parts[0]; - value = resolveValue(defaultValue, [null, key, index]); - } - } - - [key, value] = [key, value].map(v => v?.trim?.() ?? v); - - if (value === "false") { - value = false; - } - - return [key, value]; - }); - } - - entries = entries.map(([key, value]) => { - if (keys) { - key = parse(key, keys); - } - - if (values) { - value = parse(value, values); - } - - return [key, value]; - }); - - return entries; -} - -export const object = { - type: Object, - equals (a, b, { values } = {}) { - let aKeys = Object.keys(a); - let bKeys = Object.keys(b); - - if (aKeys.length !== bKeys.length) { - return false; - } - - return aKeys.every(key => equals(a[key], b[key], values)); - }, - - /** - * Parses a simple microsyntax for declaring key-value options: - * If no value is provided, it becomes `true`. The string "false" is parsed as `false`. - * Escapes for separators are supported, via backslash. - * @param {string} value - * @param {Object} [options] - * @param {Function} [options.values] The type to parse the values as - * @param {string} [options.separator=","] The separator between entries. - */ - parse (value, options = {}) { - let entries; - if (value instanceof Map) { - value = value.entries(); - } - else if (typeof value === "object") { - if (options.values) { - for (let key in value) { - value[key] = parse(value[key], options.values); - } - } - - return value; - } - - entries = parseEntries(value, options); - return Object.fromEntries(entries); - }, - - stringify (value, { values, separator = ", " } = {}) { - let entries = Object.entries(value); - - if (values) { - entries = entries.map(([key, value]) => [key, stringify(value, values)]); - } - - return entries.map(([key, value]) => `${key}: ${value}`).join(separator); - }, -}; - -export const map = { - type: Map, - equals (a, b, { values } = {}) { - let aKeys = a.keys(); - let bKeys = b.keys(); - - if (aKeys.length !== bKeys.length) { - return false; - } - - return aKeys.every(key => equals(a.get(key), b.get(key), values)); - }, - - /** - * Parses a simple microsyntax for declaring key-value options: - * If no value is provided, it becomes `true`. The string "false" is parsed as `false`. - * Escapes for separators are supported, via backslash. - * @param {string} value - * @param {Object} [options] - * @param {Function} [options.keys] The type to parse the keys as - * @param {Function} [options.values] The type to parse the values as - * @param {string} [options.separator=","] The separator between entries. - */ - parse (value, options) { - let entries; - if (value instanceof Map) { - if (options) { - let { keys, values } = options; - if (keys || values) { - for (let [key, value] of value) { - value.delete(key); - value.set(parse(key, keys), parse(value, values)); - } - } - } - - return value; - } - else if (typeof value === "object") { - value = Object.entries(value); - } - - entries = parseEntries(value, options); - return Array.isArray(entries) ? new Map(entries) : entries; - }, - - stringify (value, { keys, values, separator = ", " } = {}) { - let entries = value.entries(); - - if (keys || values) { - entries = entries.map(([key, value]) => [ - stringify(key, keys), - stringify(value, values), - ]); - } - - return entries.map(([key, value]) => `${key}: ${value}`).join(separator); - }, -}; diff --git a/src/plugins/props/types/function.js b/src/plugins/props/types/function.js new file mode 100644 index 00000000..6e15a714 --- /dev/null +++ b/src/plugins/props/types/function.js @@ -0,0 +1,27 @@ +import PropType from "../util/PropType.js"; + +export default PropType.register({ + is: Function, + equals (a, b) { + return a.toString() === b.toString(); + }, + parse (value) { + if (typeof value === "function") { + return value; + } + + return Function(...(this.arguments ?? []), String(value)); + }, + stringify () { + // Stringification is explicitly forbidden for functions. + throw new TypeError("Cannot stringify Function"); + }, +}); + +/** @import { PropTypeSpec } from "../util/PropType.js" */ + +/** + * @typedef {PropTypeSpec & { + * arguments?: string[], + * }} FunctionTypeSpec + */ diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index e38ace51..a10c6f9d 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -1,3 +1,12 @@ -export * from "./basic.js"; -export * from "./lists.js"; -export * from "./dictionaries.js"; +// Side-effect imports register the built-in types' singletons. +import "./boolean.js"; +import "./number.js"; +import "./function.js"; +import "./iterable.js"; +import "./array.js"; +import "./set.js"; +import "./map.js"; +import "./object.js"; + +export { default as PropType } from "../util/PropType.js"; +export { default as Iterable } from "./iterable.js"; diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js new file mode 100644 index 00000000..7826faeb --- /dev/null +++ b/src/plugins/props/types/iterable.js @@ -0,0 +1,126 @@ +import PropType from "../util/PropType.js"; +import { split } from "../util/split.js"; + +/** + * Abstract type for any iterable. The parsing pipeline is two streaming + * generators feeding a terminal step: {@link parseItems} yields raw items + * (no type parsing); {@link parsedItems} yields each item through + * `this.values`; {@link parse} materializes into an `Array`. Concrete + * types (Array, Set, …) `extends: Iterable` — Array inherits `parse` + * unchanged; Set overrides `parse` to wrap `this.parsedItems(value)` into + * a `Set`. Each input value flows through the chain exactly once. + * + * `MapType` extends this and reuses `parseItems` to grab the raw string + * parts, then splits each on `:` in its own `parseEntries` to yield + * `[key, value]` tuples. + */ +const Iterable = PropType.register({ + name: "Iterable", + subTypes: ["values"], + + /** + * Yield raw items of `value` — strings get split via {@link split}, + * iterables are consumed verbatim, scalars are yielded once. No + * `values.parse` is applied; this is the splitter, not the typer. + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {Iterator} + */ + *parseItems (value) { + if (typeof value === "string") { + yield* split(value, this); + } + else if (value?.[Symbol.iterator]) { + yield* value; + } + else { + yield value; + } + }, + + /** + * Yield each item from {@link parseItems} through `this.values`. + * The intermediate generator that concrete types (Set, …) consume into + * their own container. + * + * @todo Inline into {@link parse} via + * `this.parseItems(value).map(v => this.values.parse(v))` once + * `Iterator.prototype.map` is reliably available (Baseline 2025). + * + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {Iterator} + */ + *parsedItems (value) { + for (let v of this.parseItems(value)) { + yield this.values.parse(v); + } + }, + + /** + * Materialize {@link parsedItems} into an `Array`. Inherited by ArrayType + * unchanged; SetType overrides to wrap into a `Set`. + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {unknown[]} + */ + parse (value) { + return new this.is(this.parsedItems(value)); + }, + + /** + * Stringify an iterable: each item passes through `this.values`, joined + * by `joiner` (falling back to `separator`, default `", "`). + * @this {PropType} + * @param {Iterable} value + * @returns {string} + */ + stringify (value) { + let { separator = ", ", joiner = separator } = this; + let parts = []; + for (let v of value) { + parts.push(this.values.stringify(v)); + } + return parts.join(joiner); + }, + + /** + * Walk two iterables in parallel, comparing each pair via + * `this.values.equals`. Returns true iff both yield the same number + * of items and every pair compares equal. + * @this {PropType} + * @param {Iterable} a + * @param {Iterable} b + * @returns {boolean} + */ + equals (a, b) { + let aIter = a[Symbol.iterator](); + let bIter = b[Symbol.iterator](); + while (true) { + let { value: av, done: ad } = aIter.next(); + let { value: bv, done: bd } = bIter.next(); + if (ad !== bd) { + return false; + } + if (ad) { + return true; + } + if (!this.values.equals(av, bv)) { + return false; + } + } + }, +}); + +export default Iterable; + +/** @import { SpecifiedType, PropTypeSpec } from "../util/PropType.js" */ + +/** + * @typedef {PropTypeSpec & { + * values?: SpecifiedType, + * separator?: string, + * joiner?: string, + * pairs?: object, + * }} IterableSpec + */ diff --git a/src/plugins/props/types/lists.js b/src/plugins/props/types/lists.js deleted file mode 100644 index b32c4e1d..00000000 --- a/src/plugins/props/types/lists.js +++ /dev/null @@ -1,93 +0,0 @@ -import { parse, stringify, equals } from "../util/types.js"; -import { split } from "./util.js"; - -function parseList (value, { values, ...options } = {}) { - if (typeof value === "string") { - value = split(value, options); - } - else { - value = Array.isArray(value) ? value : [value]; - } - - if (values) { - value = value.map(item => parse(item, values)); - } - - return value; -} - -export const array = { - type: Array, - equals (a, b, { values } = {}) { - if (a.length !== b.length) { - return false; - } - - return a.every((item, i) => equals(item, b[i], values)); - }, - parse: parseList, - stringify: (value, { values, separator = ",", joiner } = {}) => { - if (values) { - value = value.map(item => stringify(item, values)); - } - - if (!joiner) { - let trimmedSeparator = separator.trim(); - joiner = (!trimmedSeparator || trimmedSeparator === "," ? "" : " ") + separator + " "; - } - - return value.join(joiner); - }, -}; - -export const set = { - type: Set, - equals (a, b, { values } = {}) { - if (a.size !== b.size) { - return false; - } - - for (let item of a) { - if (!b.has(item)) { - return false; - } - } - - return true; - }, - parse (value, options) { - if (value instanceof Set) { - if (options) { - let { values } = options; - - if (values) { - // Parse values in place - for (let item of value) { - let parsed = parse(item, values); - if (parsed !== item) { - value.delete(item); - value.add(parsed); - } - } - } - } - - return value; - } - - let items = parseList(value, options); - return new Set(items); - }, - stringify: (value, { values, separator = ",", joiner } = {}) => { - if (values) { - value = value.map(item => stringify(item, values)); - } - - if (!joiner) { - let trimmedSeparator = separator.trim(); - joiner = (!trimmedSeparator || trimmedSeparator === "," ? "" : " ") + separator + " "; - } - - return value.join(joiner); - }, -}; diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js new file mode 100644 index 00000000..f0718d88 --- /dev/null +++ b/src/plugins/props/types/map.js @@ -0,0 +1,161 @@ +import PropType from "../util/PropType.js"; +import Iterable from "./iterable.js"; + +const entrySplitter = /(? | unknown} value + * @returns {Iterator<[unknown, unknown]>} + */ + *parseEntries (value) { + let { defaultKey, defaultValue = true } = this; + let index = 0; + for (let item of this.parseItems(value)) { + let k, v; + if (typeof item === "string") { + let parts = item.split(entrySplitter); + if (parts.length >= 2) { + k = parts.shift(); + v = parts.join(":"); + } + else if (defaultKey !== undefined) { + v = parts[0]; + k = typeof defaultKey === "function" ? defaultKey(v, index) : defaultKey; + } + else { + k = parts[0]; + v = typeof defaultValue === "function" ? defaultValue(k, index) : defaultValue; + } + k = k?.trim?.() ?? k; + v = v?.trim?.() ?? v; + if (v === "false") { + v = false; + } + } + else { + [k, v] = item; + } + + yield [k, v]; + index++; + } + }, + + /** + * Yield each `[k, v]` from {@link parseEntries} with `this.keys` and + * `this.values` applied to the respective halves. The intermediate + * generator that concrete types (Object, …) consume into their own + * container. + * + * @todo Inline into {@link parse} via + * `this.parseEntries(value).map(([k, v]) => [this.keys.parse(k), this.values.parse(v)])` + * once `Iterator.prototype.map` is reliably available (Baseline 2025). + * + * @this {PropType} + * @param {string | Iterable<[unknown, unknown]>} value + * @returns {Iterator<[unknown, unknown]>} + */ + *parsedEntries (value) { + for (let [k, v] of this.parseEntries(value)) { + yield [this.keys.parse(k), this.values.parse(v)]; + } + }, + + /** + * Materialize {@link parsedEntries} into a `Map`. ObjectType overrides + * to wrap via `Object.fromEntries`. + * @this {PropType} + * @param {string | Iterable<[unknown, unknown]> | object} value + * @returns {Map} + */ + parse (value) { + if (value && typeof value === "object" && !value[Symbol.iterator]) { + value = Object.entries(value); + } + return new this.is(this.parsedEntries(value)); + }, + + /** + * Stringify an iterable of `[key, value]` entries into `"k: v, k: v"`. + * Each half passes through `this.keys` / `this.values`; entries are + * joined by `separator` (default `", "`). + * @this {PropType} + * @param {Iterable<[unknown, unknown]>} value + * @returns {string} + */ + stringify (value) { + let { separator = ", " } = this; + let parts = []; + for (let [k, v] of value) { + parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); + } + return parts.join(separator); + }, + + /** + * Walk two iterables of `[key, value]` entries in parallel, comparing + * each pair via `this.keys.equals` and `this.values.equals`. + * @this {PropType} + * @param {Iterable<[unknown, unknown]>} a + * @param {Iterable<[unknown, unknown]>} b + * @returns {boolean} + */ + equals (a, b) { + let aIter = a[Symbol.iterator](); + let bIter = b[Symbol.iterator](); + while (true) { + let { value: aEntry, done: ad } = aIter.next(); + let { value: bEntry, done: bd } = bIter.next(); + if (ad !== bd) { + return false; + } + if (ad) { + return true; + } + let [ak, av] = aEntry; + let [bk, bv] = bEntry; + if (!this.keys.equals(ak, bk) || !this.values.equals(av, bv)) { + return false; + } + } + }, +}); + +export default MapType; + +/** @import { SpecifiedType, PropTypeSpec } from "../util/PropType.js" */ + +/** + * @typedef {PropTypeSpec & { + * keys?: SpecifiedType, + * values?: SpecifiedType, + * separator?: string, + * defaultKey?: ((value: unknown, index: number) => unknown) | unknown, + * defaultValue?: ((key: unknown, index: number) => unknown) | unknown, + * pairs?: object, + * }} MapTypeSpec + */ diff --git a/src/plugins/props/types/number.js b/src/plugins/props/types/number.js new file mode 100644 index 00000000..13942713 --- /dev/null +++ b/src/plugins/props/types/number.js @@ -0,0 +1,9 @@ +import PropType from "../util/PropType.js"; + +export default PropType.register({ + is: Number, + equals (a, b) { + // All other cases are handled by the basic equality check in Prop.equals + return Number.isNaN(a) && Number.isNaN(b); + }, +}); diff --git a/src/plugins/props/types/object.js b/src/plugins/props/types/object.js new file mode 100644 index 00000000..effb458f --- /dev/null +++ b/src/plugins/props/types/object.js @@ -0,0 +1,31 @@ +import PropType from "../util/PropType.js"; +import MapType from "./map.js"; + +export default PropType.register({ + is: Object, + extends: MapType, + equals (a, b) { + let aKeys = Object.keys(a); + let bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => this.values.equals(a[key], b[key])); + }, + parse (value) { + if (value && typeof value === "object" && !value[Symbol.iterator]) { + value = Object.entries(value); + } + return Object.fromEntries(this.parsedEntries(value)); + }, + stringify (value) { + let { separator = ", " } = this; + let parts = []; + for (let [k, v] of Object.entries(value)) { + parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); + } + return parts.join(separator); + }, +}); diff --git a/src/plugins/props/types/set.js b/src/plugins/props/types/set.js new file mode 100644 index 00000000..5d2a0588 --- /dev/null +++ b/src/plugins/props/types/set.js @@ -0,0 +1,20 @@ +import PropType from "../util/PropType.js"; +import Iterable from "./iterable.js"; + +export default PropType.register({ + is: Set, + extends: Iterable, + equals (a, b) { + if (a.size !== b.size) { + return false; + } + + for (let item of a) { + if (!b.has(item)) { + return false; + } + } + + return true; + }, +}); diff --git a/src/plugins/props/types/util.js b/src/plugins/props/types/util.js index ff7ec097..609ac615 100644 --- a/src/plugins/props/types/util.js +++ b/src/plugins/props/types/util.js @@ -1,118 +1 @@ export * from "../../../util/resolve-value.js"; - -export const defaultPairs = { - nest: { - "(": ")", - "[": "]", - "{": "}", - }, - ignore: { - '"': '"', - // "'": "'", - // "`": "`", - }, -}; - -export function regexEscape (string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); -} - -/** - * Split a value by a separator, respecting pairs (parens, strings, etc.) but failling back gracefully for malformed input - * @param {string} value - * @param {object} options - * @returns - */ -export function split (value, { separator = ",", pairs = defaultPairs } = {}) { - value = value.trim(); - - // Make whitespace optional and flexible, unless the separator consists entirely of whitespace - separator = separator.trim(); - let isSeparatorWhitespace = !separator; - let separatorRegex = isSeparatorWhitespace - ? /\s+/g - : RegExp(regexEscape(separator).replace(/^\s*|\s*$/g, "\\s*"), "g"); - - let pairStrings = new Set([ - ...Object.keys(pairs.nest), - ...Object.values(pairs.nest), - ...Object.keys(pairs.ignore), - ...Object.values(pairs.ignore), - ]); - let pairRegex = RegExp([...pairStrings].map(regexEscape).join("|"), "g"); - - if (!pairRegex.test(value)) { - // value contains no pairs, just split - return value.trim().split(separatorRegex); - } - - let invertedNestPairs = Object.fromEntries( - Object.entries(pairs.nest).map(([start, end]) => [end, start]), - ); - let splitter = RegExp([separatorRegex.source, pairRegex.source].join("|"), "g"); - let stack = []; - let items = []; - let item = ""; - let matches = [...value.matchAll(splitter)]; - let lastIndex = 0; - let ignoreUntil; - - for (let i = 0; i < matches.length; i++) { - let match = matches[i]; - let index = match.index; - let matched = match[0]; - let part = value.slice(lastIndex, index); - - if (ignoreUntil) { - if (ignoreUntil === matched) { - // TODO escape? - ignoreUntil = null; - } - } - else if (matched.trim() === separator) { - if (stack.length === 0) { - // No open pairs, add to items - items.push(part.trim()); - lastIndex = index + matched.length; - } - } - else if (pairs.ignore[matched]) { - // Opening ignored (verbatim) pair - let closingPair = pairs.ignore[matched]; - - // Do we have its closing pair in the string? - if (matches.slice(i + 1).find(m => m[0] === closingPair)) { - ignoreUntil = closingPair; - } - } - else if (pairs.nest[matched]) { - // Opening nested pair - let closingPair = pairs.nest[matched]; - - // Do we have its closing pair in the string? - if (matches.slice(i + 1).find(m => m[0] === closingPair)) { - stack.push(matched); - } - } - else if (invertedNestPairs[matched]) { - // Closing nested pair - // Why not just check and pop? We want malformed (interleaved) pairs to work too - let startIndex = stack.findLastIndex(start => start === invertedNestPairs[matched]); - - if (startIndex > -1) { - stack.splice(startIndex, 1); - } - } - } - - if (stack.length > 0 || ignoreUntil) { - // Malformed pairs, just split - } - - if (lastIndex < value.length || item.length > 0) { - item += value.slice(lastIndex); - items.push(item.trim()); - } - - return items; -} diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 425a8aa4..83d6db48 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -1,5 +1,5 @@ import { inferDependencies } from "../util.js"; -import * as types from "./types.js"; +import { PropType } from "../types/index.js"; /** * Class-level metadata for a single prop. @@ -57,14 +57,10 @@ let Self = class Prop { to: reflect.to === true ? name : reflect.to || undefined, }; - this.type = types.resolve(spec.type); + this.type = PropType.for(spec.type); for (let fnName of ["equals", "stringify", "parse"]) { - this[fnName] = - spec[fnName] ?? - function (...args) { - return types[fnName](...args, this.type); - }; + this[fnName] = spec[fnName] ?? this.type[fnName].bind(this.type); } } diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js new file mode 100644 index 00000000..0f3439a2 --- /dev/null +++ b/src/plugins/props/util/PropType.js @@ -0,0 +1,347 @@ +import { defineLazyProperty } from "../../../util/lazy.js"; + +/** + * Constructors that should be called as functions + */ +const callableBuiltins = new Set([ + String, + Number, + Boolean, + Array, + Object, + Function, + Symbol, + BigInt, + RegExp, +]); + +/** + * Type adapter for prop values: defines equality, parsing from raw input + * (typically attribute strings), and stringification back to attributes. + * + * Instances are abstract, prop-agnostic type definitions. The {@link registry} + * keys concretes by JS constructor (`is`) and abstracts by `name`; derivatives + * are `Object.create(parent)` so option lookup walks the prototype chain. + * The parent comes from `spec.extends` or, failing that, `registry.get(spec.is)` + * — so e.g. `{is: Array, extends: Iterable}` decouples the chain parent from + * the produced JS type. + * + * {@link init} lifts every spec key onto the instance. Method overrides + * (`equals` / `parse` / `stringify`) go through their matching `get_` + * transform on the prototype, which wraps them with the shared null/identity + * short-circuits and super-walking. Other methods are lifted verbatim and can + * call into the next implementation via the {@link super} proxy. + * + * @template {PropTypeSpec} [TSpec=PropTypeSpec] + */ +export default class PropType { + /** + * The spec this instance was constructed with — stored verbatim, not cloned. + * Every own key is also lifted onto the instance by {@link init}. + * @type {PropTypeSpec | undefined} + */ + spec; + + /** + * Spec keys whose values are sub-types (e.g. `["values"]`, `["keys", "values"]`). + * {@link init} resolves them to `PropType` instances on the instance, defaulting + * to {@link PropType.any} so `this.values.parse(v)` works unconditionally. + * @type {Array | undefined} + */ + subTypes; + + /** @param {TSpec} [spec] */ + constructor (spec) { + if (!spec) { + return this.constructor.any; + } + + if (typeof spec !== "object") { + spec = { is: spec }; + } + + let { is, extends: parentSpec, name, ...extras } = spec; + + if (!is && !parentSpec && !name) { + return this.constructor.any; + } + + is = is ? PropType.normalizeIs(is) : undefined; + let parent = parentSpec + ? PropType.for(parentSpec) + : is + ? PropType.registry.get(is) + : undefined; + + // Pure lookup: a bare `{is: X}` or `{extends: Y}` (no other keys) is + // just a request for the already-registered singleton. + let hasExtras = name !== undefined || Object.keys(extras).length > 0; + if (parent && !hasExtras && !(is && parentSpec)) { + return parent; + } + + if (parent) { + return parent.from(spec); + } + + this.spec = spec; + this.init(); + } + + /** + * Create a new type that inherits from this one. + * Mainly used internally. + * @param {PropTypeSpec} spec + * @returns + */ + from (spec) { + let instance = Object.create(this); + instance.parent = this; + instance.spec = spec; + instance.init(); + return instance; + } + + init () { + let { spec } = this; + + for (let key in spec) { + if ("get_" + key in this) { + this[key] = this["get_" + key](spec[key]); + } + else { + this[key] = spec[key]; + } + } + + if (Object.hasOwn(spec, "is")) { + this.is = this.constructor.normalizeIs(spec.is); + } + + for (let key of this.subTypes ?? []) { + if (spec[key] !== undefined) { + this[key] = PropType.for(spec[key]); + } + else if (!(key in this)) { + this[key] = PropType.any; + } + } + } + + get_equals (specEquals) { + return function equals (a, b) { + if (a === null || b === null || a === undefined || b === undefined) { + return a === b; + } + + if (a === b) { + return true; + } + + if (specEquals) { + return specEquals.call(this, a, b); + } + + return typeof a.equals === "function" ? a.equals(b) : false; + }; + } + static { + this.prototype.equals = this.prototype.get_equals(); + } + + get_parse (specParse) { + return function parse (value) { + if (value === null || value === undefined) { + return value; + } + + if (specParse) { + return specParse.call(this, value); + } + + let Type = this.is; + if (!Type || value instanceof Type) { + return value; + } + + return callableBuiltins.has(Type) ? Type(value) : new Type(value); + }; + } + static { + this.prototype.parse = this.prototype.get_parse(); + } + + /** + * Null/undefined produce `null` (signaling attribute removal). + */ + get_stringify (specStringify) { + return function stringify (value) { + if (value === null || value === undefined) { + return null; + } + + if (specStringify) { + return specStringify.call(this, value); + } + + return String(value); + }; + } + static { + this.prototype.stringify = this.prototype.get_stringify(); + } + + /** + * Is this type a kind of `other` — i.e. is `other` `this` or anywhere up + * its super chain? Replaces `instanceof` checks (abstract types aren't + * JS classes). + * @param {PropType} other + * @returns {boolean} + */ + isA (other) { + other = PropType.for(other); + return this === other || Object.prototype.isPrototypeOf.call(other, this); + } + + get [Symbol.toStringTag] () { + if (this.name) { + return this.name; + } + + if (this.is) { + return this.is?.name ?? this.is; + } + + if (this.super !== this.constructor.prototype) { + return this.super[Symbol.toStringTag]; + } + + return this.constructor.name; + } + + /** @type {Map} */ + static registry = new Map(); + + /** + * Shared fallback returned by {@link PropType.for} when no type is + * specified or matched. + * @type {PropType} + */ + static any = new PropType(); + + /** + * Construct a type and store it in {@link PropType.registry}, keyed on + * `spec.is` (constructor) or `spec.name` (abstract). + * @param {PropTypeSpec} spec + * @returns {PropType} + */ + static register (spec) { + let instance = new this(spec); + let key = instance.is ?? spec.name; + this.registry.set(key, instance); + return instance; + } + + /** + * Resolve any user-facing type identifier into a {@link PropType}: + * PropType (as-is), constructor (registry → fresh `{is}` derivative), + * string (`globalThis` → registry → `fallback`), spec object (singleton + * if bare, otherwise a derivative), or nullish (`fallback`). + * + * @param {SpecifiedType} input + * @param {{ fallback?: PropType }} [options] + * @returns {PropType} + */ + static for (input, { fallback = this.any } = {}) { + if (input instanceof PropType) { + return input; + } + + if (!input) { + return fallback; + } + + // Bare `{is: X}` is equivalent to passing `X` directly — collapse it + // so both forms hit the same cached-lookup path. Anything richer + // produces a fresh derivative. + if (typeof input === "object") { + let keys = Object.keys(input); + if (keys.length === 1 && keys[0] === "is") { + input = input.is; + } + else { + return new this(input); + } + } + + let is = PropType.normalizeIs(input); + let registered = this.registry.get(is); + if (registered) { + return registered; + } + + // Unregistered constructor → register a fresh derivative so `is` is + // preserved and subsequent lookups return the same instance. Bare + // strings (those `normalizeIs` couldn't resolve to a function) fall + // through to the fallback since they almost always indicate a typo + // or missing import. + return typeof is === "function" ? this.register({ is }) : fallback; + } + + /** + * Resolve an `is` identifier to its registry key: strings are looked up on + * `globalThis` (catches built-ins like `"Array"`); anything else passes + * through as-is (including named-only abstracts like `"Iterable"`). + * @param {Function | string | undefined} is + * @returns {Function | string | undefined} + */ + static normalizeIs (is) { + if (typeof is !== "string") { + return is; + } + let resolved = globalThis[is]; + return typeof resolved === "function" ? resolved : is; + } + + static { + this.prototype.super = this.prototype; + defineLazyProperty(this.prototype, "super", function () { + let self = this; + let proto = this.parent ? this.parent : this.constructor.prototype; + return new Proxy(Object.create(proto), { + get (target, key, receiver) { + let val = target[key]; + + if (typeof val !== "function" || Object.hasOwn(target, key)) { + return val; + } + + return (target[key] = function (...args) { + let thisArg = this === receiver ? self : this; + return val.apply(thisArg, args); + }); + }, + }); + }); + } +} + +/** + * @typedef {object} PropTypeSpec + * @property {Function | string} [is] JS constructor (or its global name). + * @property {PropType | string} [extends] Explicit parent in the chain — used + * when the parent differs from `registry.get(is)` (e.g. concrete types + * extending an abstract). + * @property {string} [name] Registry key for abstract types with no `is`. + * @property {string[]} [subTypes] Spec keys whose values are sub-type specs, + * resolved to PropType instances at construction. A child's list replaces + * (not extends) the parent's. Unspecified keys default to {@link PropType.any}. + * @property {(this: PropType, a: unknown, b: unknown) => boolean} [equals] + * @property {(this: PropType, value: unknown) => unknown} [parse] + * @property {(this: PropType, value: unknown) => (string | null)} [stringify] + */ + +/** + * Anything users can pass as `type` in a prop spec. + * @typedef {PropTypeSpec | Function | string | PropType} SpecifiedType + */ diff --git a/src/plugins/props/util/split.js b/src/plugins/props/util/split.js new file mode 100644 index 00000000..df0c0a5d --- /dev/null +++ b/src/plugins/props/util/split.js @@ -0,0 +1,103 @@ +export const defaultPairs = { + nest: { + "(": ")", + "[": "]", + "{": "}", + }, + ignore: { + '"': '"', + // "'": "'", + // "`": "`", + }, +}; + +function regexEscape (string) { + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +} + +/** + * Split a value by a separator, respecting pairs (parens, strings, etc.) but + * failing back gracefully for malformed input. Yields each top-level part as + * a trimmed string. + * + * @param {string} value + * @param {object} [options] + * @param {string} [options.separator] + * @param {object} [options.pairs] + * @returns {Generator} + */ +export function* split (value, { separator = ",", pairs = defaultPairs } = {}) { + value = value.trim(); + + // Make whitespace optional and flexible, unless the separator consists entirely of whitespace + separator = separator.trim(); + let isSeparatorWhitespace = !separator; + let separatorRegex = isSeparatorWhitespace + ? /\s+/g + : RegExp(regexEscape(separator).replace(/^\s*|\s*$/g, "\\s*"), "g"); + + let pairStrings = new Set([ + ...Object.keys(pairs.nest), + ...Object.values(pairs.nest), + ...Object.keys(pairs.ignore), + ...Object.values(pairs.ignore), + ]); + let pairRegex = RegExp([...pairStrings].map(regexEscape).join("|"), "g"); + + if (!pairRegex.test(value)) { + // value contains no pairs, just split + yield* value.trim().split(separatorRegex); + return; + } + + let invertedNestPairs = Object.fromEntries( + Object.entries(pairs.nest).map(([start, end]) => [end, start]), + ); + let splitter = RegExp([separatorRegex.source, pairRegex.source].join("|"), "g"); + let stack = []; + let matches = [...value.matchAll(splitter)]; + let lastIndex = 0; + let ignoreUntil; + + for (let i = 0; i < matches.length; i++) { + let match = matches[i]; + let index = match.index; + let matched = match[0]; + + if (ignoreUntil) { + if (ignoreUntil === matched) { + // TODO escape? + ignoreUntil = null; + } + } + else if (matched.trim() === separator) { + if (stack.length === 0) { + yield value.slice(lastIndex, index).trim(); + lastIndex = index + matched.length; + } + } + else if (pairs.ignore[matched]) { + let closingPair = pairs.ignore[matched]; + if (matches.slice(i + 1).find(m => m[0] === closingPair)) { + ignoreUntil = closingPair; + } + } + else if (pairs.nest[matched]) { + let closingPair = pairs.nest[matched]; + if (matches.slice(i + 1).find(m => m[0] === closingPair)) { + stack.push(matched); + } + } + else if (invertedNestPairs[matched]) { + // Why not just check and pop? We want malformed (interleaved) pairs to work too + let startIndex = stack.findLastIndex(start => start === invertedNestPairs[matched]); + if (startIndex > -1) { + stack.splice(startIndex, 1); + } + } + } + + if (lastIndex < value.length) { + yield value.slice(lastIndex).trim(); + } +} diff --git a/src/plugins/props/util/types.js b/src/plugins/props/util/types.js deleted file mode 100644 index e6ed123e..00000000 --- a/src/plugins/props/util/types.js +++ /dev/null @@ -1,101 +0,0 @@ -import * as allTypes from "../types/index.js"; - -const defaultType = allTypes.generic; - -export const types = new Map(); -for (let name in allTypes) { - if (name === "generic") { - continue; - } - - let spec = allTypes[name]; - types.set(spec.type, spec); -} - -export function equals (a, b, type) { - if (a === null || b === null || a === undefined || b === undefined) { - return a === b; - } - - if (type) { - let { is: Type, ...typeOptions } = resolve(type); - let equals = types.get(Type)?.equals; - - if (equals) { - return equals(a, b, type); - } - } - - return defaultType.equals(a, b, type); -} - -// Cast a value to the desired type -export function parse (value, type) { - if (!type || value === undefined || value === null) { - return value; - } - - if (type) { - type = resolve(type); - let { is: Type, ...typeOptions } = type; - let parse = types.get(Type)?.parse; - - if (parse) { - return parse(value, type); - } - } - - return defaultType.parse(value, type); -} - -export function stringify (value, type) { - if (value === undefined || value === null) { - return null; - } - - if (!type) { - return String(value); - } - - let { is: Type, ...typeOptions } = resolve(type); - - let stringify = types.get(Type)?.stringify; - - if (stringify === false) { - // stringify is *explicitly* forbidden - throw new TypeError(`Cannot stringify ${type}`); - } - - return stringify ? stringify(value) : defaultType.stringify(value, type); -} - -/** - * A resolved type spec - * @typedef {Object} TypeSpec - * @property {Function} is - * @property {object} [...typeOptions] Options specific to the defined type - */ - -/** - * A type, as can be specified by users of the library - * @typedef {TypeSpec | Function | string} SpecifiedType - */ - -/** - * Resolve a type value into a spec - * @param {SpecifiedType} type - * @returns {TypeSpec} - */ -export function resolve (type) { - if (type) { - if (typeof type === "function" || typeof type === "string") { - type = { is: type }; - } - - if (typeof type.is === "string") { - type.is = globalThis[type.is]; - } - } - - return type; -} diff --git a/test/PropType.js b/test/PropType.js new file mode 100644 index 00000000..3e82be90 --- /dev/null +++ b/test/PropType.js @@ -0,0 +1,600 @@ +import PropType from "../src/plugins/props/util/PropType.js"; +import Iterable from "../src/plugins/props/types/iterable.js"; +// Side-effect imports register the built-in types. +import "../src/plugins/props/types/index.js"; +import ArrayType from "../src/plugins/props/types/array.js"; +import SetType from "../src/plugins/props/types/set.js"; +import ObjectType from "../src/plugins/props/types/object.js"; +import MapType from "../src/plugins/props/types/map.js"; + +const NumberType = PropType.for(Number); +const StringType = PropType.for(String); + +// Stand-in for any custom class a user might pass as `type` without registering +// it (the README's Color.js example). Constructs from a string, has its own +// toString and equals — enough to exercise the default parse/stringify/equals. +class Unknown { + constructor (value) { + this.value = value instanceof Unknown ? value.value : String(value); + } + toString () { + return this.value; + } + equals (other) { + return other instanceof Unknown && other.value === this.value; + } +} + +export default { + name: "PropType", + expect: ArrayType, + tests: [ + { + name: "for() — pure lookup returns the registered singleton", + run: input => PropType.for(input), + tests: [ + { name: "By constructor", arg: Array }, + { name: "By global name string", arg: "Array" }, + { name: "By bare spec {is: ctor}", arg: { is: Array } }, + { name: "By bare spec {is: 'name'}", arg: { is: "Array" } }, + { name: "PropType instance passes through", arg: ArrayType }, + { + name: "Lookup is idempotent across calls", + check: () => PropType.for(Array) === PropType.for(Array), + }, + { + name: "undefined yields a fallback that is a PropType", + check: () => PropType.for(undefined) instanceof PropType, + }, + { + name: "null yields the same fallback as undefined", + arg: null, + expect: PropType.for(undefined), + }, + { + name: "Custom fallback honored", + run: () => PropType.for(undefined, { fallback: ArrayType }), + }, + { + name: "Unregistered constructor yields a derivative carrying its `is`", + check () { + let t = PropType.for(Unknown); + return t instanceof PropType && t !== PropType.any && t.is === Unknown; + }, + }, + { + name: "Unregistered constructor lookup is idempotent across calls", + check: () => PropType.for(Unknown) === PropType.for(Unknown), + }, + { + name: "Bare spec {is: X} is idempotent across calls", + check () { + class Fresh {} + return PropType.for({ is: Fresh }) === PropType.for({ is: Fresh }); + }, + }, + { + name: "Constructor and bare spec forms resolve to the same instance", + check () { + class Cross {} + return PropType.for({ is: Cross }) === PropType.for(Cross); + }, + }, + { + name: "Unresolvable string still yields the default fallback", + check: () => + PropType.for("DefinitelyNotAGlobalOrRegisteredType") === PropType.for(undefined), + }, + { + name: "Built-in singletons match their named exports", + check: () => + PropType.for(Array) === ArrayType && + PropType.for(Set) === SetType && + PropType.for(Object) === ObjectType && + PropType.for(Map) === MapType, + }, + ], + }, + { + name: "Derivation", + tests: [ + { + name: "Specs with options produce a fresh instance, not the singleton", + check: () => PropType.for({ is: Array, values: Number }) !== ArrayType, + }, + { + name: "Derivative inherits from its abstract base type", + check: () => PropType.for({ is: Array, values: Number }).isA(Iterable), + }, + { + name: "Derivative reports the correct is", + check: () => PropType.for({ is: Array, values: Number }).is === Array, + }, + { + name: "Nested option specs resolve to PropType instances", + check () { + let t = PropType.for({ is: Array, values: Number }); + return t.values === NumberType; + }, + }, + { + name: "No caching: repeated calls yield distinct derivatives", + check () { + let t1 = PropType.for({ is: Array, values: Number }); + let t2 = PropType.for({ is: Array, values: Number }); + return t1 !== t2; + }, + }, + { + name: "But nested singletons are shared across calls", + check () { + let t1 = PropType.for({ is: Array, values: Number }); + let t2 = PropType.for({ is: Array, values: Number }); + return t1.values === t2.values; + }, + }, + { + name: "Mutating a derivative does not affect the singleton", + run () { + let t = PropType.for({ is: Array, values: Number }); + t.separator = ";"; + return ArrayType.separator; + }, + expect: undefined, + }, + { + name: "Method override is used in dispatch", + run () { + let t = PropType.for({ is: Array, equals: () => "called" }); + return t.equals([1], [1, 2]); + }, + expect: "called", + }, + { + name: "Non-overridden methods still come from the parent", + run () { + let t = PropType.for({ is: Array, equals: () => false }); + return t.parse("a, b, c"); + }, + expect: ["a", "b", "c"], + }, + { + name: "Non-overridden stringify still comes from the parent", + run () { + let t = PropType.for({ is: Array, equals: () => false }); + return t.stringify(["a", "b", "c"]); + }, + expect: "a, b, c", + }, + ], + }, + { + name: "Default method behavior", + tests: [ + { + name: "parse passes null through", + run: () => PropType.for(Array).parse(null), + expect: null, + }, + { + name: "parse passes undefined through", + run: () => PropType.for(Array).parse(undefined), + expect: undefined, + }, + { + name: "stringify returns null for null", + run: () => PropType.for(Array).stringify(null), + expect: null, + }, + { + name: "stringify returns null for undefined", + run: () => PropType.for(Array).stringify(undefined), + expect: null, + }, + { + name: "equals: null vs null is true", + check: () => PropType.for(Array).equals(null, null), + }, + { + name: "equals: null vs undefined is false", + run: () => PropType.for(Array).equals(null, undefined), + expect: false, + }, + { + name: "equals: identity short-circuit", + check () { + let a = [1, 2]; + return PropType.for(Array).equals(a, a); + }, + }, + ], + }, + { + name: "List behavior", + tests: [ + { + name: "Array parses comma-separated string", + run () { + return PropType.for({ is: Array, values: Number }).parse("1, 2, 3"); + }, + expect: [1, 2, 3], + }, + { + name: "Array stringifies", + run () { + return PropType.for({ is: Array, values: Number }).stringify([1, 2, 3]); + }, + expect: "1, 2, 3", + }, + { + name: "Array equality with matching contents", + check () { + let t = PropType.for({ is: Array, values: Number }); + return t.equals([1, 2, 3], [1, 2, 3]); + }, + }, + { + name: "Array equality with different length", + run () { + let t = PropType.for({ is: Array, values: Number }); + return t.equals([1, 2], [1, 2, 3]); + }, + expect: false, + }, + { + name: "Set parses to a Set instance", + run () { + let result = PropType.for({ is: Set, values: Number }).parse("1, 2, 3"); + return result instanceof Set && [...result].join(","); + }, + expect: "1,2,3", + }, + { + name: "Derivative with custom separator parses with it", + run () { + return PropType.for({ is: Array, values: Number, separator: ";" }).parse( + "1; 2; 3", + ); + }, + expect: [1, 2, 3], + }, + { + name: "Derivative with custom separator stringifies with it (no auto-spacing)", + run () { + return PropType.for({ + is: Array, + values: Number, + separator: ";", + }).stringify([1, 2, 3]); + }, + expect: "1;2;3", + }, + { + name: "Derivative with custom joiner stringifies with it", + run () { + return PropType.for({ is: Array, values: Number, joiner: " " }).stringify([ + 1, 2, 3, + ]); + }, + expect: "1 2 3", + }, + { + name: "Derivative without `values` still respects separator", + run () { + return PropType.for({ is: Array, separator: ";" }).parse("a; b; c"); + }, + expect: ["a", "b", "c"], + }, + ], + }, + { + name: "Dictionary behavior", + tests: [ + { + name: "Object parses microsyntax", + run () { + return PropType.for({ is: Object, keys: String, values: Number }).parse( + "a: 1, b: 2", + ); + }, + expect: { a: 1, b: 2 }, + }, + { + name: "Map parses to a Map with correct entries", + run () { + let m = PropType.for({ is: Map, keys: String, values: Number }).parse( + "a: 1, b: 2", + ); + return [m instanceof Map, m.get("a"), m.get("b")]; + }, + expect: [true, 1, 2], + }, + { + name: "Nested key and value types are resolved", + check () { + let t = PropType.for({ is: Map, keys: String, values: Number }); + return t.keys === StringType && t.values === NumberType; + }, + }, + { + name: "Derivative with custom separator parses with it", + run () { + return PropType.for({ + is: Object, + keys: String, + values: Number, + separator: ";", + }).parse("a: 1; b: 2"); + }, + expect: { a: 1, b: 2 }, + }, + { + name: "Derivative with custom separator stringifies with it", + run () { + return PropType.for({ + is: Object, + keys: String, + values: Number, + separator: " | ", + }).stringify({ a: 1, b: 2 }); + }, + expect: "a: 1 | b: 2", + }, + { + name: "Derivative with defaultValue uses it for valueless entries", + run () { + return PropType.for({ is: Object, defaultValue: 7 }).parse("a, b, c"); + }, + expect: { a: 7, b: 7, c: 7 }, + }, + { + name: "Derivative with defaultKey uses it for keyless entries", + run () { + return PropType.for({ is: Object, defaultKey: (v, i) => i }).parse( + "a, b, c", + ); + }, + expect: { 0: "a", 1: "b", 2: "c" }, + }, + ], + }, + { + name: "Nested type composition", + tests: [ + { + name: "Array> — inner type is resolved correctly", + check () { + let t = PropType.for({ + is: Array, + values: { is: Array, values: Number }, + }); + return ( + t.values.isA(Iterable) && + t.values.is === Array && + t.values.values === NumberType + ); + }, + }, + { + name: "Array> — inner type is the SetType derivative", + check () { + let t = PropType.for({ + is: Array, + values: { is: Set, values: Number }, + }); + return t.values.isA(Iterable) && t.values.is === Set; + }, + }, + ], + }, + { + name: "Built-in primitives", + tests: [ + { + name: "Boolean.parse(null) → null", + run: () => PropType.for(Boolean).parse(null), + expect: null, + }, + { + name: "Boolean.parse(any non-null) → true", + check: () => PropType.for(Boolean).parse("anything"), + }, + { + name: "Boolean.stringify(true) → empty string", + run: () => PropType.for(Boolean).stringify(true), + expect: "", + }, + { + name: "Boolean.stringify(false) → null", + run: () => PropType.for(Boolean).stringify(false), + expect: null, + }, + { + name: "Number.parse coerces string", + run: () => PropType.for(Number).parse("42"), + expect: 42, + }, + { + name: "Number.equals: NaN === NaN", + check: () => PropType.for(Number).equals(NaN, NaN), + }, + { + name: "Number.equals: 1 vs 2 false", + run: () => PropType.for(Number).equals(1, 2), + expect: false, + }, + { + name: "Function singleton parses zero-arg body", + run () { + return PropType.for(Function).parse("return 7")(); + }, + expect: 7, + }, + { + name: "Function derivative with arguments parses correctly", + run () { + let t = PropType.for({ is: Function, arguments: ["x"] }); + return t.parse("return x * 2")(5); + }, + expect: 10, + }, + { + name: "Function stringify throws", + check () { + try { + PropType.for(Function).stringify(() => {}); + return false; + } + catch (e) { + return e instanceof TypeError; + } + }, + }, + ], + }, + { + name: "Custom constructors without registration", + tests: [ + { + name: "parse(string) constructs an Unknown instance", + check: () => PropType.for(Unknown).parse("red") instanceof Unknown, + }, + { + name: "parse(string) preserves the value", + run: () => PropType.for(Unknown).parse("red").toString(), + expect: "red", + }, + { + name: "parse passes through existing Unknown instances", + check () { + let c = new Unknown("red"); + return PropType.for(Unknown).parse(c) === c; + }, + }, + { + name: "stringify uses Unknown#toString", + run: () => PropType.for(Unknown).stringify(new Unknown("blue")), + expect: "blue", + }, + { + name: "equals uses Unknown#equals for distinct-but-equal instances", + check: () => + PropType.for(Unknown).equals( + new Unknown("red"), + new Unknown("red"), + ), + }, + { + name: "equals returns false for different Unknown instances", + run: () => + PropType.for(Unknown).equals( + new Unknown("red"), + new Unknown("blue"), + ), + expect: false, + }, + ], + }, + { + name: "super", + tests: [ + { + name: "super.X returns parent's data even when child overrides it", + check () { + class Box {} + PropType.register({ is: Box, hint: "from-parent" }); + let child = PropType.for({ is: Box, hint: "from-child" }); + let result = child.super.hint === "from-parent" && child.hint === "from-child"; + PropType.registry.delete(Box); + return result; + }, + }, + { + name: "super.method() runs parent's method with this = self", + check () { + class Box {} + let calledWith; + PropType.register({ + is: Box, + parse (value) { + calledWith = this; + return value; + }, + }); + let child = PropType.for({ is: Box, other: "extra" }); + child.super.parse("anything"); + let result = calledWith === child; + PropType.registry.delete(Box); + return result; + }, + }, + { + name: "super.parse() does not recurse when called from a child's parse override", + run () { + class Box {} + PropType.register({ + is: Box, + parse: value => "parent:" + value, + }); + let child = PropType.for({ + is: Box, + parse (value) { + return "child(" + this.super.parse(value) + ")"; + }, + }); + let result = child.parse("hi"); + PropType.registry.delete(Box); + return result; + }, + expect: "child(parent:hi)", + }, + ], + }, + { + name: "register()", + tests: [ + { + name: "After register, for() returns the registered instance", + check () { + class Foo {} + let registered = PropType.register({ is: Foo }); + let result = PropType.for(Foo) === registered; + PropType.registry.delete(Foo); + return result; + }, + }, + { + name: "register with extends: Iterable produces an Iterable derivative", + check () { + class FooList {} + let t = PropType.register({ is: FooList, extends: Iterable }); + let result = t.isA(Iterable); + PropType.registry.delete(FooList); + return result; + }, + }, + { + name: "register with extends: MapType produces a Map derivative", + check () { + class FooDict {} + let t = PropType.register({ is: FooDict, extends: MapType }); + let result = t.isA(MapType); + PropType.registry.delete(FooDict); + return result; + }, + }, + { + name: "Registered parse is actually invoked", + run () { + class Foo {} + PropType.register({ is: Foo, parse: () => "parsed!" }); + let result = PropType.for(Foo).parse("anything"); + PropType.registry.delete(Foo); + return result; + }, + expect: "parsed!", + }, + ], + }, + ], +}; diff --git a/test/index.js b/test/index.js index 37359c7e..10d25fd6 100644 --- a/test/index.js +++ b/test/index.js @@ -1,3 +1,4 @@ +import PropType from "./PropType.js"; import Prop from "./Prop.js"; import Props from "./Props.js"; import hooks from "./hooks.js"; @@ -6,5 +7,5 @@ import plugins from "./plugins/index.js"; export default { name: "All tests", - tests: [Prop, Props, hooks, split, plugins], + tests: [PropType, Prop, Props, hooks, split, plugins], }; diff --git a/test/split.js b/test/split.js index 6d0e22e0..7c9eeb9f 100644 --- a/test/split.js +++ b/test/split.js @@ -1,8 +1,8 @@ -import { split } from "../src/plugins/props/types/util.js"; +import { split } from "../src/plugins/props/util/split.js"; export default { name: "split()", - run: split, + run: (...args) => [...split(...args)], tests: [ { name: "Basic",