puck icon indicating copy to clipboard operation
puck copied to clipboard

AST-powered state system for Puck

Open Ingenu1ty opened this issue 1 month ago • 2 comments

👋 Hello, everyone! It seems like this is an excellent lib for creating visual editors! (One of the few.) But it could be greatly enhanced by adding features for conditional component state and loop rendering. I mean the features that reka.js can do. (Unfortunately, reka.js seems to have been abandoned)

This would give end-users the ability to create UI components that are nearly as complex as ones that developers could write in code!

Collaborative features would also be useful in Puck...

Ingenu1ty avatar Nov 14 '25 12:11 Ingenu1ty

Hey @Ingenu1ty! Thank you very much for your feature request, and welcome to the community 🤲

That’s an interesting model, right now, Puck is mostly visually driven and block-based, but I don’t think those two approaches are mutually exclusive (they could complement each-other). It's a much more granular perspective on no-code, and it could definitely be worth exploring in the future. I’ll leave this open to track the idea.

As for collaborative features or "multiplayer," we're tracking that here: #207

It hasn’t come up too often in the community yet, which is why we haven’t prioritized it, but I’m definitely interested in exploring it soon.

Thanks again! Feel free to join the Discord server if you have any questions while exploring or building with Puck.

FedericoBonel avatar Nov 18 '25 05:11 FedericoBonel

I need it my own project. I developed a simple solution: (But it works like a charm.)

create-use-render-template.ts:

import { useEffect, useMemo, useState } from "react";
import { useEngine } from "./use-engine";
import { useShallow } from "zustand/react/shallow";

export interface UseRenderTemplateOptions<TCompiled = any> {
  /**
   * Compiles a template string into a reusable function.
   * This function is called once per unique template string.
   * Can be synchronous or asynchronous (e.g., for iframe-based rendering).
   */
  compile: (template: string) => TCompiled | Promise<TCompiled>;

  /**
   * Renders the compiled template with the given context.
   * If not provided, assumes compiled is a function and calls it with context.
   * Can be synchronous or asynchronous (e.g., for iframe postMessage communication).
   * @default (template, compiled, context) => compiled(context)
   */
  render?: (
    template: string,
    compiled: TCompiled,
    context: Record<string, any>,
  ) => string | Promise<string>;

  /**
   * Extracts dependency keys from the template string.
   * These keys are used for selective subscription to the context store.
   * Can be synchronous or asynchronous.
   */
  dependencies: (template: string) => string[] | Promise<string[]>;
  isTemplate: (template?: string) => boolean;
}

/**
 * Creates a useRenderTemplate hook with custom template engine configuration.
 * Supports both synchronous and asynchronous operations (e.g., iframe-based rendering).
 *
 * @example
 * ```ts
 * import _ from "lodash";
 *
 * // Synchronous example
 * export const useRenderTemplate = createUseRenderTemplate({
 *   compile: (template: string) => {
 *     return _.template(template, { interpolate: /{{([\s\S]+?)}}/g });
 *   },
 *   dependencies: (template: string) => {
 *     const matches = template.match(/{{([\s\S]+?)}}/g);
 *     if (!matches) return [];
 *     const deps = new Set<string>();
 *     matches.forEach((match) => {
 *       const content = match.replace(/{{|}}/g, "").trim();
 *       const rootKey = content.split(".")[0];
 *       if (rootKey) deps.add(rootKey);
 *     });
 *     return Array.from(deps);
 *   },
 * });
 *
 * // Asynchronous iframe example (skipping compile)
 * // When iframe handles everything, compile can return noop
 * export const useRuntimeTemplate = createUseRenderTemplate({
 *   compile: (template: string) => {
 *     return () => {}; // Noop - iframe compiles internally
 *   },
 *   render: async (template: string, _compiled: any, context: object) => {
 *     return await renderTemplate(template, context);
 *   },
 *   dependencies: (template: string) => {
 *     return extractDependencies(template);
 *   }
 * });
 *
 * // Asynchronous iframe example (with separate compile step)
 * export const useIframeTemplate = createUseRenderTemplate({
 *   compile: async (template: string) => {
 *     // Send to iframe and wait for compilation
 *     return await iframeCompile(template);
 *   },
 *   render: async (template: string, compiled: any, context: object) => {
 *     // Send to iframe via postMessage and wait for result
 *     return await iframeRender(compiled, context);
 *   },
 *   dependencies: async (template: string) => {
 *     return await iframeExtractDeps(template);
 *   }
 * });
 * ```
 */
export function createUseRenderTemplate<TCompiled = any>(
  options: UseRenderTemplateOptions<TCompiled>,
) {
  const {
    compile,
    render = (_, compiled: any, context) => compiled(context),
    dependencies,
    isTemplate,
  } = options;

  return function useRenderTemplate(templateString?: string) {
    // State for async operations
    const [compiledTemplate, setCompiledTemplate] = useState<
      TCompiled | undefined
    >();
    const [dependencyKeys, setDependencyKeys] = useState<
      string[] | undefined
    >();
    const [renderedValue, setRenderedValue] = useState<string | undefined>(
      () => {
        if (isTemplate(templateString)) return undefined;
        return templateString;
      },
    );

    // 1. ADIM: Template compilation (async support)
    useEffect(() => {
      if (!templateString) {
        setCompiledTemplate(undefined);
        return;
      }

      let cancelled = false;

      (async () => {
        try {
          const result = await compile(templateString);
          if (!cancelled) {
            setCompiledTemplate(() => result);
          }
        } catch (e) {
          console.error("Template compilation error:", e);
          if (!cancelled) {
            setCompiledTemplate(undefined);
          }
        }
      })();

      return () => {
        cancelled = true;
      };
    }, [templateString]);

    // 2. ADIM: Dependency extraction (async support)
    useEffect(() => {
      if (!templateString) {
        setDependencyKeys(undefined);
        return;
      }

      let cancelled = false;

      (async () => {
        try {
          const result = await dependencies(templateString);
          if (!cancelled) {
            setDependencyKeys(result);
          }
        } catch (e) {
          console.error("Dependency extraction error:", e);
          if (!cancelled) {
            setDependencyKeys(undefined);
          }
        }
      })();

      return () => {
        cancelled = true;
      };
    }, [templateString]);

    // 3. ADIM: Store'dan SADECE gerekli verileri çek (Selective Subscription)
    const relevantContext = useEngine(
      useShallow((state) => {
        if (!dependencyKeys) return {};

        const result: Record<string, any> = {};

        // Eğer template hiç değişken kullanmıyorsa boş dön
        if (dependencyKeys.length === 0) return result;

        // Sadece dependency array içindeki keyleri context'ten al
        dependencyKeys.forEach((key) => {
          result[key] = state.context[key];
        });

        return result;
      }),
    );

    // 4. ADIM: Rendering (async support)
    useEffect(() => {
      if (!compiledTemplate || !templateString) {
        setRenderedValue(undefined);
        return;
      }

      let cancelled = false;

      (async () => {
        try {
          const result = await render(
            templateString,
            compiledTemplate,
            relevantContext,
          );
          if (!cancelled) {
            setRenderedValue(result);
          }
        } catch (e) {
          console.error("Template render error:", e);
          if (!cancelled) {
            setRenderedValue(undefined);
          }
        }
      })();

      return () => {
        cancelled = true;
      };
    }, [compiledTemplate, relevantContext, templateString]);

    return renderedValue;
  };
}

use-engine.ts:

import { create } from "zustand";
import { devtools } from "zustand/middleware";

interface EngineState {
  context: Record<string, any>;
  setContext: (name: string, value: any) => void;
}

export const useEngine = create<EngineState>()(
  devtools((set) => ({
    context: {},
    setContext: (name, value) =>
      set((state) => ({
        context: {
          ...state.context,
          [name]: {
            ...state.context[name], // Mevcut veriyi koru
            ...value, // Yeni veriyi üzerine yaz
          },
        },
      })),
  })),
);

use-template-engine-component.ts:

import { useEffect, useId } from "react";
import { useEngine } from "./use-engine"; // store dosyanızın yolu

export function useTemplateEngineComponent<T>(name?: string, initialState?: T) {
  // Store'dan setter fonksiyonunu al
  const setContext = useEngine((state) => state.setContext);

  const id = useId();

  const key = name || id;

  // Sadece bu bileşene ait state'i dinle (Selector pattern)
  // Eğer henüz store'da yoksa initialState'i fallback olarak kullan
  const state = useEngine((state) => state.context[key] || initialState);

  // Component mount olduğunda initial state'i store'a yaz
  useEffect(() => {
    setContext(key, initialState);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key]); // Sadece name değişirse tekrar çalışsın

  const setState = (newState: Partial<T>) => {
    setContext(key, newState);
  };

  return [state, setState] as const;
}

Example usage:

use-render-template.ts:

import { createUseRenderTemplate } from "./create-use-render-template";
import { renderTemplate } from "./runtime-client";
import { parse } from "espree";

// Regex: {{ degisken }} yapısını yakalar
const INTERPOLATE_REGEX = /{{([\s\S]+?)}}/g;

/**
 * AST'yi traverse ederek identifier'ları toplar.
 * MemberExpression'larda sadece root identifier'ı alır (user.name -> user)
 */
function collectIdentifiers(node: any, identifiers: Set<string>): void {
  if (!node || typeof node !== "object") return;

  // MemberExpression: user.value.name -> sadece "user" al
  if (node.type === "MemberExpression") {
    // En soldaki identifier'ı bul
    let current = node;
    while (current.type === "MemberExpression") {
      current = current.object;
    }
    if (current.type === "Identifier") {
      identifiers.add(current.name);
    }
    return; // Alt düğümleri tekrar gezmeye gerek yok
  }

  // Identifier: direkt değişken ismi
  if (node.type === "Identifier") {
    identifiers.add(node.name);
    return;
  }

  // Diğer node tiplerini recursive olarak gez
  for (const key in node) {
    if (key === "loc" || key === "range" || key === "start" || key === "end") {
      continue; // Metadata'yı atla
    }
    const value = node[key];
    if (Array.isArray(value)) {
      value.forEach((child) => collectIdentifiers(child, identifiers));
    } else if (typeof value === "object") {
      collectIdentifiers(value, identifiers);
    }
  }
}

/**
 * Template string içindeki kök değişkenleri bulur.
 * Espree kullanarak AST parse eder, böylece karmaşık ifadeleri doğru analiz eder.
 *
 * Örnekler:
 * - "{{ user.value }}" -> ['user']
 * - "{{ user.value }} ve {{ city.name }}" -> ['user', 'city']
 * - "{{ 1 ? 'a' : 'b' }}" -> [] (literal, değişken yok)
 * - "{{ isActive ? user.name : guest.name }}" -> ['isActive', 'user', 'guest']
 */
function extractDependencies(templateStr: string): string[] {
  const matches = templateStr.match(INTERPOLATE_REGEX);
  if (!matches) return [];

  const dependencies = new Set<string>();

  matches.forEach((match) => {
    // {{ user.value }} -> user.value
    const content = match.replace(/{{|}}/g, "").trim();

    try {
      // JavaScript expression olarak parse et
      const ast = parse(content, {
        ecmaVersion: "latest",
        sourceType: "module",
      });

      // AST'den identifier'ları topla
      collectIdentifiers(ast, dependencies);
    } catch (error) {
      // Parse hatası olursa fallback: basit regex
      console.warn(`Failed to parse template expression: ${content}`, error);
      const rootKey = content.split(".")[0];
      if (rootKey && /^[a-zA-Z_$]/.test(rootKey)) {
        dependencies.add(rootKey);
      }
    }
  });

  return Array.from(dependencies);
}

const NoopFunction = () => {};

/**
 * Iframe-based sandboxed template render hook.
 * Uses runtime-client for secure template rendering.
 *
 * Compile step is skipped (returns noop function) because iframe handles
 * the entire compilation and rendering process internally.
 *
 * @example
 * ```tsx
 * const label = useRuntimeTemplate("{{ user.name }}");
 * // Renders via iframe, subscribes only to "user" key changes
 * ```
 */
export const useRenderTemplate = createUseRenderTemplate({
  // Compile is not needed for iframe - return noop function
  // Iframe handles compilation internally during render
  compile: (template: string) => {
    return NoopFunction; // Noop function, not used
  },

  // Render delegates to iframe via runtime-client
  render: async (
    template: string,
    _compiled: any,
    context: Record<string, any>,
  ) => {
    return await renderTemplate(template, context);
  },

  // Dependencies extracted on client-side for selective subscription
  dependencies: (template: string) => {
    return extractDependencies(template);
  },

  isTemplate: (template?: string) => {
    if (!template) return false;
    return template.includes("{{") && template.includes("}}");
  },
});

Of course the above template rendered by inside a iframe for security. An simplier example:

import lodashTemplate from "lodash/template"

export const useRenderTemplate = createUseRenderTemplate({
  compile: (template: string) => {
    return lodashTemplate(template)
  },
  dependencies: (template: string) => {
    return extractDependencies(template);
  },
  isTemplate: (template?: string) => {
    if (!template) return false;
    return template.includes("{{") && template.includes("}}");
  },
});

input.tsx:


// omitted puck components settings
fields: {
  name: {
    type: "uniqueName"
  }
},
render({ name, value, puck }) {
  const [state, setState] = useTemplateEngineComponent<{ value: string | null }>(name, { value })

  return <input ref={puck.dragRef} value={state.value} onChange={(e) => setState({ value: e.target.value })} />
}

text.tsx:

// omitted puck settings
fields: {
  text: { type: "templateString" } // show codemirror editor
},
render({ text, puck }) {
  const renderedText = useRenderTemplate(text);

  return <span ref={puck.dragRef}>{renderedText}</span>
}

Example content:

{
  content: [
    {
      type: "input",
      props: { name: "abc", value: "Cihad" }
    },
    {
      type: "text",
      props: { text: "Merhaba {{ abc.value }}!" } // It renders as 'Merhaba Cihad!'
    },
  ]
}

cihad avatar Dec 01 '25 23:12 cihad