UNPKG

@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
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