Files

492 lines
13 KiB
TypeScript

import { oneLine } from 'common-tags';
import type {
AdditionalLink,
AuthorData,
BackendClass,
BackendInitializer,
BackendInitializerOptions,
BaseField,
Collection,
Config,
CustomIcon,
Entry,
EntryData,
EventData,
EventListener,
FieldPreviewComponent,
LocalePhrasesRoot,
LoginEventHandler,
LogoutEventHandler,
MountedEventHandler,
ObjectValue,
PostSaveEventHandler,
PreSaveEventHandler,
PreviewStyle,
PreviewStyleOptions,
ShortcodeConfig,
TemplatePreviewCardComponent,
TemplatePreviewComponent,
UnknownField,
Widget,
WidgetOptions,
WidgetParam,
WidgetValueSerializer,
} from '../interface';
export const allowedEvents = ['mounted', 'login', 'logout', 'preSave', 'postSave'] as const;
export type AllowedEvent = (typeof allowedEvents)[number];
type EventHandlerRegistry = {
preSave: { handler: PreSaveEventHandler; options: Record<string, unknown> }[];
postSave: { handler: PostSaveEventHandler; options: Record<string, unknown> }[];
mounted: { handler: MountedEventHandler; options: Record<string, unknown> }[];
login: { handler: LoginEventHandler; options: Record<string, unknown> }[];
logout: { handler: LogoutEventHandler; options: Record<string, unknown> }[];
};
const eventHandlers = allowedEvents.reduce((acc, e) => {
acc[e] = [];
return acc;
}, {} as EventHandlerRegistry);
interface CardPreviews {
component: TemplatePreviewCardComponent<ObjectValue>;
getHeight?: (data: { collection: Collection; entry: Entry }) => number;
}
interface Registry {
backends: Record<string, BackendInitializer>;
templates: Record<string, TemplatePreviewComponent<ObjectValue>>;
cards: Record<string, CardPreviews>;
fieldPreviews: Record<string, Record<string, FieldPreviewComponent>>;
widgets: Record<string, Widget>;
icons: Record<string, CustomIcon>;
additionalLinks: Record<string, AdditionalLink>;
widgetValueSerializers: Record<string, WidgetValueSerializer>;
locales: Record<string, LocalePhrasesRoot>;
eventHandlers: EventHandlerRegistry;
previewStyles: PreviewStyle[];
/** Markdown editor */
shortcodes: Record<string, ShortcodeConfig>;
}
/**
* Global Registry Object
*/
const registry: Registry = {
backends: {},
templates: {},
cards: {},
fieldPreviews: {},
widgets: {},
icons: {},
additionalLinks: {},
widgetValueSerializers: {},
locales: {},
eventHandlers,
previewStyles: [],
shortcodes: {},
};
export default {
registerPreviewTemplate,
getPreviewTemplate,
registerPreviewCard,
getPreviewCard,
registerFieldPreview,
getFieldPreview,
registerWidget,
getWidget,
getWidgets,
resolveWidget,
registerWidgetValueSerializer,
getWidgetValueSerializer,
registerBackend,
getBackend,
registerLocale,
getLocale,
registerEventListener,
removeEventListener,
getEventListeners,
invokeEvent,
registerIcon,
getIcon,
registerAdditionalLink,
getAdditionalLinks,
registerPreviewStyle,
getPreviewStyles,
registerShortcode,
getShortcode,
getShortcodes,
};
/**
* Preview Styles
*
* Valid options:
* - raw {boolean} if `true`, `style` value is expected to be a CSS string
*/
export function registerPreviewStyle(style: string, { raw = false }: PreviewStyleOptions = {}) {
registry.previewStyles.push({ value: style, raw });
}
export function getPreviewStyles() {
return registry.previewStyles;
}
/**
* Preview Templates
*/
export function registerPreviewTemplate<T, EF extends BaseField = UnknownField>(
name: string,
component: TemplatePreviewComponent<T, EF>,
) {
registry.templates[name] = component as TemplatePreviewComponent<ObjectValue>;
}
export function getPreviewTemplate(name: string): TemplatePreviewComponent<ObjectValue> | null {
return registry.templates[name] ?? null;
}
/**
* Preview Cards
*/
export function registerPreviewCard<T, EF extends BaseField = UnknownField>(
name: string,
component: TemplatePreviewCardComponent<T, EF>,
getHeight?: () => number,
) {
registry.cards[name] = {
component: component as TemplatePreviewCardComponent<ObjectValue>,
getHeight,
};
}
export function getPreviewCard(name: string): CardPreviews | null {
return registry.cards[name] ?? null;
}
/**
* Field Previews
*/
export function registerFieldPreview<T, F extends BaseField = UnknownField>(
collectionName: string,
fieldName: string,
component: FieldPreviewComponent<T, F>,
) {
if (!(collectionName in registry.fieldPreviews)) {
registry.fieldPreviews[collectionName] = {};
}
registry.fieldPreviews[collectionName][fieldName] = component as FieldPreviewComponent;
}
export function getFieldPreview(
collectionName: string,
fieldName: string,
): FieldPreviewComponent | null {
return registry.fieldPreviews[collectionName]?.[fieldName] ?? null;
}
/**
* Editor Widgets
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerWidget(widgets: WidgetParam<any, any>[]): void;
export function registerWidget(widget: WidgetParam): void;
export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
name: string,
control: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'],
options?: WidgetOptions<T, F>,
): void;
export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
nameOrWidgetOrWidgets: string | WidgetParam<T, F> | WidgetParam[],
control?: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'],
{
schema,
validator = () => false,
getValidValue = (value: T | null | undefined) => value,
getDefaultValue,
}: WidgetOptions<T, F> = {},
): void {
if (Array.isArray(nameOrWidgetOrWidgets)) {
nameOrWidgetOrWidgets.forEach(widget => {
if (typeof widget !== 'object') {
console.error(`Cannot register widget: ${widget}`);
} else {
registerWidget(widget);
}
});
} else if (typeof nameOrWidgetOrWidgets === 'string') {
// A registered widget control can be reused by a new widget, allowing
// multiple copies with different previews.
const newControl = (
typeof control === 'string' ? registry.widgets[control]?.control : control
) as Widget['control'];
if (newControl) {
registry.widgets[nameOrWidgetOrWidgets] = {
control: newControl,
preview: preview as Widget['preview'],
validator: validator as Widget['validator'],
getValidValue: getValidValue as Widget['getValidValue'],
getDefaultValue: getDefaultValue as Widget['getDefaultValue'],
schema,
};
}
} else if (typeof nameOrWidgetOrWidgets === 'object') {
const {
name: widgetName,
controlComponent: control,
previewComponent: preview,
options: {
validator = () => false,
getValidValue = (value: T | undefined | null) => value,
getDefaultValue,
schema,
} = {},
} = nameOrWidgetOrWidgets;
if (registry.widgets[widgetName]) {
console.warn(oneLine`
[StaticCMS] Multiple widgets registered with name "${widgetName}". Only the last widget registered with
this name will be used.
`);
}
if (!control) {
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
}
registry.widgets[widgetName] = {
control,
preview,
validator,
getValidValue,
getDefaultValue,
schema,
} as unknown as Widget;
} else {
console.error('`registerWidget` failed, called with incorrect arguments.');
}
}
export function getWidget<T = unknown, EF extends BaseField = UnknownField>(
name: string,
): Widget<T, EF> {
return registry.widgets[name] as unknown as Widget<T, EF>;
}
export function getWidgets(): ({
name: string;
} & Widget<unknown>)[] {
return Object.entries(registry.widgets).map(([name, widget]: [string, Widget<unknown>]) => ({
name,
...widget,
}));
}
export function resolveWidget<T = unknown, EF extends BaseField = UnknownField>(
name?: string,
): Widget<T, EF> {
return getWidget(name || 'string') || getWidget('unknown');
}
/**
* Widget Serializers
*/
export function registerWidgetValueSerializer(
widgetName: string,
serializer: WidgetValueSerializer,
) {
registry.widgetValueSerializers[widgetName] = serializer;
}
export function getWidgetValueSerializer(widgetName: string): WidgetValueSerializer | undefined {
return registry.widgetValueSerializers[widgetName];
}
/**
* Backends
*/
export function registerBackend<
T extends { new (config: Config, options: BackendInitializerOptions): BackendClass },
>(name: string, BackendClass: T) {
if (!name || !BackendClass) {
console.error(
"Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)",
);
} else if (registry.backends[name]) {
console.error(`Backend [${name}] already registered. Please choose a different name.`);
} else {
registry.backends[name] = {
init: (config: Config, options: BackendInitializerOptions) =>
new BackendClass(config, options),
};
}
}
export function getBackend<EF extends BaseField = UnknownField>(
name: string,
): BackendInitializer<EF> {
return registry.backends[name] as unknown as BackendInitializer<EF>;
}
/**
* Event Handlers
*/
function validateEventName(name: AllowedEvent) {
if (!allowedEvents.includes(name)) {
throw new Error(`Invalid event name '${name}'`);
}
}
export function getEventListeners(name: AllowedEvent) {
validateEventName(name);
return [...registry.eventHandlers[name]];
}
export function registerEventListener<
E extends AllowedEvent,
O extends Record<string, unknown> = Record<string, unknown>,
>({ name, handler }: EventListener<E, O>, options?: O) {
validateEventName(name);
registry.eventHandlers[name].push({
handler: handler as MountedEventHandler &
LoginEventHandler &
PreSaveEventHandler &
PostSaveEventHandler,
options: options ?? {},
});
}
export async function invokeEvent(name: 'login', data: AuthorData): Promise<void>;
export async function invokeEvent(name: 'logout'): Promise<void>;
export async function invokeEvent(name: 'preSave', data: EventData): Promise<EntryData>;
export async function invokeEvent(name: 'postSave', data: EventData): Promise<void>;
export async function invokeEvent(name: 'mounted'): Promise<void>;
export async function invokeEvent(
name: AllowedEvent,
data?: EventData | AuthorData,
): Promise<void | EntryData> {
validateEventName(name);
if (name === 'mounted' || name === 'logout') {
console.info(`[StaticCMS] Firing ${name} event`);
const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) {
handler(options);
}
return;
}
if (name === 'login') {
console.info('[StaticCMS] Firing login event', data);
const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) {
handler(data as AuthorData, options);
}
return;
}
if (name === 'postSave') {
console.info(`[StaticCMS] Firing post save event`, data);
const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) {
handler(data as EventData, options);
}
return;
}
const handlers = registry.eventHandlers[name];
console.info(`[StaticCMS] Firing pre save event`, data);
let _data = { ...(data as EventData) };
for (const { handler, options } of handlers) {
const result = await handler(_data, options);
if (_data !== undefined && result !== undefined) {
const entry = {
..._data.entry,
data: result,
} as Entry;
_data = { ..._data, entry };
}
}
return _data.entry.data;
}
export function removeEventListener({ name, handler }: EventListener) {
validateEventName(name);
if (handler) {
registry.eventHandlers[name] = registry.eventHandlers[name].filter(
item => item.handler !== handler,
);
} else {
registry.eventHandlers[name] = [];
}
}
/**
* Locales
*/
export function registerLocale(locale: string, phrases: LocalePhrasesRoot) {
if (!locale || !phrases) {
console.error("Locale parameters invalid. example: CMS.registerLocale('locale', phrases)");
} else {
registry.locales[locale] = phrases;
}
}
export function getLocale(locale: string): LocalePhrasesRoot | undefined {
return registry.locales[locale];
}
/**
* Icons
*/
export function registerIcon(name: string, icon: CustomIcon) {
registry.icons[name] = icon;
}
export function getIcon(name: string): CustomIcon | null {
return registry.icons[name] ?? null;
}
/**
* Additional Links
*/
export function registerAdditionalLink(link: AdditionalLink) {
registry.additionalLinks[link.id] = link;
}
export function getAdditionalLinks(): Record<string, AdditionalLink> {
return registry.additionalLinks;
}
export function getAdditionalLink(id: string): AdditionalLink | undefined {
return registry.additionalLinks[id];
}
/**
* Markdown editor shortcodes
*/
export function registerShortcode<P = {}>(name: string, config: ShortcodeConfig<P>) {
if (registry.backends[name]) {
console.error(`Shortcode [${name}] already registered. Please choose a different name.`);
return;
}
registry.shortcodes[name] = config as unknown as ShortcodeConfig;
}
export function getShortcode(name: string): ShortcodeConfig {
return registry.shortcodes[name];
}
export function getShortcodes(): Record<string, ShortcodeConfig> {
return registry.shortcodes;
}