props.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import type { VisibilityCondition, StateModel } from "./types";
  2. import { getByPath } from "./types";
  3. import { evaluateVisibility, type VisibilityContext } from "./visibility";
  4. // =============================================================================
  5. // Prop Expression Types
  6. // =============================================================================
  7. /**
  8. * A prop expression that resolves to a value based on state.
  9. *
  10. * - `{ $state: string }` reads a value from the global state model
  11. * - `{ $item: string }` reads a field from the current repeat item
  12. * (relative path into the item object; use `""` for the whole item)
  13. * - `{ $index: true }` returns the current repeat array index. Uses `true`
  14. * as a sentinel flag because the index is a scalar with no sub-path to
  15. * navigate — unlike `$item` which needs a path into the item object.
  16. * - `{ $bindState: string }` two-way binding to a global state path —
  17. * resolves to the value at the path (like `$state`) AND exposes the
  18. * resolved path so the component can write back.
  19. * - `{ $bindItem: string }` two-way binding to a field on the current
  20. * repeat item — resolves via `repeatBasePath + path` and exposes the
  21. * absolute state path for write-back.
  22. * - `{ $cond, $then, $else }` conditionally picks a value
  23. * - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
  24. * registered function with resolved args and returns the result
  25. * - `{ $template: string }` interpolates `${/path}` references in the
  26. * string with values from the state model
  27. * - Any other value is a literal (passthrough)
  28. */
  29. export type PropExpression<T = unknown> =
  30. | T
  31. | { $state: string }
  32. | { $item: string }
  33. | { $index: true }
  34. | { $bindState: string }
  35. | { $bindItem: string }
  36. | {
  37. $cond: VisibilityCondition;
  38. $then: PropExpression<T>;
  39. $else: PropExpression<T>;
  40. }
  41. | { $computed: string; args?: Record<string, unknown> }
  42. | { $template: string };
  43. /**
  44. * Function signature for `$computed` expressions.
  45. * Receives a record of resolved argument values and returns a computed result.
  46. */
  47. export type ComputedFunction = (args: Record<string, unknown>) => unknown;
  48. /**
  49. * Context for resolving prop expressions.
  50. * Extends {@link VisibilityContext} with an optional `repeatBasePath` used
  51. * to resolve `$bindItem` paths to absolute state paths.
  52. */
  53. export interface PropResolutionContext extends VisibilityContext {
  54. /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
  55. repeatBasePath?: string;
  56. /** Named functions available for `$computed` expressions. */
  57. functions?: Record<string, ComputedFunction>;
  58. }
  59. // =============================================================================
  60. // Type Guards
  61. // =============================================================================
  62. function isStateExpression(value: unknown): value is { $state: string } {
  63. return (
  64. typeof value === "object" &&
  65. value !== null &&
  66. "$state" in value &&
  67. typeof (value as Record<string, unknown>).$state === "string"
  68. );
  69. }
  70. function isItemExpression(value: unknown): value is { $item: string } {
  71. return (
  72. typeof value === "object" &&
  73. value !== null &&
  74. "$item" in value &&
  75. typeof (value as Record<string, unknown>).$item === "string"
  76. );
  77. }
  78. function isIndexExpression(value: unknown): value is { $index: true } {
  79. return (
  80. typeof value === "object" &&
  81. value !== null &&
  82. "$index" in value &&
  83. (value as Record<string, unknown>).$index === true
  84. );
  85. }
  86. function isBindStateExpression(
  87. value: unknown,
  88. ): value is { $bindState: string } {
  89. return (
  90. typeof value === "object" &&
  91. value !== null &&
  92. "$bindState" in value &&
  93. typeof (value as Record<string, unknown>).$bindState === "string"
  94. );
  95. }
  96. function isBindItemExpression(value: unknown): value is { $bindItem: string } {
  97. return (
  98. typeof value === "object" &&
  99. value !== null &&
  100. "$bindItem" in value &&
  101. typeof (value as Record<string, unknown>).$bindItem === "string"
  102. );
  103. }
  104. function isCondExpression(
  105. value: unknown,
  106. ): value is { $cond: VisibilityCondition; $then: unknown; $else: unknown } {
  107. return (
  108. typeof value === "object" &&
  109. value !== null &&
  110. "$cond" in value &&
  111. "$then" in value &&
  112. "$else" in value
  113. );
  114. }
  115. function isComputedExpression(
  116. value: unknown,
  117. ): value is { $computed: string; args?: Record<string, unknown> } {
  118. return (
  119. typeof value === "object" &&
  120. value !== null &&
  121. "$computed" in value &&
  122. typeof (value as Record<string, unknown>).$computed === "string"
  123. );
  124. }
  125. function isTemplateExpression(value: unknown): value is { $template: string } {
  126. return (
  127. typeof value === "object" &&
  128. value !== null &&
  129. "$template" in value &&
  130. typeof (value as Record<string, unknown>).$template === "string"
  131. );
  132. }
  133. // Module-level set to avoid spamming console.warn on every render for the same
  134. // unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
  135. // new names are no longer deduplicated (warnings still fire) but the set stops
  136. // growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
  137. const WARNED_COMPUTED_MAX = 100;
  138. const warnedComputedFns = new Set<string>();
  139. /** @internal Test-only: clear the deduplication set for $computed warnings. */
  140. export function _resetWarnedComputedFns(): void {
  141. warnedComputedFns.clear();
  142. }
  143. // Same deduplication pattern for $template paths that don't start with "/".
  144. const WARNED_TEMPLATE_MAX = 100;
  145. const warnedTemplatePaths = new Set<string>();
  146. /** @internal Test-only: clear the deduplication set for $template warnings. */
  147. export function _resetWarnedTemplatePaths(): void {
  148. warnedTemplatePaths.clear();
  149. }
  150. // =============================================================================
  151. // Prop Expression Resolution
  152. // =============================================================================
  153. // =============================================================================
  154. // $bindItem path resolution helper
  155. // =============================================================================
  156. /**
  157. * Resolve a `$bindItem` path into an absolute state path using the repeat
  158. * scope's base path.
  159. *
  160. * `""` resolves to `repeatBasePath` (the whole item).
  161. * `"field"` resolves to `repeatBasePath + "/field"`.
  162. *
  163. * Returns `undefined` when no `repeatBasePath` is available (i.e. `$bindItem`
  164. * is used outside a repeat scope).
  165. */
  166. function resolveBindItemPath(
  167. itemPath: string,
  168. ctx: PropResolutionContext,
  169. ): string | undefined {
  170. if (ctx.repeatBasePath == null) {
  171. console.warn(`$bindItem used outside repeat scope: "${itemPath}"`);
  172. return undefined;
  173. }
  174. if (itemPath === "") return ctx.repeatBasePath;
  175. return ctx.repeatBasePath + "/" + itemPath;
  176. }
  177. // =============================================================================
  178. // Prop Expression Resolution
  179. // =============================================================================
  180. /**
  181. * Resolve a single prop value that may contain expressions.
  182. * Handles $state, $item, $index, $bindState, $bindItem, and $cond/$then/$else in a single pass.
  183. */
  184. export function resolvePropValue(
  185. value: unknown,
  186. ctx: PropResolutionContext,
  187. ): unknown {
  188. if (value === null || value === undefined) {
  189. return value;
  190. }
  191. // $state: read from global state model
  192. if (isStateExpression(value)) {
  193. return getByPath(ctx.stateModel, value.$state);
  194. }
  195. // $item: read from current repeat item
  196. if (isItemExpression(value)) {
  197. if (ctx.repeatItem === undefined) return undefined;
  198. // "" means the whole item, "field" means a field on the item
  199. return value.$item === ""
  200. ? ctx.repeatItem
  201. : getByPath(ctx.repeatItem, value.$item);
  202. }
  203. // $index: return current repeat array index
  204. if (isIndexExpression(value)) {
  205. return ctx.repeatIndex;
  206. }
  207. // $bindState: two-way binding to global state path
  208. if (isBindStateExpression(value)) {
  209. return getByPath(ctx.stateModel, value.$bindState);
  210. }
  211. // $bindItem: two-way binding to repeat item field
  212. if (isBindItemExpression(value)) {
  213. const resolvedPath = resolveBindItemPath(value.$bindItem, ctx);
  214. if (resolvedPath === undefined) return undefined;
  215. return getByPath(ctx.stateModel, resolvedPath);
  216. }
  217. // $cond/$then/$else: evaluate condition and pick branch
  218. if (isCondExpression(value)) {
  219. const result = evaluateVisibility(value.$cond, ctx);
  220. return resolvePropValue(result ? value.$then : value.$else, ctx);
  221. }
  222. // $computed: call a registered function with resolved args
  223. if (isComputedExpression(value)) {
  224. const fn = ctx.functions?.[value.$computed];
  225. if (!fn) {
  226. if (!warnedComputedFns.has(value.$computed)) {
  227. if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
  228. warnedComputedFns.add(value.$computed);
  229. }
  230. console.warn(`Unknown $computed function: "${value.$computed}"`);
  231. }
  232. return undefined;
  233. }
  234. const resolvedArgs: Record<string, unknown> = {};
  235. if (value.args) {
  236. for (const [key, arg] of Object.entries(value.args)) {
  237. resolvedArgs[key] = resolvePropValue(arg, ctx);
  238. }
  239. }
  240. return fn(resolvedArgs);
  241. }
  242. // $template: interpolate ${/path} references with state values
  243. if (isTemplateExpression(value)) {
  244. return value.$template.replace(
  245. /\$\{([^}]+)\}/g,
  246. (_match, rawPath: string) => {
  247. let path = rawPath;
  248. if (!path.startsWith("/")) {
  249. if (!warnedTemplatePaths.has(path)) {
  250. if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
  251. warnedTemplatePaths.add(path);
  252. }
  253. console.warn(
  254. `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
  255. );
  256. }
  257. path = "/" + path;
  258. }
  259. const resolved = getByPath(ctx.stateModel, path);
  260. return resolved != null ? String(resolved) : "";
  261. },
  262. );
  263. }
  264. // Arrays: resolve each element
  265. if (Array.isArray(value)) {
  266. return value.map((item) => resolvePropValue(item, ctx));
  267. }
  268. // Plain objects (not expressions): resolve each value recursively
  269. if (typeof value === "object") {
  270. const resolved: Record<string, unknown> = {};
  271. for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
  272. resolved[key] = resolvePropValue(val, ctx);
  273. }
  274. return resolved;
  275. }
  276. // Primitive literal: passthrough
  277. return value;
  278. }
  279. /**
  280. * Resolve all prop values in an element's props object.
  281. * Returns a new props object with all expressions resolved.
  282. */
  283. export function resolveElementProps(
  284. props: Record<string, unknown>,
  285. ctx: PropResolutionContext,
  286. ): Record<string, unknown> {
  287. const resolved: Record<string, unknown> = {};
  288. for (const [key, value] of Object.entries(props)) {
  289. resolved[key] = resolvePropValue(value, ctx);
  290. }
  291. return resolved;
  292. }
  293. /**
  294. * Scan an element's raw props for `$bindState` / `$bindItem` expressions
  295. * and return a map of prop name → resolved absolute state path.
  296. *
  297. * This is called **before** `resolveElementProps` so the component can
  298. * receive both the resolved value (in `props`) and the write-back path
  299. * (in `bindings`).
  300. *
  301. * @example
  302. * ```ts
  303. * const rawProps = { value: { $bindState: "/form/email" }, label: "Email" };
  304. * const bindings = resolveBindings(rawProps, ctx);
  305. * // bindings = { value: "/form/email" }
  306. * ```
  307. */
  308. export function resolveBindings(
  309. props: Record<string, unknown>,
  310. ctx: PropResolutionContext,
  311. ): Record<string, string> | undefined {
  312. let bindings: Record<string, string> | undefined;
  313. for (const [key, value] of Object.entries(props)) {
  314. if (isBindStateExpression(value)) {
  315. if (!bindings) bindings = {};
  316. bindings[key] = value.$bindState;
  317. } else if (isBindItemExpression(value)) {
  318. const resolved = resolveBindItemPath(value.$bindItem, ctx);
  319. if (resolved !== undefined) {
  320. if (!bindings) bindings = {};
  321. bindings[key] = resolved;
  322. }
  323. }
  324. }
  325. return bindings;
  326. }
  327. /**
  328. * Resolve a single action parameter value.
  329. *
  330. * Like {@link resolvePropValue} but with special handling for path-valued
  331. * params: `{ $item: "field" }` resolves to an **absolute state path**
  332. * (e.g. `/todos/0/field`) instead of the field's value, so the path can
  333. * be passed to `setState` / `pushState` / `removeState`.
  334. *
  335. * - `{ $item: "field" }` → absolute state path via `repeatBasePath`
  336. * - `{ $index: true }` → current repeat index (number)
  337. * - Everything else delegates to `resolvePropValue` ($state, $cond, literals).
  338. */
  339. export function resolveActionParam(
  340. value: unknown,
  341. ctx: PropResolutionContext,
  342. ): unknown {
  343. if (isItemExpression(value)) {
  344. return resolveBindItemPath(value.$item, ctx);
  345. }
  346. if (isIndexExpression(value)) {
  347. return ctx.repeatIndex;
  348. }
  349. return resolvePropValue(value, ctx);
  350. }