Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion lib/mcp/tools/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
replaceLayerWithComponentInstance,
} from '@/lib/layer-utils';
import { EMPTY_OVERRIDES, createTextComponentVariableValue } from '@/lib/variable-utils';
import { stringToTiptapContent } from '@/lib/text-format-utils';
import { collectFontFamiliesFromDesign, ensureFontsInstalled, fontWarnings } from '@/lib/mcp/font-install';
import { getCachedLayers as getPageLayers, saveCachedLayers } from '@/lib/mcp/page-layers';
import { getAllPages } from '@/lib/repositories/pageRepository';
Expand Down Expand Up @@ -60,13 +61,30 @@ const variableUpdateSchema = z.object({
default_value: z.unknown().optional(),
});

/**
* Coerce a `default_value` into the object shape the renderer expects.
*
* `default_value` is intentionally untyped in the tool schema (its shape
* depends on the variable type), so callers routinely pass a bare string for
* text variables. Storing that verbatim breaks rendering for every page using
* the component, so wrap it in the matching variable value here.
*/
function normalizeDefaultValue(value: unknown, type: z.infer<typeof variableTypeEnum>): ComponentVariableValue {
if (typeof value === 'string') {
return type === 'rich_text'
? createTextComponentVariableValue(stringToTiptapContent(value))
: { type: 'dynamic_text', data: { content: value } };
}
return value as ComponentVariableValue;
}

function normalizeVariables(input: Array<z.infer<typeof variableUpdateSchema>>): ComponentVariable[] {
return input.map((v) => ({
id: v.id || generateId(),
name: v.name,
type: v.type,
...(v.placeholder !== undefined && { placeholder: v.placeholder }),
...(v.default_value !== undefined && { default_value: v.default_value as ComponentVariableValue }),
...(v.default_value !== undefined && { default_value: normalizeDefaultValue(v.default_value, v.type) }),
}));
}

Expand Down
5 changes: 3 additions & 2 deletions lib/page-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4228,9 +4228,10 @@ function resolveLayerAssets(
}
}

// Resolve richTextImage src URLs inside Tiptap content
// Resolve richTextImage src URLs inside Tiptap content. The value is read
// from persisted layer JSON, so guard against a primitive before probing it.
const textVar = layer.variables?.text;
if (textVar && 'type' in textVar && textVar.type === 'dynamic_rich_text') {
if (textVar && typeof textVar === 'object' && 'type' in textVar && textVar.type === 'dynamic_rich_text') {
const resolvedContent = resolveRichTextImageAssets((textVar as any).data?.content, assetMap);
if (resolvedContent !== (textVar as any).data?.content) {
variableUpdates.text = {
Expand Down
3 changes: 2 additions & 1 deletion lib/resolve-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { Layer, Component, ComponentVariable, ComponentVariableValue, LayerVariables, VariantSettingsValue } from '@/types';
import { getComponentVariantLayers } from './component-variant-utils';
import { normalizeComponentVariableValue } from './variable-utils';

/**
* Remap collection_layer_id in a FieldVariable using the ID map.
Expand Down Expand Up @@ -386,7 +387,7 @@ export function applyComponentOverrides(
const variableDef = componentVariables?.find(v => v.id === linkedTextVariableId);
const overrideCategory = (variableDef?.type === 'rich_text' ? 'rich_text' : 'text') as OverrideCategory;
const overrideValue = overrides?.[overrideCategory]?.[linkedTextVariableId];
const valueToApply = overrideValue ?? variableDef?.default_value;
const valueToApply = normalizeComponentVariableValue(overrideValue ?? variableDef?.default_value);

// Only apply if it's a text variable (has 'type' property, not ImageSettingsValue)
if (valueToApply && 'type' in valueToApply) {
Expand Down
30 changes: 25 additions & 5 deletions lib/variable-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ export function createTextComponentVariableValue(tiptapContent: object): Compone
};
}

/**
* Coerce a persisted component variable value into its object form.
*
* Text values are expected to be `{ type, data }` objects, but a bare string
* can reach the database — the MCP component tools accept an unvalidated
* `default_value`, so an agent can store `"Accessories"` instead of
* `{ type: 'dynamic_text', data: { content: 'Accessories' } }`. Callers probe
* these values with the `in` operator, which throws on a primitive, so
* normalize before inspecting. Returns undefined for values that can never
* be a variable value.
*/
export function normalizeComponentVariableValue(value: unknown): ComponentVariableValue | undefined {
if (typeof value === 'string') {
return { type: 'dynamic_text', data: { content: value } };
}
if (typeof value !== 'object' || value === null) return undefined;
return value as ComponentVariableValue;
}

/**
* Extract Tiptap JSON content from text ComponentVariableValue
* Returns the Tiptap content object or a default empty document
Expand All @@ -123,16 +142,17 @@ export function createTextComponentVariableValue(tiptapContent: object): Compone
export function extractTiptapFromComponentVariable(value?: ComponentVariableValue): object {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };

if (!value) return emptyDoc;
const normalized = normalizeComponentVariableValue(value);
if (!normalized) return emptyDoc;

// Check if value is a text variable (has 'type' property) vs ImageSettingsValue (has 'src' property)
if ('type' in value && value.type === 'dynamic_rich_text') {
return (value as DynamicRichTextVariable).data.content;
if ('type' in normalized && normalized.type === 'dynamic_rich_text') {
return (normalized as DynamicRichTextVariable).data.content;
}

if ('type' in value && value.type === 'dynamic_text') {
if ('type' in normalized && normalized.type === 'dynamic_text') {
// Convert plain text to Tiptap format
return stringToTiptapContent((value as DynamicTextVariable).data.content);
return stringToTiptapContent((normalized as DynamicTextVariable).data.content);
}

return emptyDoc;
Expand Down