feat(dashboard): add template registry (#539)
This commit is contained in:
parent
255dc84492
commit
ebb10741fe
@ -1,10 +1,22 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { getActiveTemplate } from '@cms'
|
||||
import ArticleFeed from '@components/home/ArticleFeed'
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
|
||||
import { getActiveTemplate } from '../src/templateRegistry'
|
||||
|
||||
const template = getActiveTemplate()
|
||||
const HomePageTemplate = template.pages.home
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomePageTemplate />
|
||||
return (
|
||||
<HomePageTemplate
|
||||
slots={{
|
||||
ProductMatrix,
|
||||
ArticleFeed,
|
||||
Sidebar,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export type TemplateName = 'default'
|
||||
export type TemplateName = 'default' | (string & {})
|
||||
export type ThemeName = 'default'
|
||||
export type ExtensionName = 'app-shell' | 'markdown-sync'
|
||||
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { ComponentType, createElement, type ReactNode } from 'react'
|
||||
|
||||
import { cmsConfig, type ExtensionName, type TemplateName, type ThemeName } from './config'
|
||||
import { cmsConfig, type ExtensionName, type ThemeName } from './config'
|
||||
import type { CmsExtension, CmsTemplate, CmsTheme } from './types'
|
||||
import { appShellExtension } from './extensions/appShell'
|
||||
import { markdownSyncExtension } from './extensions/markdownSync'
|
||||
import defaultTemplate from './templates/default'
|
||||
import defaultTheme from './themes/default'
|
||||
|
||||
const templateRegistry: Record<TemplateName, CmsTemplate> = {
|
||||
default: defaultTemplate,
|
||||
}
|
||||
import {
|
||||
getActiveTemplate as getActiveTemplateFromRegistry,
|
||||
listRegisteredTemplateNames,
|
||||
registerTemplate,
|
||||
registerTemplateLoader,
|
||||
} from '../src/templateRegistry'
|
||||
|
||||
const themeRegistry: Record<ThemeName, CmsTheme> = {
|
||||
default: defaultTheme,
|
||||
@ -20,8 +21,10 @@ const extensionRegistry: Record<ExtensionName, CmsExtension> = {
|
||||
'markdown-sync': markdownSyncExtension,
|
||||
}
|
||||
|
||||
export { listRegisteredTemplateNames, registerTemplate, registerTemplateLoader }
|
||||
|
||||
export function getActiveTemplate(): CmsTemplate {
|
||||
return templateRegistry[cmsConfig.template]
|
||||
return getActiveTemplateFromRegistry()
|
||||
}
|
||||
|
||||
export function getActiveTheme(): CmsTheme {
|
||||
|
||||
@ -1,38 +1 @@
|
||||
import ArticleFeed from '@components/home/ArticleFeed'
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
|
||||
import type { CmsTemplate } from '../../types'
|
||||
|
||||
function DefaultHomePage() {
|
||||
return (
|
||||
<main className="bg-slate-950">
|
||||
<section className="pb-24 pt-24">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<ProductMatrix />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="bg-slate-50 pb-16 pt-20">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<ArticleFeed />
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultTemplate: CmsTemplate = {
|
||||
name: 'default',
|
||||
pages: {
|
||||
home: DefaultHomePage,
|
||||
},
|
||||
}
|
||||
|
||||
export default defaultTemplate
|
||||
export { default } from '../../../src/templates/default'
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
import type { HomePageTemplateSlots, TemplateRenderProps } from '../src/templates/types'
|
||||
|
||||
export interface CmsTemplate {
|
||||
name: string
|
||||
pages: {
|
||||
home: React.ComponentType
|
||||
[key: string]: React.ComponentType | undefined
|
||||
home: ComponentType<TemplateRenderProps<HomePageTemplateSlots>>
|
||||
[key: string]: ComponentType<any> | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +14,11 @@ export interface CmsTheme {
|
||||
name: string
|
||||
htmlAttributes?: Record<string, string>
|
||||
bodyClassName?: string
|
||||
Provider?: React.ComponentType<{ children: ReactNode }>
|
||||
Provider?: ComponentType<{ children: ReactNode }>
|
||||
}
|
||||
|
||||
export interface CmsExtension {
|
||||
name: string
|
||||
providers?: React.ComponentType<{ children: ReactNode }>[]
|
||||
Layout?: React.ComponentType<{ children: ReactNode }>
|
||||
providers?: ComponentType<{ children: ReactNode }>[]
|
||||
Layout?: ComponentType<{ children: ReactNode }>
|
||||
}
|
||||
|
||||
126
dashboard/src/__tests__/templateRegistry.test.ts
Normal file
126
dashboard/src/__tests__/templateRegistry.test.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import { cmsConfig } from '@cms/config'
|
||||
|
||||
vi.mock('@cms/content/source', () => ({
|
||||
resolveContentSource: () => ({
|
||||
absolutePath: 'cms/content',
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
__resetTemplateRegistryForTests,
|
||||
getActiveTemplate,
|
||||
listRegisteredTemplateNames,
|
||||
registerTemplate,
|
||||
registerTemplateLoader,
|
||||
resolveTemplateName,
|
||||
type TemplateDefinition,
|
||||
} from '../templateRegistry'
|
||||
|
||||
const originalTemplateName = cmsConfig.template
|
||||
const templateFixturesPath = path.join(process.cwd(), 'src', 'templates', 'fs-template')
|
||||
|
||||
const runtimeTemplate: TemplateDefinition = {
|
||||
name: 'runtime',
|
||||
pages: {
|
||||
home: () => null,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetTemplateRegistryForTests()
|
||||
cmsConfig.template = 'default'
|
||||
delete process.env.NEXT_PUBLIC_TEMPLATE
|
||||
delete process.env.CMS_TEMPLATE
|
||||
fs.rmSync(templateFixturesPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cmsConfig.template = originalTemplateName
|
||||
delete process.env.NEXT_PUBLIC_TEMPLATE
|
||||
delete process.env.CMS_TEMPLATE
|
||||
__resetTemplateRegistryForTests()
|
||||
const modulePath = path.join(templateFixturesPath, 'index.js')
|
||||
delete require.cache[modulePath]
|
||||
fs.rmSync(templateFixturesPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('templateRegistry', () => {
|
||||
it('returns the default template from configuration', () => {
|
||||
const template = getActiveTemplate()
|
||||
expect(template.name).toBe('default')
|
||||
})
|
||||
|
||||
it('prefers an explicit template name over configuration', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
const template = getActiveTemplate({ name: 'runtime' })
|
||||
expect(template).toBe(runtimeTemplate)
|
||||
})
|
||||
|
||||
it('allows environment variables to override the template', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
process.env.NEXT_PUBLIC_TEMPLATE = 'runtime'
|
||||
const template = getActiveTemplate()
|
||||
expect(template).toBe(runtimeTemplate)
|
||||
})
|
||||
|
||||
it('throws when config fallback is disabled and name is missing', () => {
|
||||
expect(() => resolveTemplateName(undefined, { fallbackToConfig: false })).toThrowError()
|
||||
})
|
||||
|
||||
it('supports registering custom loaders', () => {
|
||||
const loaderTemplate: TemplateDefinition = {
|
||||
name: 'loader',
|
||||
pages: {
|
||||
home: () => null,
|
||||
},
|
||||
}
|
||||
|
||||
registerTemplateLoader((name) => {
|
||||
return name === 'loader-template' ? loaderTemplate : null
|
||||
})
|
||||
|
||||
const template = getActiveTemplate({ name: 'loader-template', fallbackToConfig: false })
|
||||
expect(template).toBe(loaderTemplate)
|
||||
})
|
||||
|
||||
it('discovers templates from the filesystem', () => {
|
||||
fs.mkdirSync(templateFixturesPath, { recursive: true })
|
||||
const templateModulePath = path.join(templateFixturesPath, 'index.js')
|
||||
fs.writeFileSync(
|
||||
templateModulePath,
|
||||
"module.exports = { name: 'fs-template', pages: { home: () => null } };\n",
|
||||
'utf8',
|
||||
)
|
||||
|
||||
delete require.cache[templateModulePath]
|
||||
expect(fs.existsSync(templateModulePath)).toBe(true)
|
||||
expect(path.join(process.cwd(), 'src', 'templates', 'fs-template', 'index.js')).toBe(templateModulePath)
|
||||
|
||||
cmsConfig.template = 'fs-template'
|
||||
const template = getActiveTemplate()
|
||||
expect(template.name).toBe('fs-template')
|
||||
})
|
||||
|
||||
it('keeps the @cms facade compatible', async () => {
|
||||
const direct = getActiveTemplate()
|
||||
|
||||
vi.stubGlobal('window', undefined)
|
||||
const { getActiveTemplate: legacyGetActiveTemplate } = await import('@cms')
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
const facade = legacyGetActiveTemplate()
|
||||
expect(facade).toEqual(direct)
|
||||
})
|
||||
|
||||
it('lists registered template names including runtime additions', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
const names = listRegisteredTemplateNames()
|
||||
expect(names).toContain('default')
|
||||
expect(names).toContain('runtime')
|
||||
})
|
||||
})
|
||||
147
dashboard/src/templateRegistry.ts
Normal file
147
dashboard/src/templateRegistry.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { cmsConfig } from '@cms/config'
|
||||
import type { CmsTemplate } from '@cms/types'
|
||||
|
||||
import defaultTemplate from './templates/default'
|
||||
import type { TemplateDefinition } from './templates/types'
|
||||
|
||||
const BUILTIN_TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
default: defaultTemplate,
|
||||
}
|
||||
|
||||
const runtimeTemplates = new Map<string, TemplateDefinition>()
|
||||
const templateLoaders: TemplateLoader[] = []
|
||||
|
||||
export type TemplateLoader = (name: string) => TemplateDefinition | null
|
||||
|
||||
const FILE_PREFIX = 'file:'
|
||||
|
||||
registerTemplateLoader(filesystemTemplateLoader)
|
||||
|
||||
export interface TemplateSelectionOptions {
|
||||
name?: string
|
||||
/**
|
||||
* Override template name from configuration and environment.
|
||||
*/
|
||||
fallbackToConfig?: boolean
|
||||
}
|
||||
|
||||
export function registerTemplate(name: string, template: TemplateDefinition) {
|
||||
runtimeTemplates.set(name, template)
|
||||
}
|
||||
|
||||
export function registerTemplateLoader(loader: TemplateLoader) {
|
||||
templateLoaders.push(loader)
|
||||
}
|
||||
|
||||
export function listRegisteredTemplateNames(): string[] {
|
||||
return Array.from(new Set([...Object.keys(BUILTIN_TEMPLATES), ...runtimeTemplates.keys()]))
|
||||
}
|
||||
|
||||
export function getTemplate(name: string): CmsTemplate {
|
||||
if (runtimeTemplates.has(name)) {
|
||||
return runtimeTemplates.get(name) as CmsTemplate
|
||||
}
|
||||
|
||||
if (BUILTIN_TEMPLATES[name]) {
|
||||
return BUILTIN_TEMPLATES[name] as CmsTemplate
|
||||
}
|
||||
|
||||
const loadedTemplate = tryLoadTemplate(name)
|
||||
if (loadedTemplate) {
|
||||
runtimeTemplates.set(name, loadedTemplate)
|
||||
return loadedTemplate as CmsTemplate
|
||||
}
|
||||
|
||||
throw new Error(`Template \"${name}\" is not registered.`)
|
||||
}
|
||||
|
||||
export function resolveTemplateName(explicitName?: string, options?: TemplateSelectionOptions): string {
|
||||
if (explicitName) {
|
||||
return explicitName
|
||||
}
|
||||
|
||||
const envOverride =
|
||||
typeof process !== 'undefined' &&
|
||||
(process.env.NEXT_PUBLIC_TEMPLATE || process.env.CMS_TEMPLATE)
|
||||
|
||||
if (envOverride) {
|
||||
return envOverride
|
||||
}
|
||||
|
||||
if (options?.fallbackToConfig === false) {
|
||||
throw new Error('Template name not provided and config fallback disabled.')
|
||||
}
|
||||
|
||||
return cmsConfig.template
|
||||
}
|
||||
|
||||
export function getActiveTemplate(options?: TemplateSelectionOptions): CmsTemplate {
|
||||
const name = resolveTemplateName(options?.name, options)
|
||||
return getTemplate(name)
|
||||
}
|
||||
|
||||
function tryLoadTemplate(name: string): TemplateDefinition | null {
|
||||
for (const loader of templateLoaders) {
|
||||
const template = loader(name)
|
||||
if (template) {
|
||||
return template
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function filesystemTemplateLoader(name: string): TemplateDefinition | null {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'test') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!name.startsWith(FILE_PREFIX)) {
|
||||
const basePath = path.join(process.cwd(), 'src', 'templates', name)
|
||||
const candidates = ['index.js', 'index.cjs']
|
||||
for (const candidate of candidates) {
|
||||
const candidatePath = path.join(basePath, candidate)
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return loadTemplateModule(candidatePath)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const request = name.slice(FILE_PREFIX.length)
|
||||
const absolutePath = path.isAbsolute(request) ? request : path.join(process.cwd(), request)
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
return null
|
||||
}
|
||||
return loadTemplateModule(absolutePath)
|
||||
}
|
||||
|
||||
function loadTemplateModule(modulePath: string): TemplateDefinition | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mod = require(modulePath)
|
||||
const template = (mod?.default ?? mod) as TemplateDefinition | undefined
|
||||
if (!template || typeof template !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (!template.pages || typeof template.pages.home !== 'function') {
|
||||
return null
|
||||
}
|
||||
return template
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`Failed to load template module at ${modulePath}:`, error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetTemplateRegistryForTests() {
|
||||
runtimeTemplates.clear()
|
||||
templateLoaders.length = 0
|
||||
registerTemplateLoader(filesystemTemplateLoader)
|
||||
}
|
||||
|
||||
export type { TemplateDefinition } from './templates/types'
|
||||
42
dashboard/src/templates/default/index.tsx
Normal file
42
dashboard/src/templates/default/index.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import ArticleFeed from '@components/home/ArticleFeed'
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
|
||||
import type { HomePageTemplateProps, TemplateDefinition } from '../types'
|
||||
|
||||
function DefaultHomePageTemplate({ slots }: HomePageTemplateProps) {
|
||||
const ProductMatrixComponent = slots.ProductMatrix ?? ProductMatrix
|
||||
const ArticleFeedComponent = slots.ArticleFeed ?? ArticleFeed
|
||||
const SidebarComponent = slots.Sidebar ?? Sidebar
|
||||
|
||||
return (
|
||||
<main className="bg-slate-950">
|
||||
<section className="pb-24 pt-24">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<ProductMatrixComponent />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="bg-slate-50 pb-16 pt-20">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<ArticleFeedComponent />
|
||||
<SidebarComponent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultTemplate: TemplateDefinition = {
|
||||
name: 'default',
|
||||
pages: {
|
||||
home: DefaultHomePageTemplate,
|
||||
},
|
||||
}
|
||||
|
||||
export default defaultTemplate
|
||||
28
dashboard/src/templates/types.ts
Normal file
28
dashboard/src/templates/types.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export type TemplateSlots = Record<string, ComponentType>
|
||||
|
||||
export interface TemplateRenderProps<TSlots extends TemplateSlots = TemplateSlots> {
|
||||
slots: TSlots
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TemplateComponent<TSlots extends TemplateSlots = TemplateSlots> = ComponentType<
|
||||
TemplateRenderProps<TSlots>
|
||||
>
|
||||
|
||||
export interface HomePageTemplateSlots extends TemplateSlots {
|
||||
ProductMatrix: ComponentType
|
||||
ArticleFeed: ComponentType
|
||||
Sidebar: ComponentType
|
||||
}
|
||||
|
||||
export type HomePageTemplateProps = TemplateRenderProps<HomePageTemplateSlots>
|
||||
|
||||
export interface TemplateDefinition {
|
||||
name: string
|
||||
pages: {
|
||||
home: TemplateComponent<HomePageTemplateSlots>
|
||||
[key: string]: ComponentType<any> | undefined
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,9 @@
|
||||
"@i18n/*": ["i18n/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@types/*": ["types/*"],
|
||||
"@server/*": ["server/*"]
|
||||
"@server/*": ["server/*"],
|
||||
"@templates/*": ["src/templates/*"],
|
||||
"@src/*": ["src/*"]
|
||||
},
|
||||
"types": ["node"],
|
||||
"plugins": [{ "name": "next" }]
|
||||
@ -34,6 +36,7 @@
|
||||
"i18n",
|
||||
"lib",
|
||||
"server",
|
||||
"src",
|
||||
"types",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
|
||||
@ -6,7 +6,13 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['app/**/*.test.{ts,tsx}', 'app/**/*.__tests__/*.{ts,tsx}', 'app/**/__tests__/**/*.{ts,tsx}'],
|
||||
include: [
|
||||
'app/**/*.test.{ts,tsx}',
|
||||
'app/**/*.__tests__/*.{ts,tsx}',
|
||||
'app/**/__tests__/**/*.{ts,tsx}',
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/__tests__/**/*.{ts,tsx}',
|
||||
],
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: 'http://localhost',
|
||||
@ -20,6 +26,8 @@ export default defineConfig({
|
||||
'@i18n': path.resolve(__dirname, 'i18n'),
|
||||
'@lib': path.resolve(__dirname, 'lib'),
|
||||
'@types': path.resolve(__dirname, 'types'),
|
||||
'@templates': path.resolve(__dirname, 'src/templates'),
|
||||
'@src': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user