39948-vm/frontend/src/lib/extractPageLinks.ts
2026-04-07 16:42:56 +04:00

310 lines
9.6 KiB
TypeScript

/**
* 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<string, unknown>;
}
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<string, unknown>,
): Record<string, unknown> {
const {
all: allFields,
nested: nestedFields,
nestedUrlFields,
} = PRELOAD_CONFIG.assetFields;
const contentObj: Record<string, unknown> = {};
// 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<string, unknown>) => {
const nestedContent: Record<string, unknown> = {};
(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<string, unknown> | undefined,
): Record<string, unknown> | 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<string, string>();
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<string, unknown>[])
: [];
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<string, string>();
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<string, unknown>[])
: [];
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<string, unknown>[])
: [];
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;
}