feat(dashboard): add template registry (#539)

This commit is contained in:
shenlan 2025-10-17 13:53:13 +08:00 committed by GitHub
parent 255dc84492
commit ebb10741fe
11 changed files with 390 additions and 56 deletions

View File

@ -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,
}}
/>
)
}

View File

@ -1,4 +1,4 @@
export type TemplateName = 'default'
export type TemplateName = 'default' | (string & {})
export type ThemeName = 'default'
export type ExtensionName = 'app-shell' | 'markdown-sync'

View File

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

View File

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

View File

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

View 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')
})
})

View 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'

View 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

View 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
}
}

View File

@ -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"
],

View File

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