Skip to content

Commit

Permalink
Add fields.emptyContent and allow specifying a contentField neste…
Browse files Browse the repository at this point in the history
…d inside object or conditional fields (#1172)
  • Loading branch information
emmatown authored Jun 7, 2024
1 parent a129ea4 commit 73aa1b8
Show file tree
Hide file tree
Showing 18 changed files with 293 additions and 53 deletions.
6 changes: 6 additions & 0 deletions .changeset/gentle-avocados-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@keystatic/core': patch
---

Add `fields.emptyContent` field to replace `fields.emptyDocument` and support
extensions besides `mdoc`
5 changes: 5 additions & 0 deletions .changeset/tasty-drinks-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Allow passing an array to `contentField` to specify a `contentField` nested inside an object or conditional field
21 changes: 21 additions & 0 deletions dev-projects/next-app/keystatic.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,27 @@ export default config({
}),
},
}),
conditionalContent: collection({
label: 'Conditional Content',
path: 'conditionalContent/**',
slugField: 'title',
format: { contentField: ['content', 'value', 'content'] },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
content: fields.conditional(
fields.checkbox({ label: 'With content' }),
{
true: fields.object({
summary: fields.text({ label: 'Summary' }),
content: fields.markdoc({ label: 'Content' }),
}),
false: fields.object({
content: fields.emptyContent({ extension: 'mdoc' }),
}),
}
),
},
}),
},
singletons: {
settings: singleton({
Expand Down
7 changes: 6 additions & 1 deletion docs/src/content/navigation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,16 @@ navGroups:
discriminant: page
value: fields/empty
status: default
- label: Empty Content
link:
discriminant: page
value: fields/empty-content
status: default
- label: Empty Document
link:
discriminant: page
value: fields/empty-document
status: default
status: deprecated
- label: File
link:
discriminant: page
Expand Down
22 changes: 22 additions & 0 deletions docs/src/content/pages/fields/empty-content.mdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: Empty Content field
summary: >-
The empty content field is used to force a formats for entries without a
standard content field.
---
The `emptyContent` is a mechanism to trigger a collection or singleton to output `.mdoc`/`.mdx`/`.md` files even if there is no real `markdoc` or `mdx` field in the schema.

Use this in conjunction with the [`format.contentField`](/docs/format-options#example-with-single-file-output) option.

## Usage example

```typescript
schema: {
emptyContent: fields.emptyContent({ extension: 'mdoc' })
},
format: {
contentField: 'emptyContent'
}
```

Instead of generating `.yaml` or `.json` files, the collection or singleton will output `.mdoc`/`.mdx`/`.md` files with frontmatter data and an empty content body.
55 changes: 40 additions & 15 deletions packages/keystatic/src/app/entry-form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Grid } from '@keystar/ui/layout';
import { Box } from '@keystar/ui/layout';
import {
SplitView,
SplitPanePrimary,
Expand Down Expand Up @@ -55,6 +55,16 @@ export function ResetEntryLayoutContext(props: { children: ReactNode }) {
);
}

function isPreviewPropsKind<Kind extends ComponentSchema['kind']>(
props: GenericPreviewProps<ComponentSchema, unknown>,
kind: Kind
): props is GenericPreviewProps<
Extract<ComponentSchema, { kind: Kind }>,
unknown
> {
return props.schema.kind === kind;
}

export function FormForEntry({
formatInfo,
forceValidation,
Expand All @@ -75,6 +85,26 @@ export function FormForEntry({

if (entryLayout === 'content' && formatInfo.contentField && isAboveMobile) {
const { contentField } = formatInfo;
let contentFieldProps: GenericPreviewProps<ComponentSchema, unknown> =
props;
for (const key of contentField.path) {
if (isPreviewPropsKind(contentFieldProps, 'object')) {
contentFieldProps = contentFieldProps.fields[key];
continue;
}
if (isPreviewPropsKind(contentFieldProps, 'conditional')) {
if (key !== 'value') {
throw new Error(
'Conditional fields referenced in a contentField path must only reference the value field.'
);
}
contentFieldProps = contentFieldProps.value;
continue;
}
throw new Error(
`Path specified in contentField does not point to a content field`
);
}
return (
<PathContextProvider value={emptyArray}>
<SlugFieldProvider value={slugField}>
Expand All @@ -88,10 +118,10 @@ export function FormForEntry({
<SplitPaneSecondary>
<EntryLayoutSplitPaneContext.Provider value="main">
<ScrollView>
<AddToPathProvider part={contentField.key}>
<AddToPathProvider part={contentField.path}>
<InnerFormValueContentFromPreviewProps
forceValidation={forceValidation}
{...props.fields[contentField.key]}
{...contentFieldProps}
/>
</AddToPathProvider>
</ScrollView>
Expand All @@ -100,18 +130,13 @@ export function FormForEntry({
<SplitPanePrimary>
<EntryLayoutSplitPaneContext.Provider value="side">
<ScrollView>
<Grid gap="xlarge" padding={RESPONSIVE_PADDING}>
{Object.entries(props.fields).map(([key, propVal]) =>
key === contentField.key ? null : (
<AddToPathProvider key={key} part={key}>
<InnerFormValueContentFromPreviewProps
forceValidation={forceValidation}
{...propVal}
/>
</AddToPathProvider>
)
)}
</Grid>
<Box padding={RESPONSIVE_PADDING}>
<InnerFormValueContentFromPreviewProps
forceValidation={forceValidation}
omitFieldAtPath={contentField.path}
{...props}
/>
</Box>
</ScrollView>
</EntryLayoutSplitPaneContext.Provider>
</SplitPanePrimary>
Expand Down
137 changes: 111 additions & 26 deletions packages/keystatic/src/app/path-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { assert } from 'emery';
import { Config, DataFormat, Format, Glob } from '../config';
import { ComponentSchema, ContentFormField } from '../form/api';
import { Collection, Config, DataFormat, Glob, Singleton } from '../config';
import { ComponentSchema } from '../form/api';

export function fixPath(path: string) {
return path.replace(/^\.?\/+/, '').replace(/\/*$/, '');
Expand All @@ -27,18 +26,14 @@ export function getCollectionPath(config: Config, collection: string) {

export function getCollectionFormat(config: Config, collection: string) {
const collectionConfig = config.collections![collection];
return getFormatInfo(
collectionConfig.format ?? 'yaml',
collectionConfig.schema,
return getFormatInfo(collectionConfig)(
getConfiguredCollectionPath(config, collection)
);
}

export function getSingletonFormat(config: Config, singleton: string) {
const singletonConfig = config.singletons![singleton];
return getFormatInfo(
singletonConfig.format ?? 'yaml',
singletonConfig.schema,
return getFormatInfo(singletonConfig)(
singletonConfig.path ?? `${singleton}/`
);
}
Expand Down Expand Up @@ -89,16 +84,52 @@ export function getSingletonPath(config: Config, singleton: string) {

export function getDataFileExtension(formatInfo: FormatInfo) {
return formatInfo.contentField
? formatInfo.contentField.config.contentExtension
? formatInfo.contentField.contentExtension
: '.' + formatInfo.data;
}

function getFormatInfo(
format: Format,
schema: Record<string, ComponentSchema>,
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)
);
}
);

function _getFormatInfo(
collectionOrSingleton: Collection<any, any> | Singleton<any>,
path: string
): FormatInfo {
const dataLocation = path.endsWith('/') ? 'index' : 'outer';
const { schema, format = 'yaml' } = collectionOrSingleton;
if (typeof format === 'string') {
return {
dataLocation,
Expand All @@ -108,18 +139,16 @@ function getFormatInfo(
}
let contentField;
if (format.contentField) {
const field = schema[format.contentField];
assert(
field?.kind === 'form',
`${format.contentField} is not a form field`
);
assert(
field.formKind === 'content',
`${format.contentField} is not a content field`
);
let field: ComponentSchema = { kind: 'object' as const, fields: schema };
let path = Array.isArray(format.contentField)
? format.contentField
: [format.contentField];

contentField = {
key: format.contentField,
config: field as ContentFormField<any, any, any>,
path,
contentExtension: getContentExtension(path, field, () =>
path.length === 1 ? path[0] : JSON.stringify(path)
),
};
}
return {
Expand All @@ -129,12 +158,68 @@ function getFormatInfo(
};
}

function getContentExtension(
path: string[],
schema: ComponentSchema,
debugName: () => string
): string {
if (path.length === 0) {
if (schema.kind !== 'form' || schema.formKind !== 'content') {
throw new Error(
`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
);
}
if (schema.kind === 'conditional') {
if (path[0] !== 'value') {
throw new Error(
`Conditional fields referenced in a contentField path must only reference the value field (${debugName()})`
);
}
let contentExtension;
const innerPath = path.slice(1);
for (const value of Object.values(schema.values)) {
const foundContentExtension = getContentExtension(
innerPath,
value,
debugName
);
if (!contentExtension) {
contentExtension = foundContentExtension;
continue;
}
if (contentExtension !== foundContentExtension) {
throw new Error(
`contentField ${debugName()} has conflicting content extensions`
);
}
}
if (!contentExtension) {
throw new Error(
`contentField ${debugName()} does not point to a content field`
);
}
return contentExtension;
}
throw new Error(
`Path specified in contentField ${debugName()} does not point to a content field`
);
}

export type FormatInfo = {
data: DataFormat;
contentField:
| {
key: string;
config: ContentFormField<any, any, any>;
path: string[];
contentExtension: string;
}
| undefined;
dataLocation: 'index' | 'outer';
Expand Down
4 changes: 3 additions & 1 deletion packages/keystatic/src/app/required-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export function loadDataFile(
return {
loaded: res === null ? {} : parse(res.frontmatter),
extraFakeFile: {
path: `${formatInfo.contentField.key}${formatInfo.contentField.config.contentExtension}`,
path: `${formatInfo.contentField.path.join('/')}${
formatInfo.contentField.contentExtension
}`,
contents: res === null ? data : res.content,
},
};
Expand Down
3 changes: 1 addition & 2 deletions packages/keystatic/src/app/shell/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,7 @@ function useIsCurrent() {
if (exact) {
return href === router.pathname ? 'page' : undefined;
}
return href === router.pathname ||
router.pathname.startsWith(`${router.pathname}/`)
return href === router.pathname || router.pathname.startsWith(`${href}/`)
? 'page'
: undefined;
},
Expand Down
4 changes: 3 additions & 1 deletion packages/keystatic/src/app/updating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ export function serializeEntryToFiles(args: {
);

if (args.format.contentField) {
const filename = `${args.format.contentField.key}${args.format.contentField.config.contentExtension}`;
const filename = `${args.format.contentField.path.join('/')}${
args.format.contentField.contentExtension
}`;
let contents: undefined | Uint8Array;
extraFiles = extraFiles.filter(x => {
if (x.path !== filename) return true;
Expand Down
Loading

0 comments on commit 73aa1b8

Please sign in to comment.