diff --git a/app/ycode/components/CanvasTextEditor.tsx b/app/ycode/components/CanvasTextEditor.tsx
index 03fd59fe..f1ed33da 100644
--- a/app/ycode/components/CanvasTextEditor.tsx
+++ b/app/ycode/components/CanvasTextEditor.tsx
@@ -11,7 +11,6 @@
*/
import React, { useEffect, useMemo, useCallback, forwardRef, useImperativeHandle, useRef } from 'react';
-import { createRoot } from 'react-dom/client';
import { useEditor, EditorContent } from '@tiptap/react';
import { Mark, mergeAttributes } from '@tiptap/core';
import Document from '@tiptap/extension-document';
@@ -32,14 +31,11 @@ import { RichTextImage } from '@/lib/tiptap-extensions/rich-text-image';
import { getTextStyleClasses } from '@/lib/text-format-utils';
import type { Layer, TextStyle, CollectionField, Collection } from '@/types';
import type { FieldVariable } from '@/types';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import Icon from '@/components/ui/icon';
import {
parseValueToContent,
getVariableLabel,
} from '@/lib/cms-variables-utils';
-import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable';
+import { createDynamicVariableNodeView } from '@/lib/dynamic-variable-view';
import { RichTextComponent } from '@/lib/tiptap-extensions/rich-text-component';
import { useCanvasTextEditorStore } from '@/stores/useCanvasTextEditorStore';
import { RichTextLink } from '@/lib/tiptap-extensions/rich-text-link';
@@ -76,61 +72,7 @@ export interface CanvasTextEditorHandle {
* DynamicVariable with React node view for the canvas text editor.
* Extends the shared extension with canvas-specific Badge styling.
*/
-const DynamicVariableWithNodeView = DynamicVariable.extend({
- addNodeView() {
- return ({ node, getPos, editor }) => {
- const container = document.createElement('span');
- container.className = 'inline-block';
- container.contentEditable = 'false';
-
- const variable = node.attrs.variable;
- if (variable) {
- container.setAttribute('data-variable', JSON.stringify(variable));
- }
-
- const label = getDynamicVariableLabel(node);
-
- const handleDelete = () => {
- const pos = getPos();
- if (typeof pos === 'number') {
- editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
- }
- };
-
- const root = createRoot(container);
-
- const renderBadge = () => {
- root.render(
-
- {label}
- {editor.isEditable && (
-
- )}
-
- );
- };
-
- queueMicrotask(renderBadge);
-
- const updateListener = () => renderBadge();
- editor.on('update', updateListener);
-
- return {
- dom: container,
- destroy: () => {
- editor.off('update', updateListener);
- setTimeout(() => root.unmount(), 0);
- },
- };
- };
- },
-});
+const DynamicVariableWithNodeView = createDynamicVariableNodeView('canvas');
/**
* All block/mark extensions use a ref so renderHTML always reads the latest textStyles.
diff --git a/app/ycode/components/CenterCanvas.tsx b/app/ycode/components/CenterCanvas.tsx
index 0760b25c..31dbc12c 100644
--- a/app/ycode/components/CenterCanvas.tsx
+++ b/app/ycode/components/CenterCanvas.tsx
@@ -62,6 +62,7 @@ import { cn } from '@/lib/utils';
import { getCollectionVariable, canDeleteLayer, findLayerById, findParentCollectionLayer, canLayerHaveLink, updateLayerProps, removeRichTextSublayer } from '@/lib/layer-utils';
import { CANVAS_BORDER, CANVAS_PADDING } from '@/lib/canvas-utils';
import { buildFieldGroupsForLayer, flattenFieldGroups, filterFieldGroupsByType, SIMPLE_TEXT_FIELD_TYPES } from '@/lib/collection-field-utils';
+import { buildFieldVariableData } from '@/lib/variable-format-utils';
import { getRichTextValue } from '@/lib/tiptap-utils';
import { DropContainerIndicator, DropLineIndicator } from '@/components/DropIndicators';
import { DragCaptureOverlay } from '@/components/DragCaptureOverlay';
@@ -2147,15 +2148,7 @@ const CenterCanvas = React.memo(function CenterCanvas({
const flatFields = flattenFieldGroups(fieldGroups);
const field = flatFields.find(f => f.id === fieldId);
addFieldVariable(
- {
- type: 'field',
- data: {
- field_id: fieldId,
- relationships: relationshipPath,
- source,
- field_type: field?.type || null,
- },
- },
+ buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source),
flatFields,
collectionFieldsFromStore
);
diff --git a/app/ycode/components/ExpandableRichTextEditor.tsx b/app/ycode/components/ExpandableRichTextEditor.tsx
index 4737e046..3d2de719 100644
--- a/app/ycode/components/ExpandableRichTextEditor.tsx
+++ b/app/ycode/components/ExpandableRichTextEditor.tsx
@@ -5,7 +5,7 @@
* a RichTextEditorSheet for full-featured editing.
*/
-import { useMemo, useState } from 'react';
+import { useMemo, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import Icon from '@/components/ui/icon';
import {
@@ -15,9 +15,11 @@ import {
} from '@/components/ui/dropdown-menu';
import RichTextEditor from './RichTextEditor';
import RichTextEditorSheet from './RichTextEditorSheet';
+import VariableFormatSelector from './VariableFormatSelector';
import { CollectionFieldSelector, type FieldSourceType } from './CollectionFieldSelector';
import { hasLinkOrComponent, getSoleCmsFieldBinding } from '@/lib/tiptap-utils';
import { getVariableLabel } from '@/lib/cms-variables-utils';
+import { isFormattableFieldType, buildFieldVariableData } from '@/lib/variable-format-utils';
import { flattenFieldGroups, filterFieldGroupsByType, RICH_TEXT_ONLY_FIELD_TYPES } from '@/lib/collection-field-utils';
import type { CollectionField, Collection, CollectionFieldType } from '@/types';
import type { FieldGroup } from '@/lib/collection-field-utils';
@@ -63,12 +65,41 @@ export default function ExpandableRichTextEditor({
const [cmsDropdownOpen, setCmsDropdownOpen] = useState(false);
const isComplex = useMemo(() => hasLinkOrComponent(value), [value]);
- const richTextBinding = useMemo(() => {
+ const soleBinding = useMemo(() => {
if (!buttonOnly) return null;
- const binding = getSoleCmsFieldBinding(value);
- return binding?.field_type === 'rich_text' ? binding : null;
+ return getSoleCmsFieldBinding(value);
}, [buttonOnly, value]);
+ const richTextBinding = useMemo(() => {
+ return soleBinding?.field_type === 'rich_text' ? soleBinding : null;
+ }, [soleBinding]);
+
+ const formattableBinding = useMemo(() => {
+ return soleBinding && isFormattableFieldType(soleBinding.field_type) ? soleBinding : null;
+ }, [soleBinding]);
+
+ const handleFormatChange = useCallback((formatId: string) => {
+ if (!value?.content?.[0]?.content?.[0]?.attrs?.variable) return;
+ const variable = value.content[0].content[0].attrs.variable;
+ const updatedContent = {
+ type: 'doc',
+ content: [{
+ type: 'paragraph',
+ content: [{
+ type: 'dynamicVariable',
+ attrs: {
+ ...value.content[0].content[0].attrs,
+ variable: {
+ ...variable,
+ data: { ...variable.data, format: formatId },
+ },
+ },
+ }],
+ }],
+ };
+ onChange(updatedContent);
+ }, [value, onChange]);
+
const textFieldGroups = useMemo(
() => filterFieldGroupsByType(fieldGroups, allowedFieldTypes),
[fieldGroups, allowedFieldTypes],
@@ -82,16 +113,7 @@ export default function ExpandableRichTextEditor({
const handleFieldSelect = (fieldId: string, relationshipPath: string[], source?: FieldSourceType, layerId?: string) => {
const field = fields.find(f => f.id === fieldId);
- const variableData = {
- type: 'field' as const,
- data: {
- field_id: fieldId,
- relationships: relationshipPath,
- source,
- field_type: field?.type || null,
- collection_layer_id: layerId,
- },
- };
+ const variableData = buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source, layerId);
const label = getVariableLabel(variableData, fields, allFields);
const newContent = {
@@ -108,36 +130,47 @@ export default function ExpandableRichTextEditor({
setCmsDropdownOpen(false);
};
- if (richTextBinding && !sheetOpen) {
+ if ((richTextBinding || formattableBinding) && !sheetOpen) {
+ const activeBinding = richTextBinding || formattableBinding;
return (
<>
-
+
+
setSheetOpen(true)}
+ >
+
+
+
+ {activeBinding!.label || 'CMS Field'}
+
+ {
+ e.stopPropagation();
+ if (onClear) {
+ onClear();
+ } else {
+ onChange({ type: 'doc', content: [{ type: 'paragraph' }] });
+ }
+ }}
+ >
+
+
+
+
+ {formattableBinding && (
+
+ )}
+
{
diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx
index 1f9c3142..6270b0a8 100644
--- a/app/ycode/components/RichTextEditor.tsx
+++ b/app/ycode/components/RichTextEditor.tsx
@@ -57,7 +57,8 @@ import {
} from '@/components/ui/dropdown-menu';
import { CollectionFieldSelector, type FieldSourceType } from './CollectionFieldSelector';
import { flattenFieldGroups, filterFieldGroupsByType, RICH_TEXT_ONLY_FIELD_TYPES, type FieldGroup } from '@/lib/collection-field-utils';
-import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable';
+import { buildFieldVariableData } from '@/lib/variable-format-utils';
+import { createDynamicVariableNodeView } from '@/lib/dynamic-variable-view';
import { RichTextComponent } from '@/lib/tiptap-extensions/rich-text-component';
import { RichTextLink, getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link';
import { RichTextImage } from '@/lib/tiptap-extensions/rich-text-image';
@@ -120,61 +121,7 @@ export type { FieldVariable } from '@/types';
* DynamicVariable with React node view for the sidebar rich-text editor.
* Extends the shared extension with a Badge-based node view.
*/
-const DynamicVariableWithNodeView = DynamicVariable.extend({
- addNodeView() {
- return ({ node, getPos, editor }) => {
- const container = document.createElement('span');
- container.className = 'inline-block';
- container.contentEditable = 'false';
-
- const variable = node.attrs.variable;
- if (variable) {
- container.setAttribute('data-variable', JSON.stringify(variable));
- }
-
- const label = getDynamicVariableLabel(node);
-
- const handleDelete = () => {
- const pos = getPos();
- if (typeof pos === 'number') {
- editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
- }
- };
-
- const root = createRoot(container);
-
- const renderBadge = () => {
- root.render(
-
- {label}
- {editor.isEditable && (
-
-
-
- )}
-
- );
- };
-
- queueMicrotask(renderBadge);
-
- const updateListener = () => renderBadge();
- editor.on('update', updateListener);
-
- return {
- dom: container,
- destroy: () => {
- editor.off('update', updateListener);
- setTimeout(() => root.unmount(), 0);
- },
- };
- };
- },
-});
+const DynamicVariableWithNodeView = createDynamicVariableNodeView('sidebar');
/**
* RichTextComponent with React node view for embedding components.
@@ -828,18 +775,8 @@ const RichTextEditor = forwardRef(({
const handleFieldSelect = (fieldId: string, relationshipPath: string[], source?: FieldSourceType, layerId?: string) => {
const field = fields.find(f => f.id === fieldId);
- addFieldVariableInternal({
- type: 'field',
- data: {
- field_id: fieldId,
- relationships: relationshipPath,
- source,
- field_type: field?.type || null,
- collection_layer_id: layerId,
- },
- });
+ addFieldVariableInternal(buildFieldVariableData(fieldId, relationshipPath, field?.type ?? null, source, layerId));
- // Close the dropdown after selection
setIsDropdownOpen(false);
};
diff --git a/app/ycode/components/VariableFormatSelector.tsx b/app/ycode/components/VariableFormatSelector.tsx
new file mode 100644
index 00000000..893c2cff
--- /dev/null
+++ b/app/ycode/components/VariableFormatSelector.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+/**
+ * Format selector for date and number inline variables.
+ *
+ * Two modes:
+ * - Default (no children): renders a chevron button that opens the popover
+ * - Wrapper (with children): wraps children as the popover trigger
+ */
+
+import React, { useState, useCallback } from 'react';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { Button } from '@/components/ui/button';
+import Icon from '@/components/ui/icon';
+import { cn } from '@/lib/utils';
+import {
+ getFormatSectionsForFieldType,
+ getDateFormatPreview,
+ getNumberFormatPreview,
+ type DateFormatPreset,
+ type NumberFormatPreset,
+} from '@/lib/variable-format-utils';
+
+interface VariableFormatSelectorProps {
+ fieldType: string | null | undefined;
+ currentFormat?: string;
+ onFormatChange: (formatId: string) => void;
+ /** Visual variant for different editor contexts */
+ variant?: 'sidebar' | 'canvas';
+ /** When provided, children become the popover trigger instead of the chevron button */
+ children?: React.ReactNode;
+}
+
+function getPreview(preset: DateFormatPreset | NumberFormatPreset): string {
+ if ('sample' in preset) {
+ return getNumberFormatPreview(preset);
+ }
+ return getDateFormatPreview(preset);
+}
+
+export default function VariableFormatSelector({
+ fieldType,
+ currentFormat,
+ onFormatChange,
+ variant = 'sidebar',
+ children,
+}: VariableFormatSelectorProps) {
+ const [open, setOpen] = useState(false);
+ const sections = getFormatSectionsForFieldType(fieldType);
+
+ const handleSelect = useCallback((formatId: string) => {
+ onFormatChange(formatId);
+ setOpen(false);
+ }, [onFormatChange]);
+
+ if (sections.length === 0) {
+ return children ? <>{children}> : null;
+ }
+
+ const trigger = children ? (
+
+ e.stopPropagation()}
+ >
+ {children}
+
+
+ ) : (
+
+ {
+ e.stopPropagation();
+ }}
+ aria-label="Change format"
+ >
+
+
+
+ );
+
+ return (
+
+ {trigger}
+ e.stopPropagation()}
+ onPointerDownOutside={(e) => e.stopPropagation()}
+ >
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+ {section.presets.map((preset) => (
+
handleSelect(preset.id)}
+ >
+ {getPreview(preset)}
+ {currentFormat === preset.id && (
+
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/lib/cms-variables-utils.ts b/lib/cms-variables-utils.ts
index 31e05b6a..13d6680e 100644
--- a/lib/cms-variables-utils.ts
+++ b/lib/cms-variables-utils.ts
@@ -8,17 +8,21 @@
import type { CollectionField, InlineVariable } from '@/types';
import { formatDateInTimezone } from '@/lib/date-format-utils';
import { extractPlainTextFromTiptap } from '@/lib/tiptap-utils';
+import { formatDateWithPreset, formatNumberWithPreset } from '@/lib/variable-format-utils';
/**
* Format a field value for display based on field type
- * - date: formats in user's timezone
+ * - date: formats in user's timezone (with optional format preset)
+ * - number: formats with optional number preset
* - rich_text: extracts plain text from Tiptap JSON
* Returns the original value for other fields
+ * @param format - Optional format preset ID (e.g. 'date-long', 'number-decimal')
*/
export function formatFieldValue(
value: unknown,
fieldType: string | null | undefined,
- timezone: string = 'UTC'
+ timezone: string = 'UTC',
+ format?: string
): string {
if (value === null || value === undefined) return '';
@@ -27,7 +31,6 @@ export function formatFieldValue(
if (typeof value === 'object') {
return extractPlainTextFromTiptap(value);
}
- // If it's a string (legacy or unparsed), try to parse and extract
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
@@ -39,14 +42,24 @@ export function formatFieldValue(
return '';
}
- // Handle date fields
+ // Handle date fields with optional format preset
if (fieldType === 'date' && typeof value === 'string') {
+ if (format) {
+ return formatDateWithPreset(value, format, timezone);
+ }
return formatDateInTimezone(value, timezone, 'display');
}
+ // Handle number fields with optional format preset
+ if (fieldType === 'number' && format) {
+ const numValue = typeof value === 'number' ? value : parseFloat(String(value));
+ if (!isNaN(numValue)) {
+ return formatNumberWithPreset(numValue, format);
+ }
+ }
+
// For other fields, ensure we return a string
if (typeof value === 'object') {
- // Safety fallback - if an object slips through, stringify it
return JSON.stringify(value);
}
diff --git a/lib/dynamic-variable-view.tsx b/lib/dynamic-variable-view.tsx
new file mode 100644
index 00000000..4b50f638
--- /dev/null
+++ b/lib/dynamic-variable-view.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+/**
+ * Shared TipTap NodeView factory for dynamic variable badges.
+ *
+ * Creates a DynamicVariable extension with a React-based NodeView that renders
+ * an inline badge with optional format selector (for date/number fields).
+ *
+ * Used by both RichTextEditor (sidebar variant) and CanvasTextEditor (canvas variant).
+ */
+
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import Icon from '@/components/ui/icon';
+
+import { DynamicVariable, getDynamicVariableLabel } from '@/lib/tiptap-extensions/dynamic-variable';
+import { isFormattableFieldType } from '@/lib/variable-format-utils';
+import VariableFormatSelector from '@/app/ycode/components/VariableFormatSelector';
+
+type VariableViewVariant = 'sidebar' | 'canvas';
+
+const VARIANT_CONFIG = {
+ sidebar: {
+ badgeVariant: 'secondary' as const,
+ deleteButtonVariant: 'outline' as const,
+ formatSelectorVariant: 'sidebar' as const,
+ },
+ canvas: {
+ badgeVariant: 'inline_variable_canvas' as const,
+ deleteButtonVariant: 'inline_variable_canvas' as const,
+ formatSelectorVariant: 'canvas' as const,
+ },
+};
+
+/**
+ * Create a DynamicVariable TipTap extension with a React NodeView.
+ * The variant controls visual styling (Badge/Button variants).
+ */
+export function createDynamicVariableNodeView(variant: VariableViewVariant) {
+ const config = VARIANT_CONFIG[variant];
+
+ return DynamicVariable.extend({
+ addNodeView() {
+ return ({ node: initialNode, getPos, editor }) => {
+ const container = document.createElement('span');
+ container.className = 'inline-block';
+ container.contentEditable = 'false';
+
+ let currentNode = initialNode;
+ const variable = currentNode.attrs.variable;
+ if (variable) {
+ container.setAttribute('data-variable', JSON.stringify(variable));
+ }
+
+ const label = getDynamicVariableLabel(currentNode);
+ const fieldType = variable?.data?.field_type;
+ const isFormattable = isFormattableFieldType(fieldType);
+
+ const handleDelete = () => {
+ const pos = getPos();
+ if (typeof pos === 'number') {
+ editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
+ }
+ };
+
+ const handleFormatChange = (formatId: string) => {
+ const pos = getPos();
+ if (typeof pos === 'number') {
+ const currentVariable = currentNode.attrs.variable;
+ const updatedVariable = {
+ ...currentVariable,
+ data: { ...currentVariable.data, format: formatId },
+ };
+ editor.chain().focus()
+ .command(({ tr }) => {
+ tr.setNodeMarkup(pos, undefined, {
+ ...currentNode.attrs,
+ variable: updatedVariable,
+ });
+ return true;
+ })
+ .run();
+ }
+ };
+
+ const root = createRoot(container);
+
+ const renderBadge = () => {
+ const currentFormat = currentNode.attrs.variable?.data?.format;
+ const badgeContent = (
+
+ {label}
+ {editor.isEditable && isFormattable && (
+
+ )}
+ {editor.isEditable && (
+ {
+ e.stopPropagation();
+ handleDelete();
+ }}
+ className="size-4! p-0! -mr-1"
+ variant={config.deleteButtonVariant}
+ >
+
+
+ )}
+
+ );
+
+ root.render(
+ editor.isEditable && isFormattable ? (
+
+ {badgeContent}
+
+ ) : badgeContent
+ );
+ };
+
+ queueMicrotask(renderBadge);
+
+ const updateListener = () => renderBadge();
+ editor.on('update', updateListener);
+
+ return {
+ dom: container,
+ update: (updatedNode) => {
+ if (updatedNode.type.name !== 'dynamicVariable') return false;
+ currentNode = updatedNode;
+ renderBadge();
+ return true;
+ },
+ destroy: () => {
+ editor.off('update', updateListener);
+ setTimeout(() => root.unmount(), 0);
+ },
+ };
+ };
+ },
+ });
+}
diff --git a/lib/inline-variables.ts b/lib/inline-variables.ts
index 339cbc0f..c649a3d1 100644
--- a/lib/inline-variables.ts
+++ b/lib/inline-variables.ts
@@ -34,7 +34,7 @@ export function resolveInlineVariables(
if (parsed.type === 'field' && parsed.data?.field_id) {
const fieldValue = collectionItem.values[parsed.data.field_id];
- return formatFieldValue(fieldValue, parsed.data.field_type, timezone);
+ return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format);
}
} catch {
// Invalid JSON or not a field variable, leave as is
@@ -84,7 +84,7 @@ export function resolveInlineVariablesFromData(
parsed.data.collection_layer_id,
layerDataMap
);
- return formatFieldValue(fieldValue, parsed.data.field_type, timezone);
+ return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format);
}
} catch {
// Invalid JSON
diff --git a/lib/page-fetcher.ts b/lib/page-fetcher.ts
index e9d96723..e6475c55 100644
--- a/lib/page-fetcher.ts
+++ b/lib/page-fetcher.ts
@@ -24,6 +24,7 @@ import type { LinkResolutionContext } from '@/lib/link-utils';
import { getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link';
import { SWIPER_CLASS_MAP, SWIPER_DATA_ATTR_MAP } from '@/lib/templates/utilities';
import { resolveInlineVariables, resolveInlineVariablesFromData } from '@/lib/inline-variables';
+import { formatFieldValue } from '@/lib/cms-variables-utils';
import { buildLayerTranslationKey, getTranslationByKey, hasValidTranslationValue, getTranslationValue } from '@/lib/localisation-utils';
import { formatDateFieldsInItemValues } from '@/lib/date-format-utils';
import { getSettingByKey } from '@/lib/repositories/settingsRepository';
@@ -429,6 +430,7 @@ export const fetchPageByPath = cache(async function fetchPageByPath(
// Format date fields in user's timezone
const timezone = (await getSettingByKey('timezone') as string | null) || 'UTC';
+ const rawItemValues = { ...enhancedItemValues };
enhancedItemValues = formatDateFieldsInItemValues(enhancedItemValues, collectionFields, timezone);
// Create enhanced collection item with resolved reference values and translations
@@ -444,7 +446,7 @@ export const fetchPageByPath = cache(async function fetchPageByPath(
// This resolves inline variables like "Name → Location" on the page
const layersWithInjectedData = await Promise.all(
layersWithComponents.map((layer: Layer) =>
- injectCollectionData(layer, enhancedItemValues, collectionFields, isPublished)
+ injectCollectionData(layer, enhancedItemValues, collectionFields, isPublished, undefined, rawItemValues, timezone)
)
);
@@ -979,6 +981,7 @@ async function resolveReferenceFields(
* @param fields - Optional collection fields (for reference field resolution)
* @param isPublished - Whether fetching published data
* @param layerDataMap - Map of layer ID → item data for layer-specific resolution
+ * @param rawItemValues - Unformatted values (ISO dates) for applying custom format presets
* @returns Layer with resolved field values
*/
async function injectCollectionData(
@@ -986,7 +989,9 @@ async function injectCollectionData(
itemValues: Record,
fields?: CollectionField[],
isPublished: boolean = true,
- layerDataMap?: Record>
+ layerDataMap?: Record>,
+ rawItemValues?: Record,
+ timezone: string = 'UTC'
): Promise {
// Resolve reference fields if we have field definitions
let enhancedValues = itemValues;
@@ -995,6 +1000,8 @@ async function injectCollectionData(
}
const updates: Partial = {};
+ // Start with all original variables; each section overwrites only its own key
+ const resolvedVars: Record = { ...layer.variables };
// Resolve inline variables in text content
const textVariable = layer.variables?.text;
@@ -1003,8 +1010,6 @@ async function injectCollectionData(
if (textVariable && textVariable.type === 'dynamic_rich_text') {
const content = textVariable.data.content;
if (content && typeof content === 'object') {
- // Check if content contains block elements (lists) from inline variables
- // If so, change restrictive tags (p, h1-h6, etc.) to div
const restrictiveBlockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'a', 'button'];
const currentTag = layer.settings?.tag || layer.name || 'div';
if (restrictiveBlockTags.includes(currentTag) &&
@@ -1015,13 +1020,10 @@ async function injectCollectionData(
};
}
- const resolvedContent = resolveRichTextVariables(content, enhancedValues, layerDataMap);
- updates.variables = {
- ...layer.variables,
- text: {
- type: 'dynamic_rich_text',
- data: { content: resolvedContent }
- }
+ const resolvedContent = resolveRichTextVariables(content, enhancedValues, layerDataMap, rawItemValues, timezone);
+ resolvedVars.text = {
+ type: 'dynamic_rich_text',
+ data: { content: resolvedContent },
};
}
}
@@ -1041,14 +1043,11 @@ async function injectCollectionData(
content_hash: null,
values: enhancedValues,
};
- const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem);
+ const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem, timezone);
- updates.variables = {
- ...layer.variables,
- text: {
- type: 'dynamic_text',
- data: { content: resolved }
- }
+ resolvedVars.text = {
+ type: 'dynamic_text',
+ data: { content: resolved },
};
}
}
@@ -1057,13 +1056,9 @@ async function injectCollectionData(
const imageSrc = layer.variables?.image?.src;
if (imageSrc && isFieldVariable(imageSrc) && imageSrc.data.field_id) {
const resolvedValue = resolveFieldValueWithRelationships(imageSrc, enhancedValues, layerDataMap);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- image: {
- src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc),
- alt: layer.variables?.image?.alt || createDynamicTextVariable(''),
- },
+ resolvedVars.image = {
+ src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc),
+ alt: layer.variables?.image?.alt || createDynamicTextVariable(''),
};
}
@@ -1071,13 +1066,9 @@ async function injectCollectionData(
const videoSrc = layer.variables?.video?.src;
if (videoSrc && isFieldVariable(videoSrc) && videoSrc.data.field_id) {
const resolvedValue = resolveFieldValueWithRelationships(videoSrc, enhancedValues, layerDataMap);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- video: {
- ...layer.variables?.video,
- src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc),
- },
+ resolvedVars.video = {
+ ...layer.variables?.video,
+ src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc),
};
}
@@ -1085,13 +1076,9 @@ async function injectCollectionData(
const audioSrc = layer.variables?.audio?.src;
if (audioSrc && isFieldVariable(audioSrc) && audioSrc.data.field_id) {
const resolvedValue = resolveFieldValueWithRelationships(audioSrc, enhancedValues, layerDataMap);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- audio: {
- ...layer.variables?.audio,
- src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc),
- },
+ resolvedVars.audio = {
+ ...layer.variables?.audio,
+ src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc),
};
}
@@ -1099,12 +1086,8 @@ async function injectCollectionData(
const bgImageSrc = layer.variables?.backgroundImage?.src;
if (bgImageSrc && isFieldVariable(bgImageSrc) && bgImageSrc.data.field_id) {
const resolvedValue = resolveFieldValueWithRelationships(bgImageSrc, enhancedValues, layerDataMap);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- backgroundImage: {
- src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc),
- },
+ resolvedVars.backgroundImage = {
+ src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc),
};
}
@@ -1145,6 +1128,9 @@ async function injectCollectionData(
}
}
+ // Assign all resolved variables
+ updates.variables = resolvedVars as Layer['variables'];
+
// Recursively process children, but SKIP collection layers
// Collection layers will be processed by resolveCollectionLayers with their own item data
if (layer.children) {
@@ -1154,7 +1140,7 @@ async function injectCollectionData(
if (child.variables?.collection?.id) {
return Promise.resolve(child);
}
- return injectCollectionData(child, enhancedValues, fields, isPublished, layerDataMap);
+ return injectCollectionData(child, enhancedValues, fields, isPublished, layerDataMap, rawItemValues, timezone);
})
);
updates.children = resolvedChildren;
@@ -1172,7 +1158,8 @@ async function injectCollectionData(
*/
function resolveInlineVariablesWithRelationships(
text: string,
- collectionItem: CollectionItemWithValues
+ collectionItem: CollectionItemWithValues,
+ timezone: string = 'UTC'
): string {
if (!collectionItem || !collectionItem.values) {
return text;
@@ -1188,14 +1175,14 @@ function resolveInlineVariablesWithRelationships(
const relationships = parsed.data.relationships || [];
// Build the full path for relationship resolution
- if (relationships.length > 0) {
- const fullPath = [fieldId, ...relationships].join('.');
- const fieldValue = collectionItem.values[fullPath];
- return fieldValue || '';
- }
+ const fullPath = relationships.length > 0
+ ? [fieldId, ...relationships].join('.')
+ : fieldId;
- // Simple field lookup
- const fieldValue = collectionItem.values[fieldId];
+ const fieldValue = collectionItem.values[fullPath];
+ if (parsed.data.format && fieldValue) {
+ return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format);
+ }
return fieldValue || '';
}
} catch {
@@ -1258,11 +1245,14 @@ function hasBlockElementsInInlineVariables(
* Traverses the content tree and replaces variable nodes with resolved text
* For rich_text fields, inline the nested Tiptap content
* @param layerDataMap - Optional map of layer ID → item data for layer-specific resolution
+ * @param rawItemValues - Unformatted values (ISO dates) for applying custom format presets
*/
function resolveRichTextVariables(
content: any,
itemValues: Record,
- layerDataMap?: Record>
+ layerDataMap?: Record>,
+ rawItemValues?: Record,
+ timezone: string = 'UTC'
): any {
if (!content || typeof content !== 'object') {
return content;
@@ -1293,7 +1283,7 @@ function resolveRichTextVariables(
// Handle rich_text fields - preserve block structure for proper rendering
if (fieldType === 'rich_text' && isTiptapDoc(value)) {
const resolvedBlocks = value.content.map((block: any) =>
- resolveRichTextVariables(block, itemValues, layerDataMap)
+ resolveRichTextVariables(block, itemValues, layerDataMap, rawItemValues, timezone)
);
return resolvedBlocks.flat();
}
@@ -1307,10 +1297,20 @@ function resolveRichTextVariables(
};
}
- // For other field types, convert to string
- const textValue = value != null ? String(value) : '';
+ // Apply custom format using raw (unformatted) values when available
+ // Date values in itemValues are pre-formatted by formatDateFieldsInItemValues,
+ // so custom format presets need the original ISO string from rawItemValues
+ const format = variable.data.format;
+ let textValue: string;
+ if (format && rawItemValues) {
+ const rawValue = rawItemValues[fullPath];
+ textValue = rawValue != null
+ ? formatFieldValue(rawValue, fieldType, timezone, format)
+ : (value != null ? String(value) : '');
+ } else {
+ textValue = value != null ? String(value) : '';
+ }
- // Replace variable node with text node, preserving marks (bold, italic, etc.)
return {
type: 'text',
text: textValue,
@@ -1324,7 +1324,7 @@ function resolveRichTextVariables(
if (Array.isArray(content)) {
// Flatten arrays that may contain nested arrays from rich_text expansion
return content.flatMap(node => {
- const resolved = resolveRichTextVariables(node, itemValues, layerDataMap);
+ const resolved = resolveRichTextVariables(node, itemValues, layerDataMap, rawItemValues, timezone);
return Array.isArray(resolved) ? resolved : [resolved];
});
}
@@ -1335,11 +1335,11 @@ function resolveRichTextVariables(
if (key === 'content' && Array.isArray(content[key])) {
// Flatten the content array in case of expanded rich_text nodes
result[key] = content[key].flatMap((node: any) => {
- const resolved = resolveRichTextVariables(node, itemValues, layerDataMap);
+ const resolved = resolveRichTextVariables(node, itemValues, layerDataMap, rawItemValues, timezone);
return Array.isArray(resolved) ? resolved : [resolved];
});
} else if (typeof content[key] === 'object' && content[key] !== null) {
- result[key] = resolveRichTextVariables(content[key], itemValues, layerDataMap);
+ result[key] = resolveRichTextVariables(content[key], itemValues, layerDataMap, rawItemValues, timezone);
} else {
result[key] = content[key];
}
@@ -1630,7 +1630,7 @@ export async function resolveCollectionLayers(
// Inject virtual field data into the resolved children
const injectedChildren = await Promise.all(
resolvedChildren.map(child =>
- injectCollectionData(child, virtualValues, undefined, isPublished, updatedLayerDataMap)
+ injectCollectionData(child, virtualValues, undefined, isPublished, updatedLayerDataMap, undefined, timezone)
)
);
@@ -1805,12 +1805,16 @@ export async function resolveCollectionLayers(
sortedItems.map(async (item) => {
// Apply CMS translations to item values before using them
let translatedValues = applyCmsTranslations(item.id, item.values, collectionFields, translations);
+ // Preserve raw values before date formatting for custom format presets
+ const rawTranslatedValues = { ...translatedValues };
// Format date fields in user's timezone
translatedValues = formatDateFieldsInItemValues(translatedValues, collectionFields, timezone);
// Resolve reference fields BEFORE building layerDataMap
// This ensures relationship paths (e.g., "refFieldId.targetFieldId") are available
const enhancedValues = await resolveReferenceFields(translatedValues, collectionFields, isPublished);
+ // Overlay raw values on enhanced to preserve relationship paths while keeping unformatted dates
+ const rawEnhancedValues = { ...enhancedValues, ...rawTranslatedValues };
// Extract slug for URL building
const itemSlug = slugField ? (enhancedValues[slugField.id] || item.values[slugField.id]) : undefined;
@@ -1832,7 +1836,7 @@ export async function resolveCollectionLayers(
// Then inject field data into the resolved children
const injectedChildren = await Promise.all(
resolvedChildren.map(child =>
- injectCollectionData(child, enhancedValues, collectionFields, isPublished, updatedLayerDataMap)
+ injectCollectionData(child, enhancedValues, collectionFields, isPublished, updatedLayerDataMap, rawEnhancedValues, timezone)
)
);
@@ -2538,6 +2542,7 @@ export async function renderCollectionItemsToHtml(
const renderedItems = await Promise.all(
items.map(async (item, index) => {
// Format date fields in user's timezone
+ const rawValues = { ...item.values };
const formattedValues = formatDateFieldsInItemValues(item.values, collectionFields, htmlTimezone);
// Deep clone the template for each item
@@ -2546,7 +2551,7 @@ export async function renderCollectionItemsToHtml(
// Inject collection data into each layer of the template (text, images, etc.)
const injectedLayers = await Promise.all(
clonedTemplate.map((layer: Layer) =>
- injectCollectionDataForHtml(layer, formattedValues, collectionFields, isPublished)
+ injectCollectionDataForHtml(layer, formattedValues, collectionFields, isPublished, rawValues, htmlTimezone)
)
);
@@ -2627,7 +2632,9 @@ async function injectCollectionDataForHtml(
layer: Layer,
itemValues: Record,
fields: CollectionField[],
- isPublished: boolean
+ isPublished: boolean,
+ rawItemValues?: Record,
+ timezone: string = 'UTC'
): Promise {
// Resolve reference fields if we have field definitions
let enhancedValues = itemValues;
@@ -2636,6 +2643,7 @@ async function injectCollectionDataForHtml(
}
const updates: Partial = {};
+ const resolvedVars: Record = { ...layer.variables };
// Resolve inline variables in text content
const textVariable = layer.variables?.text;
@@ -2654,13 +2662,10 @@ async function injectCollectionDataForHtml(
};
}
- const resolvedContent = resolveRichTextVariables(content, enhancedValues);
- updates.variables = {
- ...layer.variables,
- text: {
- type: 'dynamic_rich_text',
- data: { content: resolvedContent }
- }
+ const resolvedContent = resolveRichTextVariables(content, enhancedValues, undefined, rawItemValues, timezone);
+ resolvedVars.text = {
+ type: 'dynamic_rich_text',
+ data: { content: resolvedContent },
};
}
}
@@ -2680,13 +2685,10 @@ async function injectCollectionDataForHtml(
content_hash: null,
values: enhancedValues,
};
- const resolved = resolveInlineVariables(textContent, mockItem);
- updates.variables = {
- ...layer.variables,
- text: {
- type: 'dynamic_text',
- data: { content: resolved }
- }
+ const resolved = resolveInlineVariables(textContent, mockItem, timezone);
+ resolvedVars.text = {
+ type: 'dynamic_text',
+ data: { content: resolved },
};
}
}
@@ -2705,13 +2707,9 @@ async function injectCollectionDataForHtml(
const imageSrc = layer.variables?.image?.src;
if (imageSrc && isFieldVariable(imageSrc) && imageSrc.data.field_id) {
const resolvedValue = resolveFieldPath(imageSrc);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- image: {
- src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc),
- alt: layer.variables?.image?.alt || createDynamicTextVariable(''),
- },
+ resolvedVars.image = {
+ src: createResolvedAssetVariable(imageSrc.data.field_id, resolvedValue, imageSrc),
+ alt: layer.variables?.image?.alt || createDynamicTextVariable(''),
};
}
@@ -2719,13 +2717,9 @@ async function injectCollectionDataForHtml(
const videoSrc = layer.variables?.video?.src;
if (videoSrc && isFieldVariable(videoSrc) && videoSrc.data.field_id) {
const resolvedValue = resolveFieldPath(videoSrc);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- video: {
- ...layer.variables?.video,
- src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc),
- },
+ resolvedVars.video = {
+ ...layer.variables?.video,
+ src: createResolvedAssetVariable(videoSrc.data.field_id, resolvedValue, videoSrc),
};
}
@@ -2733,13 +2727,9 @@ async function injectCollectionDataForHtml(
const audioSrc = layer.variables?.audio?.src;
if (audioSrc && isFieldVariable(audioSrc) && audioSrc.data.field_id) {
const resolvedValue = resolveFieldPath(audioSrc);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- audio: {
- ...layer.variables?.audio,
- src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc),
- },
+ resolvedVars.audio = {
+ ...layer.variables?.audio,
+ src: createResolvedAssetVariable(audioSrc.data.field_id, resolvedValue, audioSrc),
};
}
@@ -2747,12 +2737,8 @@ async function injectCollectionDataForHtml(
const bgImageSrc = layer.variables?.backgroundImage?.src;
if (bgImageSrc && isFieldVariable(bgImageSrc) && bgImageSrc.data.field_id) {
const resolvedValue = resolveFieldPath(bgImageSrc);
- updates.variables = {
- ...updates.variables,
- ...layer.variables,
- backgroundImage: {
- src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc),
- },
+ resolvedVars.backgroundImage = {
+ src: createResolvedAssetVariable(bgImageSrc.data.field_id, resolvedValue, bgImageSrc),
};
}
@@ -2767,11 +2753,14 @@ async function injectCollectionDataForHtml(
}
}
+ // Assign all resolved variables
+ updates.variables = resolvedVars as Layer['variables'];
+
// Recursively process children
if (layer.children) {
const resolvedChildren = await Promise.all(
layer.children.map(child =>
- injectCollectionDataForHtml(child, enhancedValues, fields, isPublished)
+ injectCollectionDataForHtml(child, enhancedValues, fields, isPublished, rawItemValues, timezone)
)
);
updates.children = resolvedChildren;
diff --git a/lib/text-format-utils.ts b/lib/text-format-utils.ts
index 4c5213ab..333643d7 100644
--- a/lib/text-format-utils.ts
+++ b/lib/text-format-utils.ts
@@ -278,17 +278,16 @@ function getVariableNodeData(
collectionItemData?: Record,
pageCollectionItemData?: Record,
layerDataMap?: Record>
-): { fieldType: string | null; rawValue: unknown } {
+): { fieldType: string | null; rawValue: unknown; format?: string } {
if (node.attrs?.variable?.type === 'field' && node.attrs.variable.data?.field_id) {
- const { field_id, field_type, relationships = [], source, collection_layer_id } = node.attrs.variable.data;
+ const { field_id, field_type, relationships = [], source, collection_layer_id, format } = node.attrs.variable.data;
- // Build the full path for relationship resolution
const fieldPath = relationships.length > 0
? [field_id, ...relationships].join('.')
: field_id;
const rawValue = resolveFieldFromSources(fieldPath, source, collectionItemData, pageCollectionItemData, collection_layer_id, layerDataMap);
- return { fieldType: field_type || null, rawValue };
+ return { fieldType: field_type || null, rawValue, format };
}
return { fieldType: null, rawValue: undefined };
@@ -307,8 +306,8 @@ function resolveVariableNode(
pageCollectionItemData?: Record,
timezone: string = 'UTC'
): string {
- const { fieldType, rawValue } = getVariableNodeData(node, collectionItemData, pageCollectionItemData);
- return formatFieldValue(rawValue, fieldType, timezone);
+ const { fieldType, rawValue, format } = getVariableNodeData(node, collectionItemData, pageCollectionItemData);
+ return formatFieldValue(rawValue, fieldType, timezone, format);
}
/**
@@ -511,17 +510,15 @@ function renderInlineContent(
}
if (node.type === 'dynamicVariable') {
- const { fieldType, rawValue } = getVariableNodeData(node, collectionItemData, pageCollectionItemData, layerDataMap);
+ const { fieldType, rawValue, format } = getVariableNodeData(node, collectionItemData, pageCollectionItemData, layerDataMap);
// Handle rich_text fields - render nested Tiptap content
if (fieldType === 'rich_text' && rawValue) {
- // Parse JSON string if needed (published pages store as string)
let richTextValue: unknown = rawValue;
if (typeof rawValue === 'string') {
try {
richTextValue = JSON.parse(rawValue);
} catch {
- // If parsing fails, fall through to text rendering
richTextValue = null;
}
}
@@ -544,8 +541,8 @@ function renderInlineContent(
}
}
- // For other field types, render as text
- const value = formatFieldValue(rawValue, fieldType, timezone);
+ // For other field types, render as text with optional format
+ const value = formatFieldValue(rawValue, fieldType, timezone, format);
const textNode = {
type: 'text',
text: value,
diff --git a/lib/tiptap-utils.ts b/lib/tiptap-utils.ts
index 3d4d8224..52b5b267 100644
--- a/lib/tiptap-utils.ts
+++ b/lib/tiptap-utils.ts
@@ -188,7 +188,7 @@ export function hasLinkOrComponent(node: any): boolean {
}
/** Extract the first CMS field binding from Tiptap JSON content (dynamicVariable node with type 'field'). */
-export function getCmsFieldBinding(node: any): { field_id: string; label?: string; source?: 'page' | 'collection'; collection_layer_id?: string; field_type?: string | null } | null {
+export function getCmsFieldBinding(node: any): { field_id: string; label?: string; source?: 'page' | 'collection'; collection_layer_id?: string; field_type?: string | null; format?: string } | null {
if (!node || typeof node !== 'object') return null;
if (node.type === 'dynamicVariable') {
const variable = node.attrs?.variable;
@@ -199,6 +199,7 @@ export function getCmsFieldBinding(node: any): { field_id: string; label?: strin
source: variable.data.source,
collection_layer_id: variable.data.collection_layer_id,
field_type: variable.data.field_type ?? null,
+ format: variable.data.format,
};
}
}
diff --git a/lib/variable-format-utils.ts b/lib/variable-format-utils.ts
new file mode 100644
index 00000000..c58a16df
--- /dev/null
+++ b/lib/variable-format-utils.ts
@@ -0,0 +1,330 @@
+/**
+ * Variable Format Utilities
+ *
+ * Format presets and formatting functions for date and number inline variables.
+ * Uses Intl.DateTimeFormat and Intl.NumberFormat for locale-aware formatting.
+ */
+
+import type { CollectionFieldType } from '@/types';
+
+// ─── Date Format Presets ────────────────────────────────────────────
+
+export interface DateFormatPreset {
+ id: string;
+ label: string;
+ options: Intl.DateTimeFormatOptions;
+ locale?: string;
+}
+
+export interface FormatPresetSection {
+ title: string;
+ presets: T[];
+}
+
+export const DATE_FORMAT_SECTIONS: FormatPresetSection[] = [
+ {
+ title: 'Date and time',
+ presets: [
+ {
+ id: 'datetime-long',
+ label: 'March 26, 2026, 9:38 AM',
+ options: { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true },
+ },
+ {
+ id: 'datetime-short',
+ label: 'Mar 26, 2026, 9:38 AM',
+ options: { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true },
+ },
+ {
+ id: 'datetime-24h',
+ label: 'Mar 26, 2026, 09:38',
+ options: { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false },
+ },
+ ],
+ },
+ {
+ title: 'Date',
+ presets: [
+ {
+ id: 'date-long',
+ label: 'March 26, 2026',
+ options: { month: 'long', day: 'numeric', year: 'numeric' },
+ },
+ {
+ id: 'date-short',
+ label: 'Mar 26, 2026',
+ options: { month: 'short', day: 'numeric', year: 'numeric' },
+ },
+ {
+ id: 'date-full',
+ label: 'Thursday, March 26, 2026',
+ options: { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' },
+ },
+ {
+ id: 'date-short-weekday',
+ label: 'Thu, Mar 26, 2026',
+ options: { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' },
+ },
+ {
+ id: 'date-us',
+ label: '3/26/2026',
+ options: { month: 'numeric', day: 'numeric', year: 'numeric' },
+ locale: 'en-US',
+ },
+ {
+ id: 'date-eu',
+ label: '26/03/2026',
+ options: { day: '2-digit', month: '2-digit', year: 'numeric' },
+ locale: 'en-GB',
+ },
+ {
+ id: 'date-eu-dot',
+ label: '26.03.2026',
+ options: { day: '2-digit', month: '2-digit', year: 'numeric' },
+ locale: 'de-DE',
+ },
+ {
+ id: 'date-iso',
+ label: '2026-03-26',
+ options: { year: 'numeric', month: '2-digit', day: '2-digit' },
+ locale: 'sv-SE',
+ },
+ {
+ id: 'date-month-year',
+ label: 'March 2026',
+ options: { month: 'long', year: 'numeric' },
+ },
+ {
+ id: 'date-short-month-year',
+ label: 'Mar 2026',
+ options: { month: 'short', year: 'numeric' },
+ },
+ {
+ id: 'date-day-month',
+ label: '26 Mar',
+ options: { day: 'numeric', month: 'short' },
+ },
+ ],
+ },
+ {
+ title: 'Time',
+ presets: [
+ {
+ id: 'time-12h',
+ label: '9:38 AM',
+ options: { hour: 'numeric', minute: '2-digit', hour12: true },
+ },
+ {
+ id: 'time-24h',
+ label: '09:38',
+ options: { hour: '2-digit', minute: '2-digit', hour12: false },
+ },
+ ],
+ },
+];
+
+/** Flat list of all date presets (used for lookup by ID) */
+export const DATE_FORMAT_PRESETS: DateFormatPreset[] =
+ DATE_FORMAT_SECTIONS.flatMap(s => s.presets);
+
+// ─── Number Format Presets ──────────────────────────────────────────
+
+export interface NumberFormatPreset {
+ id: string;
+ label: string;
+ options: Intl.NumberFormatOptions;
+ locale?: string;
+ sample: number;
+}
+
+export interface NumberFormatSection {
+ title: string;
+ presets: NumberFormatPreset[];
+}
+
+export const NUMBER_FORMAT_SECTIONS: NumberFormatSection[] = [
+ {
+ title: 'Standard',
+ presets: [
+ {
+ id: 'number-integer',
+ label: '12,345',
+ options: { maximumFractionDigits: 0, useGrouping: true },
+ sample: 12345,
+ },
+ {
+ id: 'number-decimal',
+ label: '12,345.00',
+ options: { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true },
+ sample: 12345,
+ },
+ {
+ id: 'number-single-decimal',
+ label: '12,345.0',
+ options: { minimumFractionDigits: 1, maximumFractionDigits: 1, useGrouping: true },
+ sample: 12345,
+ },
+ {
+ id: 'number-plain',
+ label: '12345',
+ options: { maximumFractionDigits: 0, useGrouping: false },
+ sample: 12345,
+ },
+ {
+ id: 'number-plain-decimal',
+ label: '12345.00',
+ options: { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: false },
+ sample: 12345,
+ },
+ {
+ id: 'number-compact',
+ label: '12K',
+ options: { notation: 'compact', maximumFractionDigits: 1 },
+ sample: 12345,
+ },
+ ],
+ },
+ {
+ title: 'Percent',
+ presets: [
+ {
+ id: 'number-percent',
+ label: '12%',
+ options: { style: 'percent', maximumFractionDigits: 0 },
+ sample: 0.12,
+ },
+ {
+ id: 'number-percent-decimal',
+ label: '12.35%',
+ options: { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 },
+ sample: 0.1235,
+ },
+ ],
+ },
+];
+
+/** Flat list of all number presets (used for lookup by ID) */
+export const NUMBER_FORMAT_PRESETS: NumberFormatPreset[] =
+ NUMBER_FORMAT_SECTIONS.flatMap(s => s.presets);
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+const datePresetMap = new Map(DATE_FORMAT_PRESETS.map(p => [p.id, p]));
+const numberPresetMap = new Map(NUMBER_FORMAT_PRESETS.map(p => [p.id, p]));
+
+/** Check whether a field type supports format selection */
+export function isFormattableFieldType(fieldType: string | null | undefined): boolean {
+ return fieldType === 'date' || fieldType === 'number';
+}
+
+/** Get format preset sections for a field type (grouped with titles) */
+export function getFormatSectionsForFieldType(
+ fieldType: string | null | undefined
+): FormatPresetSection[] {
+ if (fieldType === 'date') return DATE_FORMAT_SECTIONS;
+ if (fieldType === 'number') return NUMBER_FORMAT_SECTIONS;
+ return [];
+}
+
+/** Get the default format ID for a field type */
+export function getDefaultFormatId(fieldType: string | null | undefined): string | undefined {
+ if (fieldType === 'date') return 'date-long';
+ if (fieldType === 'number') return 'number-integer';
+ return undefined;
+}
+
+/** Build a field variable data object with the appropriate default format preset */
+export function buildFieldVariableData(
+ fieldId: string,
+ relationshipPath: string[],
+ fieldType: CollectionFieldType | null,
+ source?: string,
+ layerId?: string,
+) {
+ const defaultFormat = getDefaultFormatId(fieldType);
+ return {
+ type: 'field' as const,
+ data: {
+ field_id: fieldId,
+ field_type: fieldType,
+ relationships: relationshipPath,
+ ...(defaultFormat && { format: defaultFormat }),
+ ...(source && { source: source as 'page' | 'collection' }),
+ ...(layerId && { collection_layer_id: layerId }),
+ },
+ };
+}
+
+/**
+ * Format a date value using a preset ID
+ * Falls back to the default display format if preset is not found
+ */
+export function formatDateWithPreset(
+ value: string | Date,
+ presetId: string | undefined,
+ timezone: string = 'UTC'
+): string {
+ const dateObj = typeof value === 'string' ? new Date(value) : value;
+ if (isNaN(dateObj.getTime())) return '';
+
+ const preset = presetId ? datePresetMap.get(presetId) : datePresetMap.get('date-long');
+ if (!preset) {
+ return new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(dateObj);
+ }
+
+ try {
+ return new Intl.DateTimeFormat(preset.locale || 'en-US', {
+ timeZone: timezone,
+ ...preset.options,
+ }).format(dateObj);
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * Format a number value using a preset ID
+ * Falls back to plain string conversion if preset is not found
+ */
+export function formatNumberWithPreset(
+ value: number,
+ presetId: string | undefined
+): string {
+ const preset = presetId ? numberPresetMap.get(presetId) : undefined;
+ if (!preset) return String(value);
+
+ try {
+ return new Intl.NumberFormat(preset.locale || 'en-US', preset.options).format(value);
+ } catch {
+ return String(value);
+ }
+}
+
+/**
+ * Generate a live preview label for a date format preset using the current date
+ */
+export function getDateFormatPreview(preset: DateFormatPreset): string {
+ try {
+ return new Intl.DateTimeFormat(preset.locale || 'en-US', {
+ ...preset.options,
+ }).format(new Date());
+ } catch {
+ return preset.label;
+ }
+}
+
+/**
+ * Generate a live preview label for a number format preset
+ */
+export function getNumberFormatPreview(preset: NumberFormatPreset): string {
+ try {
+ return new Intl.NumberFormat(preset.locale || 'en-US', preset.options).format(preset.sample);
+ } catch {
+ return preset.label;
+ }
+}