diff --git a/.changeset/fast-trainers-watch.md b/.changeset/fast-trainers-watch.md new file mode 100644 index 000000000..0239873d8 --- /dev/null +++ b/.changeset/fast-trainers-watch.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Improve error for invalid content field diff --git a/packages/keystatic/src/app/memoize.ts b/packages/keystatic/src/app/memoize.ts new file mode 100644 index 000000000..251a2e5f4 --- /dev/null +++ b/packages/keystatic/src/app/memoize.ts @@ -0,0 +1,61 @@ +// these are intentionally more restrictive than the types allowed by strong and weak maps +type StrongKey = string | number; +type WeakKey = object; + +type MemoizeCacheNode = { + value: unknown; + strong: Map | undefined; + weak: WeakMap | undefined; +}; + +const emptyCacheNode = Symbol('emptyCacheNode'); + +// weak keys should always come before strong keys in the arguments though that cannot be enforced with types +export function memoize( + func: (...args: Args) => Return +): (...args: Args) => Return { + const cacheNode: MemoizeCacheNode = { + value: emptyCacheNode, + strong: undefined, + weak: undefined, + }; + return (...args: Args): Return => { + let currentCacheNode = cacheNode; + for (const arg of args) { + if (typeof arg === 'string' || typeof arg === 'number') { + if (currentCacheNode.strong === undefined) { + currentCacheNode.strong = new Map(); + } + if (!currentCacheNode.strong.has(arg)) { + currentCacheNode.strong.set(arg, { + value: emptyCacheNode, + strong: undefined, + weak: undefined, + }); + } + currentCacheNode = currentCacheNode.strong.get(arg)!; + continue; + } + if (typeof arg === 'object') { + if (currentCacheNode.weak === undefined) { + currentCacheNode.weak = new WeakMap(); + } + if (!currentCacheNode.weak.has(arg)) { + currentCacheNode.weak.set(arg, { + value: emptyCacheNode, + strong: undefined, + weak: undefined, + }); + } + currentCacheNode = currentCacheNode.weak.get(arg)!; + continue; + } + } + if (currentCacheNode.value !== emptyCacheNode) { + return currentCacheNode.value as Return; + } + const result = func(...args); + currentCacheNode.value = result; + return result; + }; +} diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 021859e38..97fb22caa 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -1,5 +1,6 @@ -import { Collection, Config, DataFormat, Glob, Singleton } from '../config'; +import { Config, DataFormat, Glob } from '../config'; import { ComponentSchema } from '../form/api'; +import { memoize } from './memoize'; export function fixPath(path: string) { return path.replace(/^\.?\/+/, '').replace(/\/*$/, ''); @@ -25,17 +26,11 @@ export function getCollectionPath(config: Config, collection: string) { } export function getCollectionFormat(config: Config, collection: string) { - const collectionConfig = config.collections![collection]; - return getFormatInfo(collectionConfig)( - getConfiguredCollectionPath(config, collection) - ); + return getFormatInfo(config, 'collections', collection); } export function getSingletonFormat(config: Config, singleton: string) { - const singletonConfig = config.singletons![singleton]; - return getFormatInfo(singletonConfig)( - singletonConfig.path ?? `${singleton}/` - ); + return getFormatInfo(config, 'singletons', singleton); } export function getCollectionItemPath( @@ -88,46 +83,19 @@ export function getDataFileExtension(formatInfo: FormatInfo) { : '.' + formatInfo.data; } -function weakMemoize( - func: (arg: Arg) => Return -): (arg: Arg) => Return { - const cache = new WeakMap(); - return (arg: Arg) => { - if (cache.has(arg)) { - return cache.get(arg)!; - } - const result = func(arg); - cache.set(arg, result); - return result; - }; -} - -function memoize( - func: (arg: Arg) => Return -): (arg: Arg) => Return { - const cache = new Map(); - return (arg: Arg) => { - if (cache.has(arg)) { - return cache.get(arg)!; - } - const result = func(arg); - cache.set(arg, result); - return result; - }; -} - -const getFormatInfo = weakMemoize( - (collectionOrSingleton: Collection | Singleton) => { - return memoize((path: string) => - _getFormatInfo(collectionOrSingleton, path) - ); - } -); +const getFormatInfo = memoize(_getFormatInfo); function _getFormatInfo( - collectionOrSingleton: Collection | Singleton, - path: string + config: Config, + type: 'collections' | 'singletons', + key: string ): FormatInfo { + const collectionOrSingleton = + type === 'collections' ? config.collections![key] : config.singletons![key]; + const path = + type === 'collections' + ? getConfiguredCollectionPath(config, key) + : collectionOrSingleton.path ?? `${key}/`; const dataLocation = path.endsWith('/') ? 'index' : 'outer'; const { schema, format = 'yaml' } = collectionOrSingleton; if (typeof format === 'string') { @@ -137,19 +105,24 @@ function _getFormatInfo( data: format, }; } - let contentField; + let contentField: FormatInfo['contentField']; if (format.contentField) { let field: ComponentSchema = { kind: 'object' as const, fields: schema }; let path = Array.isArray(format.contentField) ? format.contentField : [format.contentField]; - - contentField = { - path, - contentExtension: getContentExtension(path, field, () => - path.length === 1 ? path[0] : JSON.stringify(path) - ), - }; + let contentExtension; + try { + contentExtension = getContentExtension(path, field, () => + JSON.stringify(format.contentField) + ); + } catch (err) { + if (err instanceof ContentFieldLocationError) { + throw new Error(`${err.message} (${type}.${key})`); + } + throw err; + } + contentField = { path, contentExtension }; } return { data: format.data ?? 'yaml', @@ -158,6 +131,12 @@ function _getFormatInfo( }; } +class ContentFieldLocationError extends Error { + constructor(message: string) { + super(message); + } +} + function getContentExtension( path: string[], schema: ComponentSchema, @@ -165,22 +144,24 @@ function getContentExtension( ): string { if (path.length === 0) { if (schema.kind !== 'form' || schema.formKind !== 'content') { - throw new Error( + throw new ContentFieldLocationError( `Content field for ${debugName()} is not a content field` ); } return schema.contentExtension; } if (schema.kind === 'object') { - return getContentExtension( - path.slice(1), - schema.fields[path[0]], - debugName - ); + const field = schema.fields[path[0]]; + if (!field) { + throw new ContentFieldLocationError( + `Field ${debugName()} specified in contentField does not exist` + ); + } + return getContentExtension(path.slice(1), field, debugName); } if (schema.kind === 'conditional') { if (path[0] !== 'value') { - throw new Error( + throw new ContentFieldLocationError( `Conditional fields referenced in a contentField path must only reference the value field (${debugName()})` ); } @@ -197,19 +178,19 @@ function getContentExtension( continue; } if (contentExtension !== foundContentExtension) { - throw new Error( + throw new ContentFieldLocationError( `contentField ${debugName()} has conflicting content extensions` ); } } if (!contentExtension) { - throw new Error( + throw new ContentFieldLocationError( `contentField ${debugName()} does not point to a content field` ); } return contentExtension; } - throw new Error( + throw new ContentFieldLocationError( `Path specified in contentField ${debugName()} does not point to a content field` ); }