/** * extractPageLinks Utility * * Extracts synthetic page links and preload elements from tour pages' ui_schema_json. * Used by both constructor and RuntimePresentation for consistent preload behavior. */ import { PRELOAD_CONFIG } from '../config/preload.config'; import type { PreloadPageLink, PreloadElement } from '../types/preload'; interface PageWithSchema { id: string; slug?: string; ui_schema_json?: string | Record; } interface ExtractResult { pageLinks: PreloadPageLink[]; preloadElements: PreloadElement[]; } /** * Extract asset URL fields from an element using PRELOAD_CONFIG. * Returns an object with all asset fields found in the element. */ function extractAssetFields( element: Record, ): Record { const { all: allFields, nested: nestedFields, nestedUrlFields, } = PRELOAD_CONFIG.assetFields; const contentObj: Record = {}; // Extract top-level asset fields (allFields as readonly string[]).forEach((field) => { const value = element[field]; if (value !== undefined && value !== '') { contentObj[field] = value; } }); // Extract nested fields (e.g., galleryCards, carouselSlides) (nestedFields as readonly string[]).forEach((field) => { const nested = element[field]; if (Array.isArray(nested)) { // Extract URLs from nested items const filteredNested = nested .map((item: Record) => { const nestedContent: Record = {}; (nestedUrlFields as readonly string[]).forEach((urlField) => { if (item[urlField] !== undefined && item[urlField] !== '') { nestedContent[urlField] = item[urlField]; } }); return Object.keys(nestedContent).length > 0 ? nestedContent : null; }) .filter(Boolean); if (filteredNested.length > 0) { contentObj[field] = filteredNested; } } }); return contentObj; } /** * Parse ui_schema_json safely. */ function parseUiSchema( uiSchemaJson: string | Record | undefined, ): Record | null { if (!uiSchemaJson) return null; try { return typeof uiSchemaJson === 'string' ? JSON.parse(uiSchemaJson) : uiSchemaJson; } catch { return null; } } /** * Extract page links and preload elements from pages' ui_schema_json. * * This builds: * 1. Synthetic page links for the neighbor graph (enables preloading of connected pages) * 2. Preload elements with asset URLs for the preload queue * * @param pages - Array of pages with ui_schema_json * @param allPages - Optional: all pages for slug-to-id resolution. If not provided, uses `pages`. * @returns Object with pageLinks and preloadElements arrays * * @example * const { pageLinks, preloadElements } = extractPageLinksAndElements(pages); * const preloadOrchestrator = usePreloadOrchestrator({ * pages, * pageLinks, * elements: preloadElements, * currentPageId, * }); */ export function extractPageLinksAndElements( pages: PageWithSchema[], allPages?: PageWithSchema[], ): ExtractResult { const pagesForLookup = allPages || pages; const pageLinks: PreloadPageLink[] = []; const preloadElements: PreloadElement[] = []; // Build slug-to-id map for resolving targetPageSlug const slugToIdMap = new Map(); pagesForLookup.forEach((page) => { if (page.slug) { slugToIdMap.set(page.slug, page.id); } }); pages.forEach((page) => { const uiSchema = parseUiSchema(page.ui_schema_json); if (!uiSchema) return; const pageElements = Array.isArray(uiSchema.elements) ? (uiSchema.elements as Record[]) : []; pageElements.forEach((el) => { // Build preload element with asset URLs const contentObj = extractAssetFields(el); if (Object.keys(contentObj).length > 0) { preloadElements.push({ id: String(el.id || '') || `element-${page.id}-${Math.random().toString(36).slice(2)}`, pageId: page.id, element_type: String(el.type || ''), content_json: JSON.stringify(contentObj), }); } // Build synthetic page link for navigation elements const targetSlug = el.targetPageSlug && typeof el.targetPageSlug === 'string' ? el.targetPageSlug : ''; const legacyTargetId = el.targetPageId && typeof el.targetPageId === 'string' ? el.targetPageId : ''; // Resolve slug to page ID (prefer slug, fall back to legacy ID) let resolvedTargetPageId = ''; if (targetSlug) { resolvedTargetPageId = slugToIdMap.get(targetSlug) || ''; } else if (legacyTargetId) { // Legacy: targetPageId might be a slug or an ID resolvedTargetPageId = slugToIdMap.get(legacyTargetId) || legacyTargetId; } if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { pageLinks.push({ id: `synthetic-${page.id}-${el.id || preloadElements.length}`, from_pageId: page.id, to_pageId: resolvedTargetPageId, is_active: true, transition: el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' ? { id: `transition-${el.id || preloadElements.length}`, video_url: el.transitionVideoUrl, } : undefined, }); } }); }); return { pageLinks, preloadElements }; } /** * Extract only page links from pages (lightweight - no asset extraction). * Used for building navigation graph without loading all asset URLs. * * This is more efficient than extractPageLinksAndElements when you only need * the navigation structure (e.g., for determining neighbors). * * @param pages - Array of pages with ui_schema_json * @param allPages - Optional: all pages for slug-to-id resolution. If not provided, uses `pages`. * @returns Array of page links for navigation graph */ export function extractPageLinksOnly( pages: PageWithSchema[], allPages?: PageWithSchema[], ): PreloadPageLink[] { const pagesForLookup = allPages || pages; const pageLinks: PreloadPageLink[] = []; // Build slug-to-id map for resolving targetPageSlug const slugToIdMap = new Map(); pagesForLookup.forEach((page) => { if (page.slug) { slugToIdMap.set(page.slug, page.id); } }); pages.forEach((page) => { const uiSchema = parseUiSchema(page.ui_schema_json); if (!uiSchema) return; const pageElements = Array.isArray(uiSchema.elements) ? (uiSchema.elements as Record[]) : []; pageElements.forEach((el) => { // Build synthetic page link for navigation elements const targetSlug = el.targetPageSlug && typeof el.targetPageSlug === 'string' ? el.targetPageSlug : ''; const legacyTargetId = el.targetPageId && typeof el.targetPageId === 'string' ? el.targetPageId : ''; // Resolve slug to page ID (prefer slug, fall back to legacy ID) let resolvedTargetPageId = ''; if (targetSlug) { resolvedTargetPageId = slugToIdMap.get(targetSlug) || ''; } else if (legacyTargetId) { // Legacy: targetPageId might be a slug or an ID resolvedTargetPageId = slugToIdMap.get(legacyTargetId) || legacyTargetId; } if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { pageLinks.push({ id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`, from_pageId: page.id, to_pageId: resolvedTargetPageId, is_active: true, transition: el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' ? { id: `transition-${el.id || Math.random().toString(36).slice(2)}`, video_url: el.transitionVideoUrl, } : undefined, }); } }); }); return pageLinks; } /** * Extract preload elements only for specified pages (on-demand). * Used for progressive element loading - only parses ui_schema_json * for pages that are actually needed (current + neighbors). * * @param pages - Array of pages with ui_schema_json * @param pageIds - Array of page IDs to extract elements for * @returns Array of preload elements for the specified pages * * @example * // Extract elements only for current page and its neighbors * const neighborIds = [currentPageId, ...getNeighborIds(currentPageId)]; * const elements = extractElementsForPages(pages, neighborIds); */ export function extractElementsForPages( pages: PageWithSchema[], pageIds: string[], ): PreloadElement[] { const preloadElements: PreloadElement[] = []; const pageIdSet = new Set(pageIds); pages.forEach((page) => { // Skip pages not in requested set if (!pageIdSet.has(page.id)) return; const uiSchema = parseUiSchema(page.ui_schema_json); if (!uiSchema) return; const pageElements = Array.isArray(uiSchema.elements) ? (uiSchema.elements as Record[]) : []; pageElements.forEach((el) => { // Build preload element with asset URLs const contentObj = extractAssetFields(el); if (Object.keys(contentObj).length > 0) { preloadElements.push({ id: String(el.id || '') || `element-${page.id}-${Math.random().toString(36).slice(2)}`, pageId: page.id, element_type: String(el.type || ''), content_json: JSON.stringify(contentObj), }); } }); }); return preloadElements; }