diff --git a/src/module.ts b/src/module.ts index 3fea01b1e..4377b4aac 100644 --- a/src/module.ts +++ b/src/module.ts @@ -19,7 +19,7 @@ import type { Lang as ShikiLang, Theme as ShikiTheme } from 'shiki-es' import { listen } from 'listhen' import type { WatchEvent } from 'unstorage' import { createStorage } from 'unstorage' -import { withTrailingSlash } from 'ufo' +import { joinURL, withLeadingSlash, withTrailingSlash } from 'ufo' import { name, version } from '../package.json' import { CACHE_VERSION, @@ -45,8 +45,17 @@ export interface ModuleOptions { * Base route that will be used for content api * * @default '_content' + * @deprecated Use `api.base` instead */ base: string + api: { + /** + * Base route that will be used for content api + * + * @default '/api/_content' + */ + baseURL: string + } /** * Disable content watcher and hot content reload. * Note: Watcher is a development feature and will not includes in the production. @@ -229,7 +238,11 @@ export default defineNuxtModule({ } }, defaults: { - base: '_content', + // @deprecated + base: '', + api: { + baseURL: '/api/_content' + }, watch: { ws: { port: 4000, @@ -265,6 +278,12 @@ export default defineNuxtModule({ async setup (options, nuxt) { const { resolve } = createResolver(import.meta.url) const resolveRuntimeModule = (path: string) => resolveModule(path, { paths: resolve('./runtime') }) + + if (options.base) { + logger.warn('content.base is deprecated. Use content.api.baseURL instead.') + options.api.baseURL = withLeadingSlash(joinURL('api', options.base)) + } + const contentContext: ContentContext = { transformers: [], ...options @@ -292,23 +311,23 @@ export default defineNuxtModule({ nitroConfig.handlers.push( { method: 'get', - route: `/api/${options.base}/query/:qid`, + route: `${options.api.baseURL}/query/:qid`, handler: resolveRuntimeModule('./server/api/query') }, { method: 'get', - route: `/api/${options.base}/query`, + route: `${options.api.baseURL}/query`, handler: resolveRuntimeModule('./server/api/query') }, { method: 'get', - route: `/api/${options.base}/cache.json`, + route: `${options.api.baseURL}/cache.json`, handler: resolveRuntimeModule('./server/api/cache') } ) if (!nuxt.options.dev) { - nitroConfig.prerender.routes.unshift(`/api/${options.base}/cache.json`) + nitroConfig.prerender.routes.unshift(`${options.api.baseURL}/cache.json`) } // Register source storages @@ -429,12 +448,12 @@ export default defineNuxtModule({ nitroConfig.handlers = nitroConfig.handlers || [] nitroConfig.handlers.push({ method: 'get', - route: `/api/${options.base}/navigation/:qid`, + route: `${options.api.baseURL}/navigation/:qid`, handler: resolveRuntimeModule('./server/api/navigation') }) nitroConfig.handlers.push({ method: 'get', - route: `/api/${options.base}/navigation`, + route: `${options.api.baseURL}/navigation`, handler: resolveRuntimeModule('./server/api/navigation') }) }) @@ -446,13 +465,13 @@ export default defineNuxtModule({ if (options.highlight) { contentContext.transformers.push(resolveRuntimeModule('./transformers/shiki')) // @ts-ignore - contentContext.highlight.apiURL = `/api/${options.base}/highlight` + contentContext.highlight.apiURL = `${options.api.baseURL}/highlight` nuxt.hook('nitro:config', (nitroConfig) => { nitroConfig.handlers = nitroConfig.handlers || [] nitroConfig.handlers.push({ method: 'post', - route: `/api/${options.base}/highlight`, + route: `${options.api.baseURL}/highlight`, handler: resolveRuntimeModule('./server/api/highlight') }) }) @@ -571,8 +590,10 @@ export default defineNuxtModule({ // Disable cache in dev mode integrity: nuxt.options.dev ? undefined : Date.now() }, + api: { + baseURL: options.api.baseURL + }, navigation: contentContext.navigation as any, - base: options.base, // Tags will use in markdown renderer for component replacement tags: contentContext.markdown.tags as any, highlight: options.highlight as any, diff --git a/src/runtime/composables/client-db.ts b/src/runtime/composables/client-db.ts index b7d6c59f8..ef8940ed7 100644 --- a/src/runtime/composables/client-db.ts +++ b/src/runtime/composables/client-db.ts @@ -9,7 +9,7 @@ import { createQuery } from '../query/query' import type { NavItem, ParsedContent, ParsedContentMeta, QueryBuilderParams } from '../types' import { createNav } from '../server/navigation' -const withContentBase = url => withBase(url, '/api/' + useRuntimeConfig().public.content.base) +const withContentBase = url => withBase(url, useRuntimeConfig().public.content.api.baseURL) export const contentStorage = prefixStorage(createStorage({ driver: memoryDriver() }), '@content') diff --git a/src/runtime/composables/utils.ts b/src/runtime/composables/utils.ts index 10536e878..1624a7e32 100644 --- a/src/runtime/composables/utils.ts +++ b/src/runtime/composables/utils.ts @@ -2,7 +2,7 @@ import { withBase } from 'ufo' 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) +export const withContentBase = (url: string) => withBase(url, useRuntimeConfig().public.content.api.baseURL) export const useUnwrap = () => ({ unwrap, diff --git a/src/runtime/server/api/cache.ts b/src/runtime/server/api/cache.ts index c88eec50c..b21ff8b78 100644 --- a/src/runtime/server/api/cache.ts +++ b/src/runtime/server/api/cache.ts @@ -1,9 +1,12 @@ import { defineEventHandler } from 'h3' import { getContentIndex } from '../content-index' import { cacheStorage, serverQueryContent } from '../storage' +import type { NavItem } from '../../types' +import { useRuntimeConfig } from '#imports' // This route is used to cache all the parsed content export default defineEventHandler(async (event) => { + const { content } = useRuntimeConfig() const now = Date.now() // Fetch all content const contents = await serverQueryContent(event).find() @@ -11,7 +14,7 @@ export default defineEventHandler(async (event) => { // Generate Index await getContentIndex(event) - const navigation = await $fetch('/api/_content/navigation') + const navigation = await $fetch(`${content.api.baseURL}/navigation`) await cacheStorage.setItem('content-navigation.json', navigation) return { diff --git a/test/__snapshots__/custom-api-base.test.ts.snap b/test/__snapshots__/custom-api-base.test.ts.snap new file mode 100644 index 000000000..ece256b65 --- /dev/null +++ b/test/__snapshots__/custom-api-base.test.ts.snap @@ -0,0 +1,86 @@ +// Vitest Snapshot v1 + +exports[`Custom api baaseURL > Get contents index > basic-index-body 1`] = ` +{ + "children": [ + { + "children": [ + { + "type": "text", + "value": "Index", + }, + ], + "props": { + "id": "index", + }, + "tag": "h1", + "type": "element", + }, + { + "children": [ + { + "type": "text", + "value": "Hello World", + }, + ], + "props": {}, + "tag": "p", + "type": "element", + }, + ], + "toc": { + "depth": 2, + "links": [], + "searchDepth": 2, + "title": "", + }, + "type": "root", +} +`; + +exports[`Custom api baaseURL > Navigation > Get cats navigation > basic-navigation-cats 1`] = ` +[ + { + "_path": "/cats", + "children": [ + { + "_path": "/cats/bombay", + "title": "Bombay", + }, + { + "_path": "/cats", + "title": "Cats", + }, + { + "_path": "/cats/persian", + "title": "Persian", + }, + { + "_path": "/cats/ragdoll", + "title": "Ragdoll", + }, + ], + "title": "Cats list", + }, +] +`; + +exports[`Custom api baaseURL > Navigation > Get dogs navigation > basic-navigation-dogs 1`] = ` +[ + { + "_path": "/dogs", + "children": [ + { + "_path": "/dogs/bulldog", + "title": "Bulldog", + }, + { + "_path": "/dogs/german-shepherd", + "title": "German Shepherd", + }, + ], + "icon": "🐶", + "title": "Dogs List", + }, +] +`; diff --git a/test/custom-api-base.test.ts b/test/custom-api-base.test.ts new file mode 100644 index 000000000..4f86b749c --- /dev/null +++ b/test/custom-api-base.test.ts @@ -0,0 +1,173 @@ +import { fileURLToPath } from 'url' +import { assert, test, describe, expect, vi } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils' +import { hash } from 'ohash' +import { testMarkdownParser } from './features/parser-markdown' +import { testPathMetaTransformer } from './features/transformer-path-meta' +import { testYamlParser } from './features/parser-yaml' +import { testNavigation } from './features/navigation' +// import { testMDCComponent } from './features/mdc-component' +import { testJSONParser } from './features/parser-json' +import { testCSVParser } from './features/parser-csv' +import { testRegex } from './features/regex' +import { testMarkdownParserExcerpt } from './features/parser-markdown-excerpt' +import { testParserHooks } from './features/parser-hooks' +import { testModuleOptions } from './features/module-options' +import { testContentQuery } from './features/content-query' +import { testHighlighter } from './features/highlighter' +import { testMarkdownRenderer } from './features/renderer-markdown' +import { testParserOptions } from './features/parser-options' +import { testComponents } from './features/components' + +const spyConsoleWarn = vi.spyOn(global.console, 'warn') +const apiBaseURL = '/my-content-api' + +describe('Custom api baaseURL', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), + server: true, + nuxtConfig: { + // @ts-ignore + content: { + api: { + baseURL: apiBaseURL + } + } + } + }) + + const QUERY_ENDPOINT = `${apiBaseURL}/query` + const fetchDocument = (_id: string) => { + const params = { first: true, where: { _id } } + const qid = hash(params) + return $fetch(`${QUERY_ENDPOINT}/${qid}`, { + params: { _params: JSON.stringify(params) } + }) + } + + test('List contents', async () => { + const params = { only: '_id' } + const qid = hash(params) + const docs = await $fetch(`${QUERY_ENDPOINT}/${qid}`, { + params: { _params: JSON.stringify(params) } + }) + + const ids = docs.map(doc => doc._id) + + assert(ids.length > 0) + assert(ids.includes('content:index.md')) + + // Ignored files should be listed + assert(ids.includes('content:.dot-ignored.md') === false, 'Ignored files with `.` should not be listed') + assert(ids.includes('content:-dash-ignored.md') === false, 'Ignored files with `-` should not be listed') + + assert(ids.includes('fa-ir:fa:hello.md') === true, 'Files with `fa-ir` prefix should be listed') + }) + + test('Get contents index', async () => { + const index = await fetchDocument('content:index.md') + + expect(index).toHaveProperty('body') + + expect(index.body).toMatchSnapshot('basic-index-body') + }) + + test('Get ignored contents', async () => { + const ignored = await fetchDocument('content:.dot-ignored.md').catch(_err => null) + + expect(ignored).toBeNull() + }) + + test('Search contents using `locale` helper', async () => { + const fa = await $fetch('/locale-fa') + + expect(fa).toContain('fa-ir:fa:hello.md') + expect(fa).not.toContain('content:index.md') + + const en = await $fetch('/locale-en') + + expect(en).not.toContain('fa-ir:fa:hello.md') + expect(en).toContain('content:index.md') + }) + + test('Use default locale for unscoped contents', async () => { + const index = await fetchDocument('content:index.md') + + expect(index).toMatchObject({ + _locale: 'en' + }) + }) + + test('Multi part path', async () => { + const html = await $fetch('/features/multi-part-path') + expect(html).contains('Persian') + }) + + test('Empty slot', async () => { + const html = await $fetch('/features/empty-slot') + expect(html).contains('Empty!!!') + }) + + test(' head management (if same path)', async () => { + const html = await $fetch('/head') + expect(html).contains('Head overwritten') + expect(html).contains('') + expect(html).contains('') + expect(html).contains('') + }) + + test(' head management (not same path)', async () => { + const html = await $fetch('/bypass-head') + expect(html).not.contains('Head overwritten') + expect(html).not.contains('') + expect(html).not.contains('') + }) + + test('Partials specials chars', async () => { + const html = await $fetch('/_partial/content-(v2)') + expect(html).contains('Content (v2)') + }) + + test('Partials specials chars', async () => { + const html = await $fetch('/_partial/markdown') + expect(html).contains('> Default title ') + expect(html).contains('

p1

') + }) + + test('Warning for invalid file name', () => { + expect(spyConsoleWarn).toHaveBeenCalled() + expect(spyConsoleWarn).toHaveBeenCalledWith('Ignoring [content:with-\'invalid\'-char.md]. File name should not contain any of the following characters: \', ", ?, #, /') + }) + + testComponents() + + testContentQuery() + + testNavigation() + + testMarkdownParser() + + testMarkdownRenderer() + + testMarkdownParserExcerpt() + + testYamlParser() + + testCSVParser() + + testJSONParser() + + testPathMetaTransformer() + + // testMDCComponent() + + testRegex() + + testParserHooks() + + testModuleOptions() + + testHighlighter() + + testParserOptions() +}) diff --git a/test/features/navigation.ts b/test/features/navigation.ts index 82efe7e98..0788c9423 100644 --- a/test/features/navigation.ts +++ b/test/features/navigation.ts @@ -1,13 +1,15 @@ import { describe, test, expect } from 'vitest' -import { $fetch } from '@nuxt/test-utils' +import { $fetch, useTestContext } from '@nuxt/test-utils' import { hash } from 'ohash' import { jsonStringify } from '../../src/runtime/utils/json' export const testNavigation = () => { + // @ts-ignore + const apiBaseUrl = useTestContext().options.nuxtConfig.content?.api?.baseURL || '/api/_content' describe('Navigation', () => { test('Get navigation', async () => { const query = { where: [{ _locale: 'en' }] } - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } @@ -25,7 +27,7 @@ export const testNavigation = () => { test('Get cats navigation', async () => { const query = { where: [{ _path: /^\/cats/ }] } - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } @@ -36,7 +38,7 @@ export const testNavigation = () => { test('Get dogs navigation', async () => { const query = { where: [{ _path: /^\/dogs/ }] } - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } @@ -47,7 +49,7 @@ export const testNavigation = () => { test('_dir.yml should be able to filter navigation tree', async () => { const query = { where: [{ _path: /^\/test-navigation/ }] } - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } @@ -65,7 +67,7 @@ export const testNavigation = () => { test('Get numbers navigation', async () => { const query = { where: [{ _path: /^\/numbers/ }] } - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } @@ -80,7 +82,8 @@ export const testNavigation = () => { }) test('Should remove `navigation-disabled.md` content', async () => { - const list = await $fetch('/api/_content/navigation/') + const list = await $fetch(`${apiBaseUrl}/navigation/`) + const hidden = list.find(i => i._path === '/navigation-disabled') expect(hidden).toBeUndefined() }) @@ -102,7 +105,7 @@ export const testNavigation = () => { } const queryNav = async (query) => { - const list = await $fetch(`/api/_content/navigation/${hash(query)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(query)}`, { params: { _params: jsonStringify(query) } diff --git a/test/features/regex.ts b/test/features/regex.ts index b34c2a096..ebc86e4ea 100644 --- a/test/features/regex.ts +++ b/test/features/regex.ts @@ -1,13 +1,15 @@ import { describe, test, expect } from 'vitest' -import { $fetch } from '@nuxt/test-utils' +import { $fetch, useTestContext } from '@nuxt/test-utils' import { hash } from 'ohash' import { jsonStringify } from '../../src/runtime/utils/json' export const testRegex = () => { + // @ts-ignore + const apiBaseUrl = useTestContext().options.nuxtConfig.content?.api?.baseURL || '/api/_content' describe('Regex queries', () => { test('Get cats with regex', async () => { const params = { where: { _path: /^\/cats/ } } - const list = await $fetch(`/api/_content/query/${hash(params)}`, { + const list = await $fetch(`${apiBaseUrl}/query/${hash(params)}`, { params: { _params: jsonStringify(params) } @@ -21,7 +23,7 @@ export const testRegex = () => { test('Get cats navigation with regex', async () => { const params = { where: { _path: /^\/cats/ } } - const list = await $fetch(`/api/_content/navigation/${hash(params)}`, { + const list = await $fetch(`${apiBaseUrl}/navigation/${hash(params)}`, { params: { _params: jsonStringify(params) }