Skip to content

Commit

Permalink
refactor(storage): useContentStorage composable (#175)
Browse files Browse the repository at this point in the history
  • Loading branch information
larbish authored Jun 12, 2024
1 parent 1513957 commit 1774a0b
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 103 deletions.
6 changes: 2 additions & 4 deletions src/runtime/components/ContentPreviewMode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,14 @@ const closePreviewMode = async () => {
}
const sync = async (data: PreviewResponse) => {
const isUpdated = await props.syncPreview(data)
const storageReady = await props.syncPreview(data)
if (previewReady.value === true) {
// Preview already ready, no need to sync again
return
}
// If data is not updated, it means the storage is not ready yet and we should try again
if (!isUpdated) {
if (!storageReady) {
setTimeout(() => sync(data), 1000)
return
}
Expand All @@ -70,7 +69,6 @@ const sync = async (data: PreviewResponse) => {
// Remove query params in url to refresh page (in case of 404 with no SPA fallback)
await router.replace({ query: {} })
// @ts-expect-error custom hook
nuxtApp.callHook('nuxt-studio:preview:ready')
if (window.parent && window.self !== window.parent) {
Expand Down
81 changes: 81 additions & 0 deletions src/runtime/composables/useContentStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ParsedContent } from '@nuxt/content/types'
import type { PreviewFile } from '../types'
import { useNuxtApp, useState, queryContent } from '#imports'

export const useContentStorage = () => {
const nuxtApp = useNuxtApp()
const contentPathMap = {} as Record<string, ParsedContent>
const storage = useState<Storage | null>('studio-client-db', () => null)

// Initialize storage
if (!storage.value) {
nuxtApp.hook('content:storage', (_storage: Storage) => {
storage.value = _storage
})

// Call `queryContent` to trigger `content:storage` hook
queryContent('/non-existing-path').findOne()
}

const findContentItem = async (path: string): Promise<ParsedContent | null> => {
const previewToken = window.sessionStorage.getItem('previewToken')
if (!path) {
return null
}
path = path.replace(/\/$/, '')
let content = await storage.value?.getItem(`${previewToken}:${path}`)
if (!content) {
content = await storage.value?.getItem(`cached:${path}`)
}
if (!content) {
content = content = await storage.value?.getItem(path)
}

// try finding content from contentPathMap
if (!content) {
content = contentPathMap[path || '/']
}

return content as ParsedContent
}

const updateContentItem = (previewToken: string, file: PreviewFile) => {
if (!storage.value) return

contentPathMap[file.parsed!._path!] = file.parsed!
storage.value.setItem(`${previewToken}:${file.parsed?._id}`, JSON.stringify(file.parsed))
}

const removeContentItem = async (previewToken: string, path: string) => {
const content = await findContentItem(path)
await storage.value?.removeItem(`${previewToken}:${path}`)

if (content) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete contentPathMap[content._path!]
const nonDraftContent = await findContentItem(content._id)
if (nonDraftContent) {
contentPathMap[nonDraftContent._path!] = nonDraftContent
}
}
}

const removeAllContentItems = async (previewToken: string) => {
const keys: string[] = await storage.value.getKeys(`${previewToken}:`)
await Promise.all(keys.map(key => storage.value.removeItem(key)))
}

const setPreviewMetaItems = async (previewToken: string, files: PreviewFile[]) => {
const sources = new Set<string>(files.map(file => file.parsed!._id.split(':').shift()!))
await storage.value.setItem(`${previewToken}$`, JSON.stringify({ ignoreSources: Array.from(sources) }))
}

return {
storage,
findContentItem,
updateContentItem,
removeContentItem,
removeAllContentItems,
setPreviewMetaItems,
}
}
115 changes: 17 additions & 98 deletions src/runtime/composables/useStudio.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,40 @@
import { createApp } from 'vue'
import type { Storage } from 'unstorage'
import type { ParsedContent } from '@nuxt/content/dist/runtime/types'
import { createDefu } from 'defu'
import type { RouteLocationNormalized } from 'vue-router'
import type { AppConfig } from 'nuxt/schema'
import ContentPreviewMode from '../components/ContentPreviewMode.vue'
import { createSingleton, deepAssign, deepDelete, mergeDraft, StudioConfigFiles } from '../utils'
import { createSingleton, deepAssign, deepDelete, defu, mergeDraft, StudioConfigFiles } from '../utils'
import type { PreviewFile, PreviewResponse, FileChangeMessagePayload } from '../types'
import { useContentStorage } from './useContentStorage'
import { callWithNuxt } from '#app'
import { useAppConfig, useNuxtApp, useRuntimeConfig, useState, useContentState, queryContent, ref, toRaw, useRoute, useRouter } from '#imports'
import { useAppConfig, useNuxtApp, useRuntimeConfig, useContentState, ref, toRaw, useRoute, useRouter } from '#imports'

const useDefaultAppConfig = createSingleton(() => JSON.parse(JSON.stringify((useAppConfig()))))

const defu = createDefu((obj, key, value) => {
if (Array.isArray(obj[key]) && Array.isArray(value)) {
obj[key] = value
return true
}
})

let dbFiles: PreviewFile[] = []

export const useStudio = () => {
const nuxtApp = useNuxtApp()
const { storage, findContentItem, updateContentItem, removeContentItem, removeAllContentItems, setPreviewMetaItems } = useContentStorage()
const { studio: studioConfig, content: contentConfig } = useRuntimeConfig().public
const contentPathMap = {} as Record<string, ParsedContent>
const apiURL = window.sessionStorage.getItem('previewAPI') || studioConfig?.apiURL

// App config (required)
const initialAppConfig = useDefaultAppConfig()
const storage = useState<Storage | null>('studio-client-db', () => null)

if (!storage.value) {
nuxtApp.hook('content:storage', (_storage: Storage) => {
storage.value = _storage
})

// Call `queryContent` to trigger `content:storage` hook
queryContent('/non-existing-path').findOne()
}
const syncPreviewFiles = async (files: PreviewFile[]) => {
const previewToken = window.sessionStorage.getItem('previewToken') as string

const syncPreviewFiles = async (contentStorage: Storage, files: PreviewFile[]) => {
const previewToken = window.sessionStorage.getItem('previewToken')
// Remove previous preview data
const keys: string[] = await contentStorage.getKeys(`${previewToken}:`)
await Promise.all(keys.map(key => contentStorage.removeItem(key)))
removeAllContentItems(previewToken)

// Set preview meta
const sources = new Set<string>(files.map(file => file.parsed!._id.split(':').shift()!))
await contentStorage.setItem(`${previewToken}$`, JSON.stringify({ ignoreSources: Array.from(sources) }))
setPreviewMetaItems(previewToken, files)

// Handle content files
await Promise.all(
files.map((item) => {
contentPathMap[item.parsed!._path!] = item.parsed!
return contentStorage.setItem(`${previewToken}:${item.parsed!._id}`, JSON.stringify(item.parsed))
files.map((file) => {
updateContentItem(previewToken, file)
}),
)
}
Expand Down Expand Up @@ -94,7 +74,7 @@ export const useStudio = () => {

// Handle content files
const contentFiles = mergedFiles.filter(item => !([StudioConfigFiles.appConfig, StudioConfigFiles.nuxtConfig].includes(item.path)))
await syncPreviewFiles(storage.value, contentFiles)
await syncPreviewFiles(contentFiles)

const appConfig = mergedFiles.find(item => item.path === StudioConfigFiles.appConfig)
syncPreviewAppConfig(appConfig?.parsed as ParsedContent)
Expand Down Expand Up @@ -130,58 +110,12 @@ export const useStudio = () => {
}).mount(el)
}

// Content Helpers
const findContentWithId = async (path: string): Promise<ParsedContent | null> => {
const previewToken = window.sessionStorage.getItem('previewToken')
if (!path) {
return null
}
path = path.replace(/\/$/, '')
let content = await storage.value?.getItem(`${previewToken}:${path}`)
if (!content) {
content = await storage.value?.getItem(`cached:${path}`)
}
if (!content) {
content = content = await storage.value?.getItem(path)
}

// try finding content from contentPathMap
if (!content) {
content = contentPathMap[path || '/']
}

return content as ParsedContent
}

const updateContent = (content: PreviewFile) => {
const previewToken = window.sessionStorage.getItem('previewToken')
if (!storage.value) return

contentPathMap[content.parsed!._path!] = content.parsed!
storage.value.setItem(`${previewToken}:${content.parsed?._id}`, JSON.stringify(content.parsed))
}

const removeContentWithId = async (path: string) => {
const previewToken = window.sessionStorage.getItem('previewToken')
const content = await findContentWithId(path)
await storage.value?.removeItem(`${previewToken}:${path}`)

if (content) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete contentPathMap[content._path!]
const nonDraftContent = await findContentWithId(content._id)
if (nonDraftContent) {
contentPathMap[nonDraftContent._path!] = nonDraftContent
}
}
}

const requestRerender = async () => {
if (contentConfig?.documentDriven) {
const { pages } = callWithNuxt(nuxtApp, useContentState)

const contents = await Promise.all(Object.keys(pages.value).map(async (key) => {
return await findContentWithId(pages.value[key]?._id ?? key)
return await findContentItem(pages.value[key]?._id ?? key)
}))

pages.value = contents.reduce((acc, item, index) => {
Expand All @@ -196,19 +130,6 @@ export const useStudio = () => {
}

return {
apiURL,
contentStorage: storage,

syncPreviewFiles,
syncPreviewAppConfig,
requestPreviewSynchronization,

findContentWithId,
updateContent,
removeContentWithId,

requestRerender,

mountPreviewUI,
initiateIframeCommunication,
}
Expand Down Expand Up @@ -258,7 +179,7 @@ export const useStudio = () => {

switch (type) {
case 'nuxt-studio:editor:file-selected': {
const content = await findContentWithId(payload.path)
const content = await findContentItem(payload.path)
if (!content) {
// Do not navigate to another page if content is not found
// This makes sure that user stays on the same page when navigation through directories in the editor
Expand All @@ -273,21 +194,19 @@ export const useStudio = () => {
}
break
}
case 'nuxt-studio:editor:media-changed':
case 'nuxt-studio:editor:file-changed': {
const previewToken = window.sessionStorage.getItem('previewToken') as string
const { additions = [], deletions = [] } = payload as FileChangeMessagePayload
for (const addition of additions) {
await updateContent(addition)
await updateContentItem(previewToken, addition)
}
for (const deletion of deletions) {
await removeContentWithId(deletion.path)
await removeContentItem(previewToken, deletion.path)
}
requestRerender()
break
}
case 'nuxt-studio:preview:sync': {
syncPreview(payload)
break
}
case 'nuxt-studio:config:file-changed': {
const { additions = [], deletions = [] } = payload as FileChangeMessagePayload

Expand Down
1 change: 0 additions & 1 deletion src/runtime/plugins/preview.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export default defineNuxtPlugin((nuxtApp) => {

// Listen to `content:storage` hook to get storage instance
// There is some cases that `content:storage` hook is called before initializing preview
// @ts-expect-error custom hook
nuxtApp.hook('content:storage', (_storage: Storage) => {
storage.value = _storage
})
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { createDefu } from 'defu'

export * from './files'

export const StudioConfigFiles = {
appConfig: 'app.config.ts',
nuxtConfig: 'nuxt.config.ts',
}

export const defu = createDefu((obj, key, value) => {
if (Array.isArray(obj[key]) && Array.isArray(value)) {
obj[key] = value
return true
}
})

export const createSingleton = <T, Params extends Array<unknown>>(fn: () => T) => {
let instance: T | undefined
return (_args?: Params) => {
Expand Down

0 comments on commit 1774a0b

Please sign in to comment.