Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: spa mode #1525

Merged
merged 25 commits into from
Sep 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"test:unit": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/document-driven && vitest run"
},
"dependencies": {
"@nuxt/kit": "^3.0.0-rc.10",
"@nuxt/kit": "^3.0.0-rc.11",
"consola": "^2.15.3",
"defu": "^6.1.0",
"destr": "^1.1.1",
Expand Down Expand Up @@ -80,8 +80,8 @@
},
"devDependencies": {
"@nuxt/module-builder": "^0.1.7",
"@nuxt/schema": "^3.0.0-rc.10",
"@nuxt/test-utils": "^3.0.0-rc.10",
"@nuxt/schema": "^3.0.0-rc.11",
"@nuxt/test-utils": "^3.0.0-rc.11",
"@nuxthq/admin": "npm:@nuxthq/admin-edge@latest",
"@nuxtjs/eslint-config-typescript": "latest",
"@types/ws": "^8.5.3",
Expand All @@ -92,7 +92,7 @@
"husky": "^8.0.1",
"jiti": "^1.15.0",
"lint-staged": "^13.0.3",
"nuxt": "^3.0.0-rc.10",
"nuxt": "npm:nuxt3@latest",
"rehype-figure": "^1.0.1",
"remark-oembed": "^1.2.2",
"vitest": "^0.23.2",
Expand Down
50 changes: 46 additions & 4 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
createResolver,
addImports,
addComponentsDir,
addTemplate
addTemplate,
addComponent
} from '@nuxt/kit'
import { genImport, genSafeVariableName } from 'knitwork'
import type { ListenOptions } from 'listhen'
Expand Down Expand Up @@ -184,6 +185,9 @@ export interface ModuleOptions {
}
layoutFallbacks?: string[]
injectPage?: boolean
},
experimental: {
clientDB: boolean
}
}

Expand Down Expand Up @@ -229,7 +233,10 @@ export default defineNuxtModule<ModuleOptions>({
navigation: {
fields: []
},
documentDriven: false
documentDriven: false,
experimental: {
clientDB: false
}
},
async setup (options, nuxt) {
const { resolve } = createResolver(import.meta.url)
Expand Down Expand Up @@ -274,13 +281,13 @@ export default defineNuxtModule<ModuleOptions>({
},
{
method: 'get',
route: `/api/${options.base}/cache`,
route: `/api/${options.base}/cache.json`,
handler: resolveRuntimeModule('./server/api/cache')
}
)

if (!nuxt.options.dev) {
nitroConfig.prerender.routes.unshift('/api/_content/cache')
nitroConfig.prerender.routes.unshift(`/api/${options.base}/cache.json`)
}

// Register source storages
Expand Down Expand Up @@ -515,6 +522,12 @@ export default defineNuxtModule<ModuleOptions>({
contentContext.markdown = processMarkdownOptions(contentContext.markdown)

nuxt.options.runtimeConfig.public.content = defu(nuxt.options.runtimeConfig.public.content, {
clientDB: {
isSPA: options.experimental.clientDB && nuxt.options.ssr === false,
// Disable cache in dev mode
integrity: nuxt.options.dev ? undefined : Date.now()
},
navigation: contentContext.navigation,
base: options.base,
// Tags will use in markdown renderer for component replacement
tags: contentContext.markdown.tags as any,
Expand All @@ -538,6 +551,23 @@ export default defineNuxtModule<ModuleOptions>({
tailwindConfig.content.push(resolve(nuxt.options.buildDir, 'content-cache', 'parsed/**/*.md'))
})

// Experimental preview mode
if (process.env.NUXT_PREVIEW_API) {
// Add preview plugin
addPlugin(resolveRuntimeModule('./preview/preview-plugin'))

// Add preview components
addComponent({
name: 'ContentPreviewMode',
filePath: resolveRuntimeModule('./preview/components/ContentPreviewMode.vue')
})

// @ts-ignore
nuxt.options.runtimeConfig.public.content.previewAPI = process.env.NUXT_PREVIEW_API
// @ts-ignore
nuxt.options.runtimeConfig.content.previewAPI = process.env.NUXT_PREVIEW_API
}

// Setup content dev module
if (!nuxt.options.dev) {
nuxt.hook('build:before', async () => {
Expand Down Expand Up @@ -613,6 +643,14 @@ export default defineNuxtModule<ModuleOptions>({
})

interface ModulePublicRuntimeConfig {
/**
* @experimental
*/
clientDB: {
isSPA: boolean
integrity: number
}

tags: Record<string, string>

base: string;
Expand All @@ -622,6 +660,8 @@ interface ModulePublicRuntimeConfig {

// Shiki config
highlight: ModuleOptions['highlight']

navigation: ModuleOptions['navigation']
}

interface ModulePrivateRuntimeConfig {
Expand All @@ -631,6 +671,8 @@ interface ModulePrivateRuntimeConfig {
*/
cacheVersion: string;
cacheIntegrity: string;

previewAPI?: string
}

declare module '@nuxt/schema' {
Expand Down
102 changes: 102 additions & 0 deletions src/runtime/composables/client-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Storage } from 'unstorage'
// @ts-ignore
import LSDriver from 'unstorage/drivers/localstorage'
import { createStorage, prefixStorage } from 'unstorage'
import { createPipelineFetcher } from '../query/match/pipeline'
import { createQuery } from '../query/query'
import type { NavItem, ParsedContent, ParsedContentMeta, QueryBuilderParams } from '../types'
import { createNav } from '../server/navigation'

export const contentStorage = prefixStorage(createStorage({ driver: LSDriver() }), '@content')

export const getPreview = () => {
return useCookie('previewToken').value
}

export function createDB (storage: Storage) {
async function getItems () {
const keys = new Set(await storage.getKeys('cache:'))

// Merge preview items
const previewToken = getPreview()
if (previewToken) {
const previewKeys = await storage.getKeys(`${previewToken}:`)
const previewContents = await Promise.all(previewKeys.map(key => storage.getItem(key) as Promise<ParsedContent>))
for (const pItem of previewContents) {
keys.delete(`cache:${pItem._id}`)
if (!pItem.__deleted) {
keys.add(`${previewToken}:${pItem._id}`)
}
}
}

return Promise.all(Array.from(keys).map(key => storage.getItem(key) as Promise<ParsedContent>))
}
return {
storage,
fetch: createPipelineFetcher(getItems),
query: (query?: QueryBuilderParams) => createQuery(createPipelineFetcher(getItems), query)
}
}

let contentDatabase
export async function useContentDatabase () {
if (!contentDatabase) {
const { clientDB } = useRuntimeConfig().public.content
contentDatabase = createDB(contentStorage)
const integrity = await contentDatabase.storage.getItem('integrity')
if (clientDB.integrity !== +integrity) {
const { contents, navigation } = await $fetch(withContentBase('cache.json'))

for (const content of contents) {
await contentDatabase.storage.setItem(`cache:${content._id}`, content)
}

await contentDatabase.storage.setItem('navigation', navigation)

await contentDatabase.storage.setItem('integrity', clientDB.integrity)
}
}
return contentDatabase
}

export async function generateNavigation (query): Promise<Array<NavItem>> {
const db = await useContentDatabase()

if (!getPreview() && Object.keys(query || {}).length === 0) {
return db.storage.getItem('navigation')
}

const contents = await db.query(query)
.where({
/**
* Partial contents are not included in the navigation
* A partial content is a content that has `_` prefix in its path
*/
_partial: false,
/**
* Exclude any pages which have opted out of navigation via frontmatter.
*/
navigation: {
$ne: false
}
})
.find()

const dirConfigs = await db.query().where({ _path: /\/_dir$/i, _partial: true }).find()

const configs = dirConfigs.reduce((configs, conf) => {
if (conf.title.toLowerCase() === 'dir') {
conf.title = undefined
}
const key = conf._path.split('/').slice(0, -1).join('/') || '/'
configs[key] = {
...conf,
// Extract meta from body. (non MD files)
...conf.body
}
return configs
}, {} as Record<string, ParsedContentMeta>)

return createNav(contents as ParsedContentMeta[], configs)
}
9 changes: 7 additions & 2 deletions src/runtime/composables/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { hash } from 'ohash'
import { useCookie } from '#app'
import type { NavItem, QueryBuilder, QueryBuilderParams } from '../types'
import { jsonStringify } from '../utils/json'
import { addPrerenderPath, withContentBase } from './utils'
import { addPrerenderPath, shouldUseClientDB, withContentBase } from './utils'

export const fetchContentNavigation = (queryBuilder?: QueryBuilder | QueryBuilderParams): Promise<Array<NavItem>> => {
export const fetchContentNavigation = async (queryBuilder?: QueryBuilder | QueryBuilderParams): Promise<Array<NavItem>> => {
let params = queryBuilder

// When params is an instance of QueryBuilder then we need to pick the params explicitly
Expand All @@ -17,6 +17,11 @@ export const fetchContentNavigation = (queryBuilder?: QueryBuilder | QueryBuilde
addPrerenderPath(apiPath)
}

if (shouldUseClientDB()) {
const generateNavigation = await import('./client-db').then(m => m.generateNavigation)
return generateNavigation(params || {})
}

return $fetch(apiPath, {
method: 'GET',
responseType: 'json',
Expand Down
9 changes: 7 additions & 2 deletions src/runtime/composables/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { useCookie } from '#app'
import { createQuery } from '../query/query'
import type { ParsedContent, QueryBuilder, QueryBuilderParams } from '../types'
import { jsonStringify } from '../utils/json'
import { addPrerenderPath, withContentBase } from './utils'
import { addPrerenderPath, shouldUseClientDB, withContentBase } from './utils'

/**
* Query fetcher
*/
export const createQueryFetch = <T = ParsedContent>(path?: string) => (query: QueryBuilder<T>) => {
export const createQueryFetch = <T = ParsedContent>(path?: string) => async (query: QueryBuilder<T>) => {
if (path) {
if (query.params().first) {
query.where({ _path: withoutTrailingSlash(path) })
Expand All @@ -31,6 +31,11 @@ export const createQueryFetch = <T = ParsedContent>(path?: string) => (query: Qu
addPrerenderPath(apiPath)
}

if (shouldUseClientDB()) {
const db = await import('./client-db').then(m => m.useContentDatabase())
return db.fetch(query)
}

return $fetch(apiPath as any, {
method: 'GET',
responseType: 'json',
Expand Down
16 changes: 15 additions & 1 deletion src/runtime/composables/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withBase } from 'ufo'
import { useRuntimeConfig, useRequestEvent } from '#app'
import { useRuntimeConfig, useRequestEvent, useCookie, useRoute } from '#app'
import { unwrap, flatUnwrap } from '../markdown-parser/utils/node'

export const withContentBase = (url: string) => withBase(url, '/api/' + useRuntimeConfig().public.content.base)
Expand Down Expand Up @@ -30,3 +30,17 @@ export const addPrerenderPath = (path: string) => {
].filter(Boolean).join(',')
)
}

export const shouldUseClientDB = () => {
const { previewAPI, clientDB } = useRuntimeConfig().content
if (!process.client) { return false }
if (clientDB?.isSPA) { return true }

// Disable clientDB when preview mode is disabled
if (!previewAPI) { return false }
if (useRoute().query?.preview || useCookie('previewToken').value) {
return true
}

return false
}
4 changes: 2 additions & 2 deletions src/runtime/plugins/documentDriven.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { defineNuxtPlugin, queryContent, useContentHelpers, useContentState, fet
import layouts from '#build/layouts'

export default defineNuxtPlugin((nuxt) => {
const { documentDriven: moduleOptions } = useRuntimeConfig()?.public?.content
const { documentDriven: moduleOptions, clientDB } = useRuntimeConfig()?.public?.content

/**
* Finds a layout value from a cascade of objects.
Expand Down Expand Up @@ -255,7 +255,7 @@ export default defineNuxtPlugin((nuxt) => {
// TODO: Remove this (https://github.com/nuxt/framework/pull/5274)
if (to.path.includes('favicon.ico')) { return }
// Avoid calling on hash change
if (process.client && to.path === from.path) { return }
if (process.client && !clientDB.isSPA && to.path === from.path) { return }

const redirect = await refresh(to, false)

Expand Down