Skip to content

Commit

Permalink
Improve error for invalid content field (#1365)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown authored Nov 28, 2024
1 parent 46d9c55 commit c65f48b
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 64 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-trainers-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Improve error for invalid content field
61 changes: 61 additions & 0 deletions packages/keystatic/src/app/memoize.ts
Original file line number Diff line number Diff line change
@@ -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<StrongKey, MemoizeCacheNode> | undefined;
weak: WeakMap<WeakKey, MemoizeCacheNode> | 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<Args extends readonly (WeakKey | StrongKey)[], Return>(
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;
};
}
109 changes: 45 additions & 64 deletions packages/keystatic/src/app/path-utils.ts
Original file line number Diff line number Diff line change
@@ -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(/\/*$/, '');
Expand All @@ -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(
Expand Down Expand Up @@ -88,46 +83,19 @@ export function getDataFileExtension(formatInfo: FormatInfo) {
: '.' + formatInfo.data;
}

function weakMemoize<Arg extends object, Return>(
func: (arg: Arg) => Return
): (arg: Arg) => Return {
const cache = new WeakMap<Arg, Return>();
return (arg: Arg) => {
if (cache.has(arg)) {
return cache.get(arg)!;
}
const result = func(arg);
cache.set(arg, result);
return result;
};
}

function memoize<Arg, Return>(
func: (arg: Arg) => Return
): (arg: Arg) => Return {
const cache = new Map<Arg, Return>();
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<any, any> | Singleton<any>) => {
return memoize((path: string) =>
_getFormatInfo(collectionOrSingleton, path)
);
}
);
const getFormatInfo = memoize(_getFormatInfo);

function _getFormatInfo(
collectionOrSingleton: Collection<any, any> | Singleton<any>,
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') {
Expand All @@ -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',
Expand All @@ -158,29 +131,37 @@ function _getFormatInfo(
};
}

class ContentFieldLocationError extends Error {
constructor(message: string) {
super(message);
}
}

function getContentExtension(
path: string[],
schema: ComponentSchema,
debugName: () => string
): 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()})`
);
}
Expand All @@ -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`
);
}
Expand Down

0 comments on commit c65f48b

Please sign in to comment.