Skip to content

Commit c65f48b

Browse files
authored
Improve error for invalid content field (#1365)
1 parent 46d9c55 commit c65f48b

File tree

3 files changed

+111
-64
lines changed

3 files changed

+111
-64
lines changed

.changeset/fast-trainers-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@keystatic/core': patch
3+
---
4+
5+
Improve error for invalid content field
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// these are intentionally more restrictive than the types allowed by strong and weak maps
2+
type StrongKey = string | number;
3+
type WeakKey = object;
4+
5+
type MemoizeCacheNode = {
6+
value: unknown;
7+
strong: Map<StrongKey, MemoizeCacheNode> | undefined;
8+
weak: WeakMap<WeakKey, MemoizeCacheNode> | undefined;
9+
};
10+
11+
const emptyCacheNode = Symbol('emptyCacheNode');
12+
13+
// weak keys should always come before strong keys in the arguments though that cannot be enforced with types
14+
export function memoize<Args extends readonly (WeakKey | StrongKey)[], Return>(
15+
func: (...args: Args) => Return
16+
): (...args: Args) => Return {
17+
const cacheNode: MemoizeCacheNode = {
18+
value: emptyCacheNode,
19+
strong: undefined,
20+
weak: undefined,
21+
};
22+
return (...args: Args): Return => {
23+
let currentCacheNode = cacheNode;
24+
for (const arg of args) {
25+
if (typeof arg === 'string' || typeof arg === 'number') {
26+
if (currentCacheNode.strong === undefined) {
27+
currentCacheNode.strong = new Map();
28+
}
29+
if (!currentCacheNode.strong.has(arg)) {
30+
currentCacheNode.strong.set(arg, {
31+
value: emptyCacheNode,
32+
strong: undefined,
33+
weak: undefined,
34+
});
35+
}
36+
currentCacheNode = currentCacheNode.strong.get(arg)!;
37+
continue;
38+
}
39+
if (typeof arg === 'object') {
40+
if (currentCacheNode.weak === undefined) {
41+
currentCacheNode.weak = new WeakMap();
42+
}
43+
if (!currentCacheNode.weak.has(arg)) {
44+
currentCacheNode.weak.set(arg, {
45+
value: emptyCacheNode,
46+
strong: undefined,
47+
weak: undefined,
48+
});
49+
}
50+
currentCacheNode = currentCacheNode.weak.get(arg)!;
51+
continue;
52+
}
53+
}
54+
if (currentCacheNode.value !== emptyCacheNode) {
55+
return currentCacheNode.value as Return;
56+
}
57+
const result = func(...args);
58+
currentCacheNode.value = result;
59+
return result;
60+
};
61+
}

packages/keystatic/src/app/path-utils.ts

Lines changed: 45 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Collection, Config, DataFormat, Glob, Singleton } from '../config';
1+
import { Config, DataFormat, Glob } from '../config';
22
import { ComponentSchema } from '../form/api';
3+
import { memoize } from './memoize';
34

45
export function fixPath(path: string) {
56
return path.replace(/^\.?\/+/, '').replace(/\/*$/, '');
@@ -25,17 +26,11 @@ export function getCollectionPath(config: Config, collection: string) {
2526
}
2627

2728
export function getCollectionFormat(config: Config, collection: string) {
28-
const collectionConfig = config.collections![collection];
29-
return getFormatInfo(collectionConfig)(
30-
getConfiguredCollectionPath(config, collection)
31-
);
29+
return getFormatInfo(config, 'collections', collection);
3230
}
3331

3432
export function getSingletonFormat(config: Config, singleton: string) {
35-
const singletonConfig = config.singletons![singleton];
36-
return getFormatInfo(singletonConfig)(
37-
singletonConfig.path ?? `${singleton}/`
38-
);
33+
return getFormatInfo(config, 'singletons', singleton);
3934
}
4035

4136
export function getCollectionItemPath(
@@ -88,46 +83,19 @@ export function getDataFileExtension(formatInfo: FormatInfo) {
8883
: '.' + formatInfo.data;
8984
}
9085

91-
function weakMemoize<Arg extends object, Return>(
92-
func: (arg: Arg) => Return
93-
): (arg: Arg) => Return {
94-
const cache = new WeakMap<Arg, Return>();
95-
return (arg: Arg) => {
96-
if (cache.has(arg)) {
97-
return cache.get(arg)!;
98-
}
99-
const result = func(arg);
100-
cache.set(arg, result);
101-
return result;
102-
};
103-
}
104-
105-
function memoize<Arg, Return>(
106-
func: (arg: Arg) => Return
107-
): (arg: Arg) => Return {
108-
const cache = new Map<Arg, Return>();
109-
return (arg: Arg) => {
110-
if (cache.has(arg)) {
111-
return cache.get(arg)!;
112-
}
113-
const result = func(arg);
114-
cache.set(arg, result);
115-
return result;
116-
};
117-
}
118-
119-
const getFormatInfo = weakMemoize(
120-
(collectionOrSingleton: Collection<any, any> | Singleton<any>) => {
121-
return memoize((path: string) =>
122-
_getFormatInfo(collectionOrSingleton, path)
123-
);
124-
}
125-
);
86+
const getFormatInfo = memoize(_getFormatInfo);
12687

12788
function _getFormatInfo(
128-
collectionOrSingleton: Collection<any, any> | Singleton<any>,
129-
path: string
89+
config: Config,
90+
type: 'collections' | 'singletons',
91+
key: string
13092
): FormatInfo {
93+
const collectionOrSingleton =
94+
type === 'collections' ? config.collections![key] : config.singletons![key];
95+
const path =
96+
type === 'collections'
97+
? getConfiguredCollectionPath(config, key)
98+
: collectionOrSingleton.path ?? `${key}/`;
13199
const dataLocation = path.endsWith('/') ? 'index' : 'outer';
132100
const { schema, format = 'yaml' } = collectionOrSingleton;
133101
if (typeof format === 'string') {
@@ -137,19 +105,24 @@ function _getFormatInfo(
137105
data: format,
138106
};
139107
}
140-
let contentField;
108+
let contentField: FormatInfo['contentField'];
141109
if (format.contentField) {
142110
let field: ComponentSchema = { kind: 'object' as const, fields: schema };
143111
let path = Array.isArray(format.contentField)
144112
? format.contentField
145113
: [format.contentField];
146-
147-
contentField = {
148-
path,
149-
contentExtension: getContentExtension(path, field, () =>
150-
path.length === 1 ? path[0] : JSON.stringify(path)
151-
),
152-
};
114+
let contentExtension;
115+
try {
116+
contentExtension = getContentExtension(path, field, () =>
117+
JSON.stringify(format.contentField)
118+
);
119+
} catch (err) {
120+
if (err instanceof ContentFieldLocationError) {
121+
throw new Error(`${err.message} (${type}.${key})`);
122+
}
123+
throw err;
124+
}
125+
contentField = { path, contentExtension };
153126
}
154127
return {
155128
data: format.data ?? 'yaml',
@@ -158,29 +131,37 @@ function _getFormatInfo(
158131
};
159132
}
160133

134+
class ContentFieldLocationError extends Error {
135+
constructor(message: string) {
136+
super(message);
137+
}
138+
}
139+
161140
function getContentExtension(
162141
path: string[],
163142
schema: ComponentSchema,
164143
debugName: () => string
165144
): string {
166145
if (path.length === 0) {
167146
if (schema.kind !== 'form' || schema.formKind !== 'content') {
168-
throw new Error(
147+
throw new ContentFieldLocationError(
169148
`Content field for ${debugName()} is not a content field`
170149
);
171150
}
172151
return schema.contentExtension;
173152
}
174153
if (schema.kind === 'object') {
175-
return getContentExtension(
176-
path.slice(1),
177-
schema.fields[path[0]],
178-
debugName
179-
);
154+
const field = schema.fields[path[0]];
155+
if (!field) {
156+
throw new ContentFieldLocationError(
157+
`Field ${debugName()} specified in contentField does not exist`
158+
);
159+
}
160+
return getContentExtension(path.slice(1), field, debugName);
180161
}
181162
if (schema.kind === 'conditional') {
182163
if (path[0] !== 'value') {
183-
throw new Error(
164+
throw new ContentFieldLocationError(
184165
`Conditional fields referenced in a contentField path must only reference the value field (${debugName()})`
185166
);
186167
}
@@ -197,19 +178,19 @@ function getContentExtension(
197178
continue;
198179
}
199180
if (contentExtension !== foundContentExtension) {
200-
throw new Error(
181+
throw new ContentFieldLocationError(
201182
`contentField ${debugName()} has conflicting content extensions`
202183
);
203184
}
204185
}
205186
if (!contentExtension) {
206-
throw new Error(
187+
throw new ContentFieldLocationError(
207188
`contentField ${debugName()} does not point to a content field`
208189
);
209190
}
210191
return contentExtension;
211192
}
212-
throw new Error(
193+
throw new ContentFieldLocationError(
213194
`Path specified in contentField ${debugName()} does not point to a content field`
214195
);
215196
}

0 commit comments

Comments
 (0)