directives.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import type { z } from "zod";
  2. import type { PropResolutionContext } from "./props";
  3. /**
  4. * Definition for a custom directive — a user-defined `$`-prefixed dynamic
  5. * value that extends the spec language.
  6. *
  7. * @example
  8. * ```ts
  9. * const formatDirective = defineDirective({
  10. * name: '$format',
  11. * description: 'Locale-aware value formatting (date, currency, number, percent).',
  12. * schema: z.object({
  13. * $format: z.enum(['date', 'currency', 'number']),
  14. * value: z.unknown(),
  15. * }),
  16. * resolve(value, ctx) {
  17. * const resolved = resolvePropValue(value.value, ctx);
  18. * return new Intl.NumberFormat().format(resolved);
  19. * },
  20. * });
  21. * ```
  22. */
  23. export interface DirectiveDefinition<TSchema extends z.ZodType = z.ZodType> {
  24. /** The `$`-prefixed key that triggers this directive (e.g. `"$format"`). */
  25. name: string;
  26. /**
  27. * Short description of the directive for the AI system prompt.
  28. * The schema fields are auto-generated; this adds behavioral context.
  29. */
  30. description?: string;
  31. /** Zod schema for validating the directive object. */
  32. schema: TSchema;
  33. /**
  34. * Resolver function. Receives the raw directive value and the current
  35. * {@link PropResolutionContext}. May call `resolvePropValue` on sub-values
  36. * to support composition with other dynamic expressions.
  37. */
  38. resolve: (value: z.infer<TSchema>, ctx: PropResolutionContext) => unknown;
  39. }
  40. /**
  41. * A Map from directive name (e.g. `"$format"`) to its definition.
  42. * Passed through {@link PropResolutionContext} for runtime resolution.
  43. */
  44. export type DirectiveRegistry = Map<string, DirectiveDefinition>;
  45. /** Keys handled by built-in prop resolution — directives must not shadow these. */
  46. const BUILT_IN_KEYS = new Set([
  47. "$state",
  48. "$item",
  49. "$index",
  50. "$bindState",
  51. "$bindItem",
  52. "$cond",
  53. "$computed",
  54. "$template",
  55. ]);
  56. /**
  57. * Define a custom directive.
  58. *
  59. * This is an identity function that provides type checking and serves as
  60. * a documentation convention. Throws if the name collides with a built-in
  61. * prop expression key.
  62. */
  63. export function defineDirective<TSchema extends z.ZodType>(
  64. definition: DirectiveDefinition<TSchema>,
  65. ): DirectiveDefinition<TSchema> {
  66. if (!definition.name.startsWith("$")) {
  67. throw new Error(
  68. `Directive name must start with "$": got "${definition.name}"`,
  69. );
  70. }
  71. if (BUILT_IN_KEYS.has(definition.name)) {
  72. throw new Error(
  73. `Directive name "${definition.name}" conflicts with a built-in prop expression key`,
  74. );
  75. }
  76. return definition;
  77. }
  78. /**
  79. * Convert an array of directive definitions into a {@link DirectiveRegistry}.
  80. */
  81. export function createDirectiveRegistry(
  82. directives: DirectiveDefinition[],
  83. ): DirectiveRegistry {
  84. const registry: DirectiveRegistry = new Map();
  85. for (const d of directives) {
  86. registry.set(d.name, d);
  87. }
  88. return registry;
  89. }
  90. /**
  91. * Look up a custom directive for a plain-object value.
  92. *
  93. * Iterates the registry and checks whether the object contains a matching key.
  94. * Returns `undefined` when no match is found or when no registry is provided.
  95. *
  96. * This is only called **after** all built-in expressions (`$state`, `$cond`,
  97. * etc.) have been checked in `resolvePropValue`, so built-ins always take
  98. * precedence. {@link defineDirective} enforces this at registration time by
  99. * rejecting names that collide with built-in keys.
  100. */
  101. export function findDirective(
  102. value: Record<string, unknown>,
  103. directives?: DirectiveRegistry,
  104. ): DirectiveDefinition | undefined {
  105. if (!directives || directives.size === 0) return undefined;
  106. let match: DirectiveDefinition | undefined;
  107. for (const [key, def] of directives) {
  108. if (key in value) {
  109. if (match) {
  110. throw new Error(
  111. `Ambiguous directive: object has multiple directive keys ("${match.name}" and "${key}")`,
  112. );
  113. }
  114. match = def;
  115. }
  116. }
  117. return match;
  118. }