UNPKG

@invisiblecities/sanity-edge-fetcher

Version:

Lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions

406 lines (354 loc) 12.3 kB
/** * @file stega.ts * @description Stega encoding support for visual editing with optional @vercel/stega dependency * @author Invisible Cities Agency * @license MIT */ // Edge-safe ESM import for @vercel/stega (tiny, browser/edge-friendly) // This package has no Node dependencies and is safe in Edge bundles. import { vercelStegaEncode as _vercelStegaEncode, vercelStegaCombine as _vercelStegaCombine, vercelStegaClean as _vercelStegaClean } from '@vercel/stega'; // NOTE: Library API: vercelStegaEncode(json) -> invisible suffix, vercelStegaCombine(text, json, skip?) -> combined string const vercelStegaEncode: ((metadata: any) => string) | undefined = _vercelStegaEncode as any; const vercelStegaCombine: ((value: string, metadata: any, skip?: 'auto' | boolean) => string) | undefined = _vercelStegaCombine as any; const vercelStegaClean: (<T = any>(value: T) => T) | undefined = _vercelStegaClean as any; /** * Stega configuration options */ export interface StegaConfig { enabled: boolean; studioUrl?: string; basePath?: string; filter?: (path: string) => boolean; projectId?: string; dataset?: string; } /** * Inline stega implementation for when @vercel/stega is not available * Uses Unicode code points to encode invisible metadata */ const STEGA_CODES = { // Tuple ensures index access returns number (not possibly undefined) base4: [8203, 8204, 8205, 65279] as const, hex: { 0: 8203, 1: 8204, 2: 8205, 3: 8290, 4: 8291, 5: 8288, 6: 65279, 7: 8289, 8: 119155, 9: 119156, a: 119157, b: 119158, c: 119159, d: 119160, e: 119161, f: 119162 } as Record<string | number, number> }; const STEGA_PREFIX = new Array(4).fill(String.fromCodePoint(STEGA_CODES.base4[0])).join(''); /** * Encode data as invisible Unicode characters (base4 encoding) */ function encodeInvisibleBase4(data: any): string { const jsonStr = JSON.stringify(data); const encoded = Array.from(jsonStr).map(char => { const charCode = char.charCodeAt(0); if (charCode > 255) { throw new Error(`Only ASCII can be encoded. Error on character ${char} (${charCode})`); } // Convert to base-4 and encode as invisible characters return Array.from(charCode.toString(4).padStart(4, '0')) .map(digit => { // digit is one of '0' | '1' | '2' | '3' const idx = parseInt(digit, 10) as 0 | 1 | 2 | 3; return String.fromCodePoint(STEGA_CODES.base4[idx]); }) .join(''); }).join(''); return `${STEGA_PREFIX}${encoded}`; } /** * Check if a value should skip stega encoding */ function shouldSkipEncoding(value: string): boolean { // Skip dates and URLs as they shouldn't be encoded const isDate = /\d+(?:[-:\/]\d+){2}(?:T\d+(?:[-:\/]\d+){1,2}(\.\d+)?Z?)?/.test(value); const isUrl = (() => { try { new URL(value, value.startsWith('/') ? 'https://example.com' : undefined); return true; } catch { return false; } })(); // Skip Sanity asset/file id strings like image-<hash>-<WxH>-<ext> const isSanityAssetId = /^(image|file)-[A-Za-z0-9]+-\d+x\d+-[A-Za-z0-9]+$/.test(value); return isDate || isUrl || isSanityAssetId; } /** * Determine if the current path points to structured fields that must not be stega-encoded */ function isStructuredPath(path: Array<string | number>): boolean { if (!path.length) return false; const last = String(path[path.length - 1]); // Any Sanity meta or reference/slug/asset/urlish fields const disallowed = new Set([ '_id', '_ref', '_key', '_type', 'slug', 'current', 'asset', 'path', 'href', 'url', 'src' ]); if (disallowed.has(last)) return true; // If parent key is 'asset', skip child values as well if (path.length >= 2 && String(path[path.length - 2]) === 'asset') return true; return false; } /** * Encode stega metadata into a string value */ function encodeStegaString(value: string, metadata: any, config: StegaConfig): string { if (!config.enabled || !metadata) { return value; } // Skip encoding for dates and URLs if (shouldSkipEncoding(value)) { return value; } // Skip encoding when there is no visible content to preserve if (typeof value !== 'string' || value.trim().length === 0) { return value; } // Skip if already stega-encoded to avoid double payloads try { if (vercelStegaClean && vercelStegaClean(value) !== value) { return value; } } catch { // ignore and continue } // Use @vercel/stega if available, otherwise use inline implementation if (vercelStegaEncode) { // Prefer combine when available; fallback to appending encoded invisible suffix if (vercelStegaCombine) return vercelStegaCombine(value, metadata, 'auto'); return `${value}${vercelStegaEncode(metadata)}`; } // Inline implementation return `${value}${encodeInvisibleBase4(metadata)}`; } /** * Recursively encode stega metadata into result data */ function encodeStegaInResult( data: any, sourceMap: any, config: StegaConfig, path: Array<string | number> = [] ): any { if (!config.enabled || !sourceMap) { return data; } // Handle null/undefined if (data == null) { return data; } // Handle strings if (typeof data === 'string') { // Never encode structured/system fields if (isStructuredPath(path)) return data; const metadata = resolveSourceMapForPath(sourceMap, path, config); // Only encode mapped leaf values if (metadata && (metadata.type === undefined || metadata.type === 'value')) { return encodeStegaString(data, metadata, config); } return data; } // Handle arrays if (Array.isArray(data)) { return data.map((item, index) => encodeStegaInResult(item, sourceMap, config, [...path, index]) ); } // Handle objects if (typeof data === 'object') { const result: any = {}; for (const key in data) { if (data.hasOwnProperty(key)) { result[key] = encodeStegaInResult( data[key], sourceMap, config, [...path, key] ); } } return result; } // Return primitives as-is return data; } /** * Resolve source map metadata for a given path */ function resolveSourceMapForPath(sourceMap: any, path: Array<string | number>, config?: StegaConfig): any { if (!sourceMap?.mappings) { return null; } // Convert path to JSONPath format for lookup const jsonPath = `$${path.map(segment => typeof segment === 'number' ? `[${segment}]` : `['${segment}']` ).join('')}`; // Look for exact match or closest parent if (sourceMap.mappings[jsonPath]) { const mapping = sourceMap.mappings[jsonPath]; const studioUrl = sourceMap.studioUrl || config?.studioUrl; // Attempt to build a Studio Edit Intent URL: /intent/edit/id=<docId>;path=<fieldPath> let href: string | undefined; try { const src = mapping?.source; const docId = typeof src?.document === 'number' ? sourceMap.documents?.[src.document]?._id : undefined; const fieldPath = typeof src?.path === 'number' ? sourceMap.paths?.[src.path] : undefined; if (studioUrl && docId) { // Normalize studio base to exclude trailing /presentation if present const studioBase = String(studioUrl).replace(/\/?presentation\/?$/, '').replace(/\/$/, ''); const pathParam = fieldPath ? `;path=${encodeURIComponent(fieldPath)}` : ''; href = `${studioBase}/intent/edit/id=${encodeURIComponent(docId)}${pathParam}`; } } catch { // ignore href build errors } return { // Canonical hints for overlay decoders _origin: 'sanity', projectId: config?.projectId, dataset: config?.dataset, studioUrl, path: jsonPath, source: sourceMap.source, href, ...mapping, }; } // Try to find parent paths let currentPath = jsonPath; while (currentPath.includes('[') || currentPath.includes('.')) { const lastIndex = Math.max( currentPath.lastIndexOf('['), currentPath.lastIndexOf('.') ); if (lastIndex === -1) break; currentPath = currentPath.substring(0, lastIndex); if (sourceMap.mappings[currentPath]) { return { _origin: 'sanity', projectId: config?.projectId, dataset: config?.dataset, studioUrl: sourceMap.studioUrl, source: sourceMap.source, path: currentPath, ...sourceMap.mappings[currentPath] }; } } return null; } /** * Get the studio URL from environment or config */ function getStudioUrl(config?: Partial<StegaConfig>): string | undefined { if (config?.studioUrl) { return config.studioUrl; } // Try environment variables const envStudioUrl = process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || process.env.SANITY_STUDIO_URL; if (envStudioUrl) { return envStudioUrl; } // Default based on environment if (process.env.NODE_ENV === 'development') { // Prefer HTTPS proxy for cookie/iframe parity with Presentation return 'https://localhost:3334/presentation'; } return undefined; } /** * Check if stega should be enabled */ export function shouldEnableStega(isDraftMode: boolean, config?: Partial<StegaConfig>): boolean { // Explicit config takes precedence if (config?.enabled !== undefined) { return config.enabled; } // Default: enable in draft mode or development return isDraftMode || process.env.NODE_ENV === 'development'; } /** * Process Sanity response to handle stega encoding */ export function processStegaResponse( response: any, isDraftMode: boolean, config?: Partial<StegaConfig> | StegaConfig ): any { // If stega is not enabled, return just the result if (!shouldEnableStega(isDraftMode, config)) { return response.result; } // When stega is enabled, we need to encode the source map into the result const result = response.result; const sourceMap = response.resultSourceMap; if (sourceMap && config?.enabled) { // Add studio URL to source map for visual editing const studioUrl = getStudioUrl(config); if (studioUrl && sourceMap) { sourceMap.studioUrl = studioUrl; } // Encode stega metadata into the result const fullConfig = buildStegaConfig(isDraftMode, config); return encodeStegaInResult(result, sourceMap, fullConfig); } return result; } /** * Build stega configuration from environment and options */ export function buildStegaConfig( isDraftMode: boolean, options?: Partial<StegaConfig> ): StegaConfig { const config: Partial<StegaConfig> = options || {}; const enabled = shouldEnableStega(isDraftMode, config); const studioUrl = getStudioUrl(config); const projectId = config.projectId || process.env.NEXT_PUBLIC_SANITY_PROJECT_ID; const dataset = config.dataset || process.env.NEXT_PUBLIC_SANITY_DATASET; return { enabled, ...(studioUrl !== undefined ? { studioUrl } : {}), ...(config.basePath !== undefined ? { basePath: config.basePath } : {}), ...(config.filter !== undefined ? { filter: config.filter } : {}), ...(projectId !== undefined ? { projectId } : {}), ...(dataset !== undefined ? { dataset } : {}), } as StegaConfig; } /** * Clean stega-encoded strings (remove invisible characters) * Useful for comparing values or using in business logic */ export function stegaClean<T = any>(value: T): T { if (vercelStegaClean) { return vercelStegaClean(value); } // Inline implementation - remove all stega Unicode characters const stegaChars = Object.values(STEGA_CODES.hex) .map(code => `\\u{${code.toString(16)}}`) .join(''); const stegaRegex = new RegExp(`[${stegaChars}]{4,}`, 'gu'); const cleanString = (str: string) => str.replace(stegaRegex, ''); // Recursively clean all strings in the value if (typeof value === 'string') { return cleanString(value) as T; } if (Array.isArray(value)) { return value.map(item => stegaClean(item)) as T; } if (value && typeof value === 'object') { const cleaned: any = {}; for (const key in value) { if ((value as any).hasOwnProperty(key)) { cleaned[key] = stegaClean((value as any)[key]); } } return cleaned; } return value; }