@nuasite/cms 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -0
- package/dist/editor.js +11536 -11345
- package/package.json +1 -1
- package/src/collection-scanner.ts +152 -12
- package/src/editor/components/fields.tsx +8 -2
- package/src/editor/components/frontmatter-fields.tsx +13 -3
- package/src/editor/components/link-edit-popover.tsx +232 -0
- package/src/editor/components/markdown-inline-editor.tsx +25 -52
- package/src/editor/components/mdx-block-view.tsx +20 -17
- package/src/editor/hooks/useLinkPopover.ts +64 -0
- package/src/editor/milkdown-utils.ts +21 -0
- package/src/field-types.ts +109 -27
- package/src/index.ts +2 -0
- package/src/types.ts +18 -0
|
@@ -4,21 +4,22 @@ import {
|
|
|
4
4
|
commonmark,
|
|
5
5
|
liftListItemCommand,
|
|
6
6
|
toggleEmphasisCommand,
|
|
7
|
-
toggleLinkCommand,
|
|
8
7
|
toggleStrongCommand,
|
|
9
8
|
wrapInBlockquoteCommand,
|
|
10
9
|
wrapInBulletListCommand,
|
|
11
10
|
wrapInOrderedListCommand,
|
|
12
11
|
} from '@milkdown/preset-commonmark'
|
|
13
12
|
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
14
|
-
import { callCommand,
|
|
13
|
+
import { callCommand, replaceAll } from '@milkdown/utils'
|
|
15
14
|
import type { ComponentChildren } from 'preact'
|
|
16
15
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
16
|
+
import { useLinkPopover } from '../hooks/useLinkPopover'
|
|
17
17
|
import { getComponentDefinition } from '../manifest'
|
|
18
18
|
import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
|
|
19
19
|
import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
|
|
20
20
|
import { manifest, openMediaLibraryWithCallback } from '../signals'
|
|
21
21
|
import type { ComponentProp } from '../types'
|
|
22
|
+
import { LinkEditPopover } from './link-edit-popover'
|
|
22
23
|
|
|
23
24
|
const MDX_COMPONENT_ICON_PATH =
|
|
24
25
|
'M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5'
|
|
@@ -78,6 +79,7 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
78
79
|
const latestMarkdown = useRef(value)
|
|
79
80
|
const isFocused = useRef(false)
|
|
80
81
|
const [formats, setFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
82
|
+
const link = useLinkPopover(editorRef, formats)
|
|
81
83
|
|
|
82
84
|
useEffect(() => {
|
|
83
85
|
const el = containerRef.current
|
|
@@ -144,18 +146,6 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
144
146
|
return false
|
|
145
147
|
}, [])
|
|
146
148
|
|
|
147
|
-
const handleLink = useCallback(() => {
|
|
148
|
-
if (!editorRef.current) return
|
|
149
|
-
const url = prompt('Enter URL:', 'https://')
|
|
150
|
-
if (!url) return
|
|
151
|
-
try {
|
|
152
|
-
editorRef.current.action(callCommand(toggleLinkCommand.key, { href: url }))
|
|
153
|
-
} catch {
|
|
154
|
-
const linkText = window.getSelection()?.toString() || 'Link'
|
|
155
|
-
editorRef.current.action(insert(`[${linkText}](${url})`))
|
|
156
|
-
}
|
|
157
|
-
}, [])
|
|
158
|
-
|
|
159
149
|
const handleHeadingToggle = useCallback((level: number) => {
|
|
160
150
|
if (!editorRef.current) return
|
|
161
151
|
try {
|
|
@@ -242,7 +232,7 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
242
232
|
<div class="w-px h-4 bg-white/15 mx-0.5" />
|
|
243
233
|
|
|
244
234
|
{/* Link */}
|
|
245
|
-
<MiniToolbarButton onClick={
|
|
235
|
+
<MiniToolbarButton onClick={link.toggleLinkPopover} title="Link" active={formats.link || link.linkPopoverOpen}>
|
|
246
236
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
247
237
|
<path
|
|
248
238
|
stroke-linecap="round"
|
|
@@ -253,6 +243,19 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
253
243
|
</MiniToolbarButton>
|
|
254
244
|
</div>
|
|
255
245
|
|
|
246
|
+
{link.linkPopoverState && (
|
|
247
|
+
<div class="mb-1.5">
|
|
248
|
+
<LinkEditPopover
|
|
249
|
+
inline
|
|
250
|
+
initialUrl={link.linkPopoverState.href}
|
|
251
|
+
suggestions={link.pageSuggestions}
|
|
252
|
+
onApply={link.applyLink}
|
|
253
|
+
onRemove={link.linkPopoverState.isEdit ? link.removeLink : undefined}
|
|
254
|
+
onClose={link.closeLinkPopover}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
256
259
|
{/* Editor */}
|
|
257
260
|
<div
|
|
258
261
|
ref={(el) => {
|
|
@@ -426,11 +429,11 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
|
|
|
426
429
|
|
|
427
430
|
return (
|
|
428
431
|
<div
|
|
429
|
-
class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md
|
|
432
|
+
class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md select-none"
|
|
430
433
|
data-cms-ui
|
|
431
434
|
>
|
|
432
435
|
{/* Header */}
|
|
433
|
-
<div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10">
|
|
436
|
+
<div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10 rounded-t-cms-md">
|
|
434
437
|
<div class="flex items-center gap-2">
|
|
435
438
|
<MdxComponentIcon />
|
|
436
439
|
<span class="text-[13px] font-semibold text-white">{componentName}</span>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Editor } from '@milkdown/core'
|
|
2
|
+
import { editorViewCtx } from '@milkdown/core'
|
|
3
|
+
import { toggleLinkCommand, updateLinkCommand } from '@milkdown/preset-commonmark'
|
|
4
|
+
import { callCommand } from '@milkdown/utils'
|
|
5
|
+
import type { RefObject } from 'preact'
|
|
6
|
+
import { useCallback, useMemo, useState } from 'preact/hooks'
|
|
7
|
+
import type { LinkSuggestion } from '../components/link-edit-popover'
|
|
8
|
+
import type { ActiveFormats } from '../milkdown-utils'
|
|
9
|
+
import { removeLinkMark } from '../milkdown-utils'
|
|
10
|
+
import { manifest } from '../signals'
|
|
11
|
+
|
|
12
|
+
export interface LinkPopoverState {
|
|
13
|
+
href: string
|
|
14
|
+
isEdit: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useLinkPopover(editorRef: RefObject<Editor | null>, activeFormats: ActiveFormats) {
|
|
18
|
+
const [linkPopoverState, setLinkPopoverState] = useState<LinkPopoverState | null>(null)
|
|
19
|
+
const linkPopoverOpen = linkPopoverState !== null
|
|
20
|
+
const closeLinkPopover = useCallback(() => setLinkPopoverState(null), [])
|
|
21
|
+
|
|
22
|
+
const pageSuggestions = useMemo<LinkSuggestion[]>(() =>
|
|
23
|
+
(manifest.value.pages || []).map(p => ({
|
|
24
|
+
value: p.pathname,
|
|
25
|
+
label: p.title || p.pathname,
|
|
26
|
+
description: p.title ? p.pathname : undefined,
|
|
27
|
+
})), [manifest.value.pages])
|
|
28
|
+
|
|
29
|
+
const toggleLinkPopover = useCallback(() => {
|
|
30
|
+
if (!editorRef.current) return
|
|
31
|
+
setLinkPopoverState((prev) => prev !== null ? null : { href: activeFormats.linkHref || 'https://', isEdit: activeFormats.link })
|
|
32
|
+
}, [activeFormats.link, activeFormats.linkHref, editorRef])
|
|
33
|
+
|
|
34
|
+
const applyLink = useCallback((url: string) => {
|
|
35
|
+
if (!editorRef.current) return
|
|
36
|
+
const isEdit = linkPopoverState?.isEdit ?? false
|
|
37
|
+
closeLinkPopover()
|
|
38
|
+
try {
|
|
39
|
+
const view = editorRef.current.ctx.get(editorViewCtx)
|
|
40
|
+
view.focus()
|
|
41
|
+
if (isEdit) {
|
|
42
|
+
editorRef.current.action(callCommand(updateLinkCommand.key, { href: url }))
|
|
43
|
+
} else {
|
|
44
|
+
editorRef.current.action(callCommand(toggleLinkCommand.key, { href: url }))
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Failed to apply link:', error)
|
|
48
|
+
}
|
|
49
|
+
}, [linkPopoverState, closeLinkPopover, editorRef])
|
|
50
|
+
|
|
51
|
+
const removeLink = useCallback(() => {
|
|
52
|
+
if (!editorRef.current) return
|
|
53
|
+
closeLinkPopover()
|
|
54
|
+
try {
|
|
55
|
+
const view = editorRef.current.ctx.get(editorViewCtx)
|
|
56
|
+
view.focus()
|
|
57
|
+
removeLinkMark(view)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to remove link:', error)
|
|
60
|
+
}
|
|
61
|
+
}, [closeLinkPopover, editorRef])
|
|
62
|
+
|
|
63
|
+
return { linkPopoverState, linkPopoverOpen, closeLinkPopover, toggleLinkPopover, applyLink, removeLink, pageSuggestions }
|
|
64
|
+
}
|
|
@@ -121,6 +121,27 @@ export function toggleHeading(view: EditorView, level: number): void {
|
|
|
121
121
|
view.focus()
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Remove the link mark around the current cursor position.
|
|
126
|
+
* Finds the text node with a link mark at/near the selection and dispatches a removeMark transaction.
|
|
127
|
+
*/
|
|
128
|
+
export function removeLinkMark(view: EditorView): void {
|
|
129
|
+
const { state } = view
|
|
130
|
+
const { from, to } = state.selection
|
|
131
|
+
const linkType = state.schema.marks.link
|
|
132
|
+
if (!linkType) return
|
|
133
|
+
let linkFrom = from
|
|
134
|
+
let linkTo = to
|
|
135
|
+
state.doc.nodesBetween(from, from === to ? to + 1 : to, (node, pos) => {
|
|
136
|
+
if (linkType.isInSet(node.marks)) {
|
|
137
|
+
linkFrom = pos
|
|
138
|
+
linkTo = pos + node.nodeSize
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
view.dispatch(state.tr.removeMark(linkFrom, linkTo, linkType))
|
|
143
|
+
}
|
|
144
|
+
|
|
124
145
|
function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
|
|
125
146
|
return a.bold === b.bold
|
|
126
147
|
&& a.italic === b.italic
|
package/src/field-types.ts
CHANGED
|
@@ -1,47 +1,129 @@
|
|
|
1
1
|
import { z } from 'astro/zod'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Schema helpers for content collections.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* scanner detects them by name in the source and renders the
|
|
9
|
-
* appropriate editor input.
|
|
6
|
+
* Combines Zod passthrough methods with CMS-aware semantic types,
|
|
7
|
+
* so a content config can import only `n` instead of both `n` and `z`.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
9
|
+
* Pass an options object to configure editor hints and Zod validation
|
|
10
|
+
* in one place. Chain `.orderBy('asc' | 'desc')` to mark the ordering field.
|
|
12
11
|
*
|
|
13
12
|
* @example
|
|
14
13
|
* ```ts
|
|
15
14
|
* import { n } from '@nuasite/cms'
|
|
16
|
-
* import { z } from 'astro/zod'
|
|
17
15
|
*
|
|
18
|
-
* const schema =
|
|
16
|
+
* const schema = n.object({
|
|
17
|
+
* title: n.text({ placeholder: "Enter title", maxLength: 120 }),
|
|
19
18
|
* photo: n.image(),
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* opensAt: n.time(),
|
|
26
|
-
* bio: n.textarea(),
|
|
19
|
+
* bio: n.textarea({ rows: 4, maxLength: 500 }),
|
|
20
|
+
* order: n.number({ min: 1, max: 100, step: 1 }).orderBy('asc'),
|
|
21
|
+
* date: n.date().orderBy('desc'),
|
|
22
|
+
* tags: n.array(n.string()),
|
|
23
|
+
* featured: n.boolean().default(false),
|
|
27
24
|
* })
|
|
28
25
|
* ```
|
|
29
26
|
*/
|
|
27
|
+
|
|
28
|
+
// --- Per-type hint interfaces ---
|
|
29
|
+
|
|
30
|
+
export interface NumberHints {
|
|
31
|
+
min?: number
|
|
32
|
+
max?: number
|
|
33
|
+
step?: number
|
|
34
|
+
placeholder?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TextHints {
|
|
38
|
+
placeholder?: string
|
|
39
|
+
maxLength?: number
|
|
40
|
+
minLength?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TextareaHints {
|
|
44
|
+
placeholder?: string
|
|
45
|
+
maxLength?: number
|
|
46
|
+
rows?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DateHints {
|
|
50
|
+
min?: string
|
|
51
|
+
max?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ImageHints {
|
|
55
|
+
accept?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Internals ---
|
|
59
|
+
|
|
60
|
+
type OrderByDirection = 'asc' | 'desc'
|
|
61
|
+
type WithOrderBy<T> = T & { orderBy(direction?: OrderByDirection): T }
|
|
62
|
+
|
|
63
|
+
/** Normalize YAML Date objects to ISO date strings (YYYY-MM-DD) */
|
|
64
|
+
const toISODate = (v: unknown) => (v instanceof Date ? v.toISOString().slice(0, 10) : v)
|
|
65
|
+
/** Normalize YAML Date objects to ISO datetime strings */
|
|
66
|
+
const toISODatetime = (v: unknown) => (v instanceof Date ? v.toISOString() : v)
|
|
67
|
+
|
|
68
|
+
/** Add a chainable `.orderBy()` method to a Zod schema. The scanner detects it from source code. */
|
|
69
|
+
function withOrderBy<T extends z.ZodTypeAny>(schema: T): WithOrderBy<T> {
|
|
70
|
+
const s = schema as WithOrderBy<T>
|
|
71
|
+
s.orderBy = (_direction?: OrderByDirection) => schema
|
|
72
|
+
return s
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a CMS string field with optional length validation. Shared by text, url, email, textarea. */
|
|
76
|
+
function stringField(cmsType: string, hints?: { minLength?: number; maxLength?: number }) {
|
|
77
|
+
let schema = z.string()
|
|
78
|
+
if (hints?.minLength != null) schema = schema.min(hints.minLength)
|
|
79
|
+
if (hints?.maxLength != null) schema = schema.max(hints.maxLength)
|
|
80
|
+
return withOrderBy(schema.describe(`cms:${cmsType}`))
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
export const n = {
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
// --- Zod passthroughs ---
|
|
85
|
+
/** Object schema */
|
|
86
|
+
object: <T extends z.ZodRawShape>(shape: T) => z.object(shape),
|
|
87
|
+
/** Array schema */
|
|
88
|
+
array: <T extends z.ZodTypeAny>(schema: T) => z.array(schema),
|
|
89
|
+
/** Enum schema */
|
|
90
|
+
enum: <U extends string, T extends [U, ...U[]]>(values: T) => z.enum(values),
|
|
91
|
+
/** Coerce namespace — parses input into the target type */
|
|
92
|
+
coerce: {
|
|
93
|
+
date: () => withOrderBy(z.coerce.date()),
|
|
94
|
+
number: () => withOrderBy(z.coerce.number()),
|
|
95
|
+
string: () => withOrderBy(z.coerce.string()),
|
|
96
|
+
boolean: () => withOrderBy(z.coerce.boolean()),
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// --- CMS semantic types ---
|
|
100
|
+
/** Boolean / checkbox */
|
|
101
|
+
boolean: () => withOrderBy(z.boolean().describe('cms:checkbox')),
|
|
102
|
+
/** Number input with optional min/max/step */
|
|
103
|
+
number: (hints?: NumberHints) => {
|
|
104
|
+
let schema = z.number()
|
|
105
|
+
if (hints?.min != null) schema = schema.min(hints.min)
|
|
106
|
+
if (hints?.max != null) schema = schema.max(hints.max)
|
|
107
|
+
return withOrderBy(schema.describe('cms:number'))
|
|
108
|
+
},
|
|
109
|
+
/** Image picker (opens media library). Accepts hints for the scanner; no Zod validation applied. */
|
|
110
|
+
image: (_hints?: ImageHints) => withOrderBy(z.string().describe('cms:image')),
|
|
33
111
|
/** URL input */
|
|
34
|
-
url: () =>
|
|
112
|
+
url: (hints?: TextHints) => stringField('url', hints),
|
|
35
113
|
/** Email input */
|
|
36
|
-
email: () =>
|
|
114
|
+
email: (hints?: TextHints) => stringField('email', hints),
|
|
37
115
|
/** Color picker */
|
|
38
|
-
color: () => z.string().describe('cms:color'),
|
|
39
|
-
/** Date picker */
|
|
40
|
-
date: () => z.string().describe('cms:date'),
|
|
41
|
-
/** Date + time picker */
|
|
42
|
-
datetime: () => z.string().describe('cms:datetime'),
|
|
43
|
-
/** Time picker */
|
|
44
|
-
time: () => z.string().describe('cms:time'),
|
|
116
|
+
color: () => withOrderBy(z.string().describe('cms:color')),
|
|
117
|
+
/** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
|
|
118
|
+
date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
|
|
119
|
+
/** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
|
|
120
|
+
datetime: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODatetime, z.string()).describe('cms:datetime')),
|
|
121
|
+
/** Time picker. Accepts hints for the scanner; no Zod validation applied. */
|
|
122
|
+
time: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:time')),
|
|
45
123
|
/** Multiline textarea */
|
|
46
|
-
textarea: () =>
|
|
124
|
+
textarea: (hints?: TextareaHints) => stringField('textarea', hints),
|
|
125
|
+
/** Text input */
|
|
126
|
+
text: (hints?: TextHints) => stringField('text', hints),
|
|
127
|
+
/** Plain string (no CMS type hint — type inferred from values) */
|
|
128
|
+
string: () => withOrderBy(z.string()),
|
|
47
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -358,6 +358,7 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
export { n } from './field-types'
|
|
361
|
+
export type { DateHints, ImageHints, NumberHints, TextareaHints, TextHints } from './field-types'
|
|
361
362
|
export { createContemberStorageAdapter as contemberMedia } from './media/contember'
|
|
362
363
|
export { createLocalStorageAdapter as localMedia } from './media/local'
|
|
363
364
|
export { createS3StorageAdapter as s3Media } from './media/s3'
|
|
@@ -393,6 +394,7 @@ export type {
|
|
|
393
394
|
ComponentProp,
|
|
394
395
|
ContentConstraints,
|
|
395
396
|
FieldDefinition,
|
|
397
|
+
FieldHints,
|
|
396
398
|
FieldType,
|
|
397
399
|
ImageMetadata,
|
|
398
400
|
JsonLdEntry,
|
package/src/types.ts
CHANGED
|
@@ -242,6 +242,18 @@ export type FieldType =
|
|
|
242
242
|
| 'object'
|
|
243
243
|
| 'reference'
|
|
244
244
|
|
|
245
|
+
/** Editor hints for enhanced field rendering (extracted from `n.*()` options in content config) */
|
|
246
|
+
export interface FieldHints {
|
|
247
|
+
min?: number | string
|
|
248
|
+
max?: number | string
|
|
249
|
+
step?: number
|
|
250
|
+
placeholder?: string
|
|
251
|
+
maxLength?: number
|
|
252
|
+
minLength?: number
|
|
253
|
+
rows?: number
|
|
254
|
+
accept?: string
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
/** Definition of a single field in a collection's schema */
|
|
246
258
|
export interface FieldDefinition {
|
|
247
259
|
/** Field name as it appears in frontmatter */
|
|
@@ -270,6 +282,8 @@ export interface FieldDefinition {
|
|
|
270
282
|
hidden?: boolean
|
|
271
283
|
/** Source field name this field is derived from (e.g. categoryHref derived from category) */
|
|
272
284
|
derivedFrom?: string
|
|
285
|
+
/** Editor hints for enhanced field rendering */
|
|
286
|
+
hints?: FieldHints
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
/** Per-entry metadata for collection browsing */
|
|
@@ -304,6 +318,10 @@ export interface CollectionDefinition {
|
|
|
304
318
|
fileExtension: 'md' | 'mdx' | 'json' | 'yaml' | 'yml'
|
|
305
319
|
/** Per-entry metadata for browsing */
|
|
306
320
|
entries?: CollectionEntryInfo[]
|
|
321
|
+
/** Frontmatter field name to sort entries by (detected from `.orderBy()` in content config) */
|
|
322
|
+
orderBy?: string
|
|
323
|
+
/** Sort direction for orderBy field */
|
|
324
|
+
orderDirection?: 'asc' | 'desc'
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
/** Manifest metadata for versioning and conflict detection */
|