diff --git a/.changeset/gentle-avocados-tickle.md b/.changeset/gentle-avocados-tickle.md new file mode 100644 index 000000000..858982440 --- /dev/null +++ b/.changeset/gentle-avocados-tickle.md @@ -0,0 +1,6 @@ +--- +'@keystatic/core': patch +--- + +Add `fields.emptyContent` field to replace `fields.emptyDocument` and support +extensions besides `mdoc` diff --git a/.changeset/tasty-drinks-leave.md b/.changeset/tasty-drinks-leave.md new file mode 100644 index 000000000..2435cc796 --- /dev/null +++ b/.changeset/tasty-drinks-leave.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Allow passing an array to `contentField` to specify a `contentField` nested inside an object or conditional field diff --git a/dev-projects/next-app/keystatic.config.tsx b/dev-projects/next-app/keystatic.config.tsx index e92441eaa..5f1b79c98 100644 --- a/dev-projects/next-app/keystatic.config.tsx +++ b/dev-projects/next-app/keystatic.config.tsx @@ -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({ diff --git a/docs/src/content/navigation.yaml b/docs/src/content/navigation.yaml index 443b904bc..233ad27e9 100644 --- a/docs/src/content/navigation.yaml +++ b/docs/src/content/navigation.yaml @@ -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 diff --git a/docs/src/content/pages/fields/empty-content.mdoc b/docs/src/content/pages/fields/empty-content.mdoc new file mode 100644 index 000000000..50c48fe4f --- /dev/null +++ b/docs/src/content/pages/fields/empty-content.mdoc @@ -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. diff --git a/packages/keystatic/src/app/entry-form.tsx b/packages/keystatic/src/app/entry-form.tsx index 1922cc08a..43461de1f 100644 --- a/packages/keystatic/src/app/entry-form.tsx +++ b/packages/keystatic/src/app/entry-form.tsx @@ -1,4 +1,4 @@ -import { Grid } from '@keystar/ui/layout'; +import { Box } from '@keystar/ui/layout'; import { SplitView, SplitPanePrimary, @@ -55,6 +55,16 @@ export function ResetEntryLayoutContext(props: { children: ReactNode }) { ); } +function isPreviewPropsKind( + props: GenericPreviewProps, + kind: Kind +): props is GenericPreviewProps< + Extract, + unknown +> { + return props.schema.kind === kind; +} + export function FormForEntry({ formatInfo, forceValidation, @@ -75,6 +85,26 @@ export function FormForEntry({ if (entryLayout === 'content' && formatInfo.contentField && isAboveMobile) { const { contentField } = formatInfo; + let contentFieldProps: GenericPreviewProps = + 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 ( @@ -88,10 +118,10 @@ export function FormForEntry({ - + @@ -100,18 +130,13 @@ export function FormForEntry({ - - {Object.entries(props.fields).map(([key, propVal]) => - key === contentField.key ? null : ( - - - - ) - )} - + + + diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 59e7bf21d..021859e38 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -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(/\/*$/, ''); @@ -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}/` ); } @@ -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, +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) + ); + } +); + +function _getFormatInfo( + collectionOrSingleton: Collection | Singleton, path: string ): FormatInfo { const dataLocation = path.endsWith('/') ? 'index' : 'outer'; + const { schema, format = 'yaml' } = collectionOrSingleton; if (typeof format === 'string') { return { dataLocation, @@ -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, + path, + contentExtension: getContentExtension(path, field, () => + path.length === 1 ? path[0] : JSON.stringify(path) + ), }; } return { @@ -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; + path: string[]; + contentExtension: string; } | undefined; dataLocation: 'index' | 'outer'; diff --git a/packages/keystatic/src/app/required-files.ts b/packages/keystatic/src/app/required-files.ts index 2a8b3786d..150db2853 100644 --- a/packages/keystatic/src/app/required-files.ts +++ b/packages/keystatic/src/app/required-files.ts @@ -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, }, }; diff --git a/packages/keystatic/src/app/shell/sidebar/index.tsx b/packages/keystatic/src/app/shell/sidebar/index.tsx index 11c98479b..877152340 100644 --- a/packages/keystatic/src/app/shell/sidebar/index.tsx +++ b/packages/keystatic/src/app/shell/sidebar/index.tsx @@ -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 || href.startsWith(`${router.pathname}/`) ? 'page' : undefined; }, diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 4dd536421..174f39ed1 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -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; diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 70b4d99a1..4c95368f3 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -9,7 +9,12 @@ import { RepoConfig } from './app/repo-config'; // ---------------------------------------------------------------------------- export type DataFormat = 'json' | 'yaml'; -export type Format = DataFormat | { data?: DataFormat; contentField?: string }; +export type Format = + | DataFormat + | { + data?: DataFormat; + contentField?: string | [string, ...string[]]; + }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; export type Collection< diff --git a/packages/keystatic/src/form/fields/conditional/ui.tsx b/packages/keystatic/src/form/fields/conditional/ui.tsx index e5168d566..4d1842af5 100644 --- a/packages/keystatic/src/form/fields/conditional/ui.tsx +++ b/packages/keystatic/src/form/fields/conditional/ui.tsx @@ -26,6 +26,7 @@ export function ConditionalFieldInput< onChange, value, forceValidation, + omitFieldAtPath, }: GenericPreviewProps< ConditionalField, unknown @@ -34,6 +35,12 @@ export function ConditionalFieldInput< const schemaDiscriminant = schema.discriminant as BasicFormField< string | boolean >; + const innerOmitFieldAtPath = useMemo(() => { + if (!omitFieldAtPath) { + return undefined; + } + return omitFieldAtPath.slice(1); + }, [omitFieldAtPath]); return ( {useMemo( @@ -53,6 +60,9 @@ export function ConditionalFieldInput< diff --git a/packages/keystatic/src/form/fields/emptyContent.tsx b/packages/keystatic/src/form/fields/emptyContent.tsx new file mode 100644 index 000000000..6b8a19a63 --- /dev/null +++ b/packages/keystatic/src/form/fields/emptyContent.tsx @@ -0,0 +1,36 @@ +import { ContentFormField } from '../api'; + +export function emptyContent(opts: { + extension: 'mdoc' | 'md' | 'mdx'; +}): ContentFormField { + return { + kind: 'form', + formKind: 'content', + Input() { + return null; + }, + defaultValue() { + return null; + }, + parse() { + return null; + }, + contentExtension: `.${opts.extension}`, + serialize() { + return { + value: undefined, + content: new Uint8Array(), + external: new Map(), + other: new Map(), + }; + }, + validate(value) { + return value; + }, + reader: { + parse() { + return null; + }, + }, + }; +} diff --git a/packages/keystatic/src/form/fields/emptyDocument.tsx b/packages/keystatic/src/form/fields/emptyDocument.tsx index 6f3d4dc95..81ecdc300 100644 --- a/packages/keystatic/src/form/fields/emptyDocument.tsx +++ b/packages/keystatic/src/form/fields/emptyDocument.tsx @@ -1,5 +1,8 @@ import { ContentFormField } from '../api'; +/** + * @deprecated `emptyDocument` has been replaced with the `emptyContent` field + */ export function emptyDocument(): ContentFormField { return { kind: 'form', diff --git a/packages/keystatic/src/form/fields/index.ts b/packages/keystatic/src/form/fields/index.ts index c5a40c309..01ac0bc49 100644 --- a/packages/keystatic/src/form/fields/index.ts +++ b/packages/keystatic/src/form/fields/index.ts @@ -9,6 +9,7 @@ export { datetime } from './datetime'; export { document } from './document'; export { empty } from './empty'; export { emptyDocument } from './emptyDocument'; +export { emptyContent } from './emptyContent'; export { file } from './file'; export { image } from './image'; export { integer } from './integer'; diff --git a/packages/keystatic/src/form/fields/object/ui.tsx b/packages/keystatic/src/form/fields/object/ui.tsx index 9ee96af9a..0dc6c4226 100644 --- a/packages/keystatic/src/form/fields/object/ui.tsx +++ b/packages/keystatic/src/form/fields/object/ui.tsx @@ -1,5 +1,5 @@ import { assert, assertNever } from 'emery'; -import { useId } from 'react'; +import { useId, useMemo } from 'react'; import { Grid } from '@keystar/ui/layout'; import { containerQueries, css } from '@keystar/ui/style'; @@ -20,12 +20,19 @@ export function ObjectFieldInput< autoFocus, fields, forceValidation, + omitFieldAtPath, }: GenericPreviewProps, unknown> & ExtraFieldInputProps) { validateLayout(schema); const firstFocusable = autoFocus ? findFocusableObjectFieldKey(schema) : undefined; + const innerOmitFieldAtPath = useMemo(() => { + if (!omitFieldAtPath) { + return undefined; + } + return omitFieldAtPath.slice(1); + }, [omitFieldAtPath]); const inner = ( diff --git a/packages/keystatic/src/form/fields/text/path-slug-context.tsx b/packages/keystatic/src/form/fields/text/path-slug-context.tsx index fccc18ddc..14163bfc7 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -84,12 +84,12 @@ function CollabAddToPathProvider(props: { } export function AddToPathProvider(props: { - part: string | number; + part: (string | number) | readonly (string | number)[]; children: ReactNode; }) { const path = useContext(PathContext); const config = useConfig(); - const newPath = useMemo(() => [...path, props.part], [path, props.part]); + const newPath = useMemo(() => path.concat(props.part), [path, props.part]); let inner = ( diff --git a/packages/keystatic/src/form/form-from-preview.tsx b/packages/keystatic/src/form/form-from-preview.tsx index c10ebacf0..987e0aec3 100644 --- a/packages/keystatic/src/form/form-from-preview.tsx +++ b/packages/keystatic/src/form/form-from-preview.tsx @@ -1,4 +1,4 @@ -import { MemoExoticComponent, ReactElement, memo } from 'react'; +import { MemoExoticComponent, ReactElement, ReactNode, memo } from 'react'; import { ComponentSchema, GenericPreviewProps } from './api'; import { ReadonlyPropPath } from './fields/document/DocumentEditor/component-blocks/utils'; @@ -15,6 +15,7 @@ import { ChildFieldInput } from './fields/document/DocumentEditor/component-bloc export type ExtraFieldInputProps = { autoFocus: boolean; forceValidation: boolean; + omitFieldAtPath?: string[]; }; function getInputComponent(schema: ComponentSchema): any { @@ -38,9 +39,12 @@ export const InnerFormValueContentFromPreviewProps: MemoExoticComponent< props: GenericPreviewProps & { autoFocus?: boolean; forceValidation?: boolean; + // array fields are not supported because the use case for this (omitting content fields) is not used + omitFieldAtPath?: string[]; } - ) => ReactElement + ) => ReactNode > = memo(function InnerFormValueContentFromPreview(props) { + if (props.omitFieldAtPath?.length === 0) return null; let Input = getInputComponent(props.schema); return (