@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
text/typescript
/**
* @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;
}