Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fields.emptyContent and allow specifying a contentField nested inside object or conditional fields #1172

Merged
merged 2 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading