@limitless-angular/sanity
Version:
A powerful Angular library for Sanity.io integration, featuring Portable Text rendering and optimized image loading.
469 lines (461 loc) • 21.7 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, inject, PLATFORM_ID, Injectable, DestroyRef, input, afterNextRender, effect, Component, ChangeDetectionStrategy, untracked, signal, computed } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isPlatformBrowser } from '@angular/common';
import { BehaviorSubject, Subject, fromEvent, Observable, combineLatest, timer, of, takeWhile, from, EMPTY, mergeMap, startWith } from 'rxjs';
import { shareReplay, map, filter, switchMap, distinctUntilChanged, tap, catchError, finalize } from 'rxjs/operators';
import { LRUCache } from 'lru-cache';
import { applyPatch } from 'mendoza';
import { vercelStegaSplit } from '@vercel/stega';
import { applySourceDocuments } from '@sanity/client/csm';
import { isEqual } from 'lodash-es';
import { UseDocumentsInUseService } from '@limitless-angular/sanity/preview-kit-compat';
import { SANITY_CLIENT_FACTORY } from '@limitless-angular/sanity/shared';
const LIVE_PREVIEW_REFRESH_INTERVAL = new InjectionToken('LIVE_PREVIEW_REFRESH_INTERVAL');
class RevalidateService {
constructor() {
this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
this.revalidateState$ = new BehaviorSubject('hit');
this.online$ = new BehaviorSubject(false);
this.refreshInterval$ = new Subject();
if (this.isBrowser) {
this.setupOnlineListener();
this.visibilityState$ = this.createVisibilityState$();
this.shouldPause$ = this.createShouldPause$();
this.setupStateManagement();
}
}
setupOnlineListener() {
this.online$.next(navigator.onLine);
fromEvent(window, 'online')
.pipe(takeUntilDestroyed())
.subscribe(() => this.online$.next(true));
fromEvent(window, 'offline')
.pipe(takeUntilDestroyed())
.subscribe(() => this.online$.next(false));
}
createVisibilityState$() {
return new Observable((observer) => {
const onVisibilityChange = () => observer.next(document.visibilityState);
document.addEventListener('visibilitychange', onVisibilityChange);
onVisibilityChange(); // Initial value
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}).pipe(shareReplay(1));
}
createShouldPause$() {
return combineLatest([this.online$, this.visibilityState$]).pipe(map(([online, visibilityState]) => !online || visibilityState === 'hidden'), shareReplay(1));
}
setupStateManagement() {
// Handle window focus
fromEvent(window, 'focus')
.pipe(filter(() => this.revalidateState$.value === 'hit'), takeUntilDestroyed())
.subscribe(() => this.revalidateState$.next('stale'));
// Handle refresh interval
this.refreshInterval$
.pipe(
// If interval is nullish then we don't want to refresh.
// Inflight means it's already refreshing and we pause the countdown.
// It's only necessary to start the countdown if the cache isn't already stale
filter((interval) => !!interval && this.revalidateState$.value === 'hit'), switchMap((interval) => timer(0, interval)), takeUntilDestroyed())
.subscribe(() => this.revalidateState$.next('stale'));
// Revalidate on changes to shouldPause
combineLatest([this.shouldPause$, this.revalidateState$])
.pipe(distinctUntilChanged(isEqual), takeUntilDestroyed())
.subscribe(([shouldPause, state]) => {
// Mark as stale pre-emptively if we're offline or the document isn't visible
if (shouldPause && state === 'hit') {
this.revalidateState$.next('stale');
}
// If not paused we can mark stale as ready for refresh
if (!shouldPause && state === 'stale') {
this.revalidateState$.next('refresh');
}
});
}
setupRevalidate(refreshInterval) {
this.refreshInterval$.next(refreshInterval);
}
startRefresh() {
this.revalidateState$.next('inflight');
return () => this.revalidateState$.next('hit');
}
getRevalidateState() {
return this.revalidateState$.asObservable().pipe(distinctUntilChanged());
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: RevalidateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: RevalidateService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: RevalidateService, decorators: [{
type: Injectable
}], ctorParameters: () => [] });
const DEFAULT_TAG = 'sanity.preview-kit';
function getTurboCacheKey(projectId, dataset, _id) {
return `${projectId}-${dataset}-${_id}`;
}
/** @internal */
function getQueryCacheKey(query, params) {
return `${query}-${JSON.stringify(params)}`;
}
/**
* Return params that are stable with deep equal as long as the key order is the same
*
* Based on https://github.com/sanity-io/visual-editing/blob/main/packages/visual-editing-helpers/src/hooks/useQueryParams.ts
* @internal
*/
function getStableQueryParams(params) {
return JSON.parse(JSON.stringify(params ?? {}));
}
class LivePreviewService {
constructor() {
this.clientFactory = inject(SANITY_CLIENT_FACTORY);
this.destroyRef = inject(DestroyRef);
this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
this.refreshInterval = inject(LIVE_PREVIEW_REFRESH_INTERVAL);
this.revalidateService = inject(RevalidateService);
this.useDocumentsInUse = inject(UseDocumentsInUseService);
this.snapshots = new Map();
this.docsInUse = new Map();
this.lastMutatedDocumentId$ = new BehaviorSubject(null);
this.turboIds$ = new BehaviorSubject([]);
this.#isInitialized = false;
this.warnedAboutCrossDatasetReference = false;
}
#isInitialized;
get isInitialized() {
return this.#isInitialized;
}
initialize(token) {
if (this.#isInitialized) {
console.warn('LiveStoreService is already initialized');
return;
}
const client = this.clientFactory({ token });
const { requestTagPrefix, resultSourceMap } = client.config();
this.client = client.withConfig({
requestTagPrefix: requestTagPrefix ?? DEFAULT_TAG,
resultSourceMap: resultSourceMap === 'withKeyArraySelector'
? 'withKeyArraySelector'
: true,
// Set the recommended defaults, this is a convenience to make it easier to share a client config from a server component to the client component
...(token && {
token,
useCdn: false,
perspective: 'previewDrafts',
ignoreBrowserTokenWarning: true,
}),
});
this.config = this.client.config();
this.documentsCache = new LRUCache({ max: 500 });
if (this.isBrowser) {
this.useDocumentsInUse.initialize(this.config);
this.setupTurboUpdates();
this.loadMissingDocuments();
this.revalidateService.setupRevalidate(this.refreshInterval);
this.setupSourceMapUpdates();
this.updateActiveDocumentIds();
this.syncWithPresentationToolIfPresent();
}
this.#isInitialized = true;
}
checkInitialization() {
if (!this.#isInitialized) {
throw new Error('LiveStoreService is not initialized. Call initialize(token) first.');
}
}
turboIdsFromSourceMap(resultSourceMap) {
const nextTurboIds = new Set();
this.docsInUse.clear();
if (resultSourceMap?.documents?.length) {
for (const document of resultSourceMap.documents) {
nextTurboIds.add(document._id);
this.docsInUse.set(document._id, document);
}
}
const prevTurboIds = this.turboIds$.getValue();
const mergedTurboIds = Array.from(new Set([...prevTurboIds, ...nextTurboIds]));
if (JSON.stringify(mergedTurboIds.sort()) !==
JSON.stringify(prevTurboIds.sort())) {
this.turboIds$.next(mergedTurboIds);
}
}
turboChargeResultIfSourceMap(result, resultSourceMap) {
if (!resultSourceMap) {
return result;
}
return applySourceDocuments(result, resultSourceMap, (sourceDocument) => {
if (sourceDocument._projectId) {
// @TODO Handle cross dataset references
if (!this.warnedAboutCrossDatasetReference) {
console.warn('Cross dataset references are not supported yet, ignoring source document', sourceDocument);
this.warnedAboutCrossDatasetReference = true;
}
return undefined;
}
return this.documentsCache.get(getTurboCacheKey(this.config.projectId, this.config.dataset, sourceDocument._id));
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(changedValue, { previousValue }) => {
if (typeof changedValue === 'string' &&
typeof previousValue === 'string') {
const { encoded } = vercelStegaSplit(previousValue);
const { cleaned } = vercelStegaSplit(changedValue);
return `${encoded}${cleaned}`;
}
return changedValue;
}, 'previewDrafts');
}
listenLiveQuery(initialData, query, queryParams) {
if (!this.isBrowser) {
return of(initialData);
}
this.checkInitialization();
const params = getStableQueryParams(queryParams);
const key = getQueryCacheKey(query, params);
let snapshot = this.snapshots.get(key);
if (!snapshot) {
snapshot = new BehaviorSubject({
result: initialData ?? null,
resultSourceMap: {},
query,
params,
});
this.snapshots.set(key, snapshot);
}
if (!snapshot.observed) {
this.handleRevalidation(snapshot);
}
return snapshot.pipe(map((snapshot) => snapshot.result), distinctUntilChanged());
}
handleRevalidation(snapshot) {
const { query, params } = snapshot.getValue();
this.revalidateService
.getRevalidateState()
.pipe(map((state) => state === 'refresh' || state === 'inflight'), distinctUntilChanged(), filter(Boolean), switchMap(() => this.fetchQuery(query, params)), takeWhile(() => snapshot.observed), takeUntilDestroyed(this.destroyRef))
.subscribe();
}
fetchQuery(query, params) {
const onFinally = this.revalidateService.startRefresh();
const controller = new AbortController();
return from(this.client.fetch(query, params, {
signal: controller.signal,
filterResponse: false,
})).pipe(tap(({ result, resultSourceMap }) => {
this.updateSnapshot(query, params, result, resultSourceMap);
if (resultSourceMap) {
this.turboIdsFromSourceMap(resultSourceMap);
}
}), catchError((error) => {
if (error.name !== 'AbortError') {
// Here you might want to implement error handling
console.error(error);
}
return EMPTY;
}), finalize(onFinally), map(() => undefined));
}
updateSnapshot(query, params, result, resultSourceMap) {
const key = getQueryCacheKey(query, params);
const snapshot = this.snapshots.get(key);
if (snapshot) {
snapshot.next({
...snapshot.getValue(),
result: this.turboChargeResultIfSourceMap(result, resultSourceMap),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resultSourceMap: resultSourceMap,
});
}
}
setupTurboUpdates() {
this.client
.listen('*', {}, {
events: ['mutation'],
effectFormat: 'mendoza',
includePreviousRevision: false,
includeResult: false,
tag: 'turbo',
})
.pipe(filter((update) => update.type === 'mutation' &&
(update.effects?.apply?.length ?? 0) > 0), tap((update) => {
const key = getTurboCacheKey(this.config.projectId, this.config.dataset, update.documentId);
const cachedDocument = this.documentsCache.peek(key);
if (cachedDocument) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const patchDoc = { ...cachedDocument };
delete patchDoc._rev;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const patchedDocument = applyPatch(patchDoc, update.effects.apply);
this.documentsCache.set(key, patchedDocument);
}
}), takeUntilDestroyed(this.destroyRef))
.subscribe((update) => {
this.lastMutatedDocumentId$.next(update.documentId);
});
}
setupSourceMapUpdates() {
combineLatest([this.lastMutatedDocumentId$, this.turboIds$])
.pipe(filter(([lastMutatedDocumentId, turboIds]) => !!lastMutatedDocumentId && turboIds.includes(lastMutatedDocumentId)), switchMap(() => this.updateAllSnapshots()), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.lastMutatedDocumentId$.next(null);
});
}
updateActiveDocumentIds() {
this.turboIds$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((turboIds) => {
const nextTurboIds = new Set();
this.docsInUse.clear();
for (const snapshot of this.snapshots.values()) {
if (snapshot.observed) {
const { resultSourceMap } = snapshot.getValue();
if (resultSourceMap?.documents?.length) {
for (const document of resultSourceMap.documents) {
nextTurboIds.add(document._id);
this.docsInUse.set(document._id, document);
}
}
}
}
const nextTurboIdsSnapshot = [...nextTurboIds].sort();
if (JSON.stringify(turboIds) !== JSON.stringify(nextTurboIdsSnapshot)) {
this.turboIds$.next(nextTurboIdsSnapshot);
}
});
}
syncWithPresentationToolIfPresent() {
this.turboIds$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.useDocumentsInUse.updateDocumentsInUse(this.docsInUse);
});
}
updateAllSnapshots() {
for (const [, snapshot] of this.snapshots.entries()) {
const currentSnapshot = snapshot.getValue();
if (currentSnapshot.resultSourceMap?.documents?.length) {
const updatedResult = this.turboChargeResultIfSourceMap(currentSnapshot.result, currentSnapshot.resultSourceMap);
snapshot.next({
...currentSnapshot,
result: updatedResult,
});
}
}
return EMPTY;
}
loadMissingDocuments() {
const { projectId, dataset } = this.config;
const documentsCache = this.documentsCache;
const batch$ = new BehaviorSubject([]);
combineLatest([batch$, this.turboIds$])
.pipe(map(([batch, turboIds]) => {
const batchSet = new Set(batch.flat());
const nextBatch = new Set();
for (const turboId of turboIds) {
if (!batchSet.has(turboId) &&
!documentsCache.has(getTurboCacheKey(projectId, dataset, turboId))) {
nextBatch.add(turboId);
}
}
return [...nextBatch].slice(0, 100);
}), filter((nextBatchSlice) => !!nextBatchSlice.length), takeUntilDestroyed(this.destroyRef))
.subscribe((nextBatchSlice) => {
const prevBatch = batch$.getValue();
batch$.next([...prevBatch.slice(-100), nextBatchSlice]);
});
batch$
.pipe(switchMap((batches) => from(batches)), mergeMap((ids) => {
const missingIds = ids.filter((id) => !documentsCache.has(getTurboCacheKey(projectId, dataset, id)));
if (missingIds.length === 0) {
return EMPTY;
}
return from(this.client.getDocuments(missingIds)).pipe(tap((documents) => {
for (const doc of documents) {
if (doc && doc._id) {
documentsCache.set(getTurboCacheKey(projectId, dataset, doc._id), doc);
}
}
}), catchError((error) => {
console.error('Error loading documents:', error);
return EMPTY;
}));
}), takeUntilDestroyed(this.destroyRef))
.subscribe();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LivePreviewService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LivePreviewService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LivePreviewService, decorators: [{
type: Injectable
}] });
class LiveQueryProviderComponent {
constructor() {
this.token = input.required();
this.livePreviewService = inject(LivePreviewService);
// Initialization for Angular v18
afterNextRender(() => {
if (!this.livePreviewService.isInitialized) {
this.livePreviewService.initialize(this.token());
}
});
// Initialization for Angular v19
effect(() => {
if (!this.livePreviewService.isInitialized) {
this.livePreviewService.initialize(this.token());
}
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LiveQueryProviderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.0.3", type: LiveQueryProviderComponent, isStandalone: true, selector: "live-query-provider", inputs: { token: { classPropertyName: "token", publicName: "token", isSignal: true, isRequired: true, transformFunction: null } }, providers: [LivePreviewService, RevalidateService, UseDocumentsInUseService], ngImport: i0, template: `<ng-content />`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LiveQueryProviderComponent, decorators: [{
type: Component,
args: [{
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'live-query-provider',
template: `<ng-content />`,
providers: [LivePreviewService, RevalidateService, UseDocumentsInUseService],
changeDetection: ChangeDetectionStrategy.OnPush,
}]
}], ctorParameters: () => [] });
// Implementation
function createLiveData(initialData, queries) {
const livePreviewService = inject(LivePreviewService);
return computedAsync(() => {
const queryConfig = queries();
const initial = untracked(initialData);
// Handle single query configuration
if ('query' in queryConfig) {
return livePreviewService
.listenLiveQuery(initial, queryConfig.query, queryConfig.params)
.pipe(startWith(initial));
}
// Handle multiple queries configuration
const observables = Object.entries(queryConfig).reduce((acc, [key, config]) => {
acc[key] = livePreviewService.listenLiveQuery(initial[key],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
config.query,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
config.params);
return acc;
}, {});
return combineLatest(observables).pipe(startWith(initial));
}, initialData);
}
function computedAsync(computation, initialValue) {
const value = signal(undefined);
const error = signal(undefined);
effect((onCleanup) => {
const subscription = computation().subscribe({
next: (v) => value.set(v),
error: (e) => error.set(e),
});
onCleanup(() => subscription.unsubscribe());
}, { allowSignalWrites: true });
return computed(() => {
if (error()) {
throw error();
}
return value() ?? untracked(initialValue);
});
}
/**
* Generated bundle index. Do not edit.
*/
export { LIVE_PREVIEW_REFRESH_INTERVAL, LivePreviewService, LiveQueryProviderComponent, createLiveData };
//# sourceMappingURL=limitless-angular-sanity-preview-kit.mjs.map