@nuasite/cms 0.27.0 → 0.29.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.
@@ -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, insert, replaceAll } from '@milkdown/utils'
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={handleLink} title="Link" active={formats.link}>
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) => {
@@ -311,6 +314,7 @@ const INLINE_INPUT_TYPES: Record<string, string> = {
311
314
  datetime: 'datetime-local',
312
315
  time: 'time',
313
316
  email: 'email',
317
+ tel: 'tel',
314
318
  }
315
319
  const inputClass =
316
320
  'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
@@ -426,11 +430,11 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
426
430
 
427
431
  return (
428
432
  <div
429
- class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md overflow-hidden select-none"
433
+ class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md select-none"
430
434
  data-cms-ui
431
435
  >
432
436
  {/* Header */}
433
- <div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10">
437
+ <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
438
  <div class="flex items-center gap-2">
435
439
  <MdxComponentIcon />
436
440
  <span class="text-[13px] font-semibold text-white">{componentName}</span>
@@ -233,7 +233,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
233
233
  if (isCreating) {
234
234
  const slug = slugify(newName.trim())
235
235
  return (
236
- <div class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3">
236
+ <form
237
+ class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3"
238
+ onSubmit={(e) => {
239
+ e.preventDefault()
240
+ handleCreate()
241
+ }}
242
+ >
237
243
  <div class="flex items-center justify-between">
238
244
  <span class="text-[12px] font-medium text-white/70">Create new entry</span>
239
245
  {options.length > 0 && (
@@ -251,6 +257,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
251
257
  value={newName}
252
258
  onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
253
259
  placeholder="Enter name..."
260
+ required
254
261
  class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
255
262
  autoFocus
256
263
  />
@@ -279,15 +286,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
279
286
  Cancel
280
287
  </button>
281
288
  <button
282
- type="button"
283
- onClick={handleCreate}
284
- disabled={!newName.trim()}
289
+ type="submit"
285
290
  class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
286
291
  >
287
292
  Create
288
293
  </button>
289
294
  </div>
290
- </div>
295
+ </form>
291
296
  )
292
297
  }
293
298
 
@@ -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
@@ -1,47 +1,131 @@
1
1
  import { z } from 'astro/zod'
2
2
 
3
3
  /**
4
- * Semantic field type schemas for content collections.
4
+ * Schema helpers for content collections.
5
5
  *
6
- * Each method returns a `z.string()` schema that Astro resolves
7
- * statically (concrete return type, no generics). The CMS collection
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
- * Chain Zod methods as usual (`.optional()`, `.default()`, etc.).
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 = z.object({
16
+ * const schema = n.object({
17
+ * title: n.text({ placeholder: "Enter title", maxLength: 120 }),
19
18
  * photo: n.image(),
20
- * website: n.url().optional(),
21
- * contact: n.email(),
22
- * accent: n.color(),
23
- * publishedAt: n.date(),
24
- * startsAt: n.datetime(),
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
- /** Image picker (opens media library) */
32
- image: () => z.string().describe('cms:image'),
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: () => z.string().describe('cms:url'),
112
+ url: (hints?: TextHints) => stringField('url', hints),
35
113
  /** Email input */
36
- email: () => z.string().describe('cms:email'),
114
+ email: (hints?: TextHints) => stringField('email', hints),
115
+ /** Phone number input */
116
+ tel: (hints?: TextHints) => stringField('tel', hints),
37
117
  /** 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'),
118
+ color: () => withOrderBy(z.string().describe('cms:color')),
119
+ /** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
120
+ date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
121
+ /** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
122
+ datetime: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODatetime, z.string()).describe('cms:datetime')),
123
+ /** Time picker. Accepts hints for the scanner; no Zod validation applied. */
124
+ time: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:time')),
45
125
  /** Multiline textarea */
46
- textarea: () => z.string().describe('cms:textarea'),
126
+ textarea: (hints?: TextareaHints) => stringField('textarea', hints),
127
+ /** Text input */
128
+ text: (hints?: TextHints) => stringField('text', hints),
129
+ /** Plain string (no CMS type hint — type inferred from values) */
130
+ string: () => withOrderBy(z.string()),
47
131
  }
@@ -21,15 +21,6 @@ export interface RouteContext {
21
21
  manifestWriter: ManifestWriter
22
22
  contentDir: string
23
23
  mediaAdapter?: MediaStorageAdapter
24
- /**
25
- * Triggered after a content file (markdown / data collection) is written so
26
- * the dev middleware can synchronously refresh Astro's content layer and
27
- * invalidate Vite's SSR module cache before responding to the client.
28
- *
29
- * Awaiting this is important: returning success before the cache is fresh
30
- * causes the editor to reload the page into a stale render.
31
- */
32
- notifyContentChanged?: (filePath: string) => Promise<void>
33
24
  }
34
25
 
35
26
  type RouteHandler = (ctx: RouteContext) => Promise<void>
@@ -113,16 +104,20 @@ const routeMap = new Map<string, RouteHandler>([
113
104
  }
114
105
  sendJson(res, result)
115
106
  }),
116
- custom('POST', 'markdown/update', async ({ req, res, manifestWriter, notifyContentChanged }) => {
107
+ custom('POST', 'markdown/update', async ({ req, res, manifestWriter }) => {
117
108
  const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
118
109
  const result = await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions())
119
- if (result.success && notifyContentChanged) {
120
- await notifyContentChanged(body.filePath)
121
- }
122
110
  sendJson(res, result)
123
111
  }),
124
112
  post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
125
- postWithStatus('markdown/create', (body: Parameters<typeof handleCreateMarkdown>[0]) => handleCreateMarkdown(body)),
113
+ custom('POST', 'markdown/create', async ({ req, res, manifestWriter, contentDir }) => {
114
+ const body = await parseJsonBody<Parameters<typeof handleCreateMarkdown>[0]>(req)
115
+ const result = await handleCreateMarkdown(body)
116
+ if (result.success) {
117
+ manifestWriter.setCollectionDefinitions(await scanCollections(contentDir))
118
+ }
119
+ sendJson(res, result, result.success ? 200 : 400)
120
+ }),
126
121
  custom('POST', 'markdown/delete', async ({ req, res, manifestWriter, contentDir }) => {
127
122
  const body = await parseJsonBody<Parameters<typeof handleDeleteMarkdown>[0]>(req)
128
123
  const fullPath = path.resolve(getProjectRoot(), body.filePath?.replace(/^\//, '') ?? '')
@@ -238,9 +233,8 @@ export async function handleCmsApiRoute(
238
233
  manifestWriter: ManifestWriter,
239
234
  contentDir: string,
240
235
  mediaAdapter?: MediaStorageAdapter,
241
- notifyContentChanged?: (filePath: string) => Promise<void>,
242
236
  ): Promise<void> {
243
- const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter, notifyContentChanged }
237
+ const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
244
238
 
245
239
  // Exact match lookup
246
240
  const handler = routeMap.get(`${req.method}:${route}`)
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,
@@ -125,6 +125,21 @@ export class ManifestWriter {
125
125
  return this.collectionDefinitions
126
126
  }
127
127
 
128
+ /**
129
+ * Clear all entry pathnames on collection definitions.
130
+ * Called when route files change so stale pathnames from addPage() don't
131
+ * point to routes that no longer exist.
132
+ */
133
+ clearCollectionPathnames(): void {
134
+ for (const def of Object.values(this.collectionDefinitions)) {
135
+ if (def.entries) {
136
+ for (const entry of def.entries) {
137
+ entry.pathname = undefined
138
+ }
139
+ }
140
+ }
141
+ }
142
+
128
143
  /**
129
144
  * Get the manifest path for a given page
130
145
  * Places manifest next to the page: /about -> /about.json, / -> /index.json
package/src/types.ts CHANGED
@@ -236,12 +236,25 @@ export type FieldType =
236
236
  | 'image'
237
237
  | 'url'
238
238
  | 'email'
239
+ | 'tel'
239
240
  | 'color'
240
241
  | 'select'
241
242
  | 'array'
242
243
  | 'object'
243
244
  | 'reference'
244
245
 
246
+ /** Editor hints for enhanced field rendering (extracted from `n.*()` options in content config) */
247
+ export interface FieldHints {
248
+ min?: number | string
249
+ max?: number | string
250
+ step?: number
251
+ placeholder?: string
252
+ maxLength?: number
253
+ minLength?: number
254
+ rows?: number
255
+ accept?: string
256
+ }
257
+
245
258
  /** Definition of a single field in a collection's schema */
246
259
  export interface FieldDefinition {
247
260
  /** Field name as it appears in frontmatter */
@@ -270,6 +283,8 @@ export interface FieldDefinition {
270
283
  hidden?: boolean
271
284
  /** Source field name this field is derived from (e.g. categoryHref derived from category) */
272
285
  derivedFrom?: string
286
+ /** Editor hints for enhanced field rendering */
287
+ hints?: FieldHints
273
288
  }
274
289
 
275
290
  /** Per-entry metadata for collection browsing */
@@ -304,6 +319,10 @@ export interface CollectionDefinition {
304
319
  fileExtension: 'md' | 'mdx' | 'json' | 'yaml' | 'yml'
305
320
  /** Per-entry metadata for browsing */
306
321
  entries?: CollectionEntryInfo[]
322
+ /** Frontmatter field name to sort entries by (detected from `.orderBy()` in content config) */
323
+ orderBy?: string
324
+ /** Sort direction for orderBy field */
325
+ orderDirection?: 'asc' | 'desc'
307
326
  }
308
327
 
309
328
  /** Manifest metadata for versioning and conflict detection */
@@ -1,8 +1,5 @@
1
- import { watch } from 'node:fs'
2
- import { join } from 'node:path'
3
1
  import type { Plugin } from 'vite'
4
- import { invalidateContentCache, notifyContentStoreUpdated, type ViteServerLike } from './content-invalidator'
5
- import { expectedDeletions } from './dev-middleware'
2
+ import { expectedDeletions, invalidateCollectionRoutesCache } from './dev-middleware'
6
3
  import type { ManifestWriter } from './manifest-writer'
7
4
  import { markFileDirty } from './source-finder'
8
5
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
@@ -56,6 +53,11 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
56
53
  if (INDEXED_EXTENSIONS.test(filePath)) {
57
54
  markFileDirty(filePath)
58
55
  }
56
+ // Invalidate cached collection routes when a dynamic route file changes
57
+ if (filePath.includes('/src/pages/') && filePath.includes('[')) {
58
+ invalidateCollectionRoutesCache()
59
+ manifestWriter.clearCollectionPathnames()
60
+ }
59
61
  }
60
62
 
61
63
  // Intercept Vite's file watcher to:
@@ -80,83 +82,27 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
80
82
  // processes them. We use prependListener so our handler runs first.
81
83
  const origEmit = watcher.emit.bind(watcher)
82
84
  watcher.emit = ((event: string, filePath: string, ...args: any[]) => {
83
- if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
84
- expectedDeletions.delete(filePath)
85
- // Swallow the event — don't let Vite/Astro see it
86
- return true
85
+ if (event === 'unlink' || event === 'unlinkDir') {
86
+ if (expectedDeletions.has(filePath)) {
87
+ expectedDeletions.delete(filePath)
88
+ // Swallow the event — don't let Vite/Astro see it
89
+ return true
90
+ }
91
+ // Invalidate cached collection routes when a dynamic route file is deleted
92
+ if (filePath.includes('/src/pages/') && filePath.includes('[')) {
93
+ invalidateCollectionRoutesCache()
94
+ manifestWriter.clearCollectionPathnames()
95
+ }
87
96
  }
88
97
  return origEmit(event, filePath, ...args)
89
98
  }) as typeof watcher.emit
90
99
  },
91
100
  }
92
101
 
93
- // Vite's bundled chokidar 3.6.0 fails to detect changes to .astro/data-store.json
94
- // (added via watcher.add() in Astro's vite-plugin-content-virtual-mod).
95
- // Without this, content collection edits update the data store on disk but the
96
- // browser never receives a full-reload because Vite's watcher never fires "change"
97
- // for that file. We use native fs.watch as a reliable fallback.
98
- //
99
- // Caveat: native fs.watch on Linux tracks the inode, not the path. Astro writes
100
- // data-store.json via atomic rename (writeFile-tmp + rename), which replaces the
101
- // inode and silently kills the existing watcher. We re-attach on every event to
102
- // keep tracking the live file across atomic writes.
103
- const dataStoreWatchPlugin: Plugin = {
104
- name: 'cms-data-store-watch',
105
- configureServer(server) {
106
- if (command !== 'dev') return
107
- const root = server.config.root
108
- const dataStorePath = join(root, '.astro', 'data-store.json')
109
- let fsWatcher: ReturnType<typeof watch> | undefined
110
- let debounce: ReturnType<typeof setTimeout> | undefined
111
- let closed = false
112
-
113
- const invalidate = () => {
114
- invalidateContentCache(server as unknown as ViteServerLike)
115
- // Wake any CMS API middleware call that is currently blocked
116
- // waiting for the data store to reflect a just-written file.
117
- // This keeps the invalidation on a single path (here) and lets
118
- // the middleware respond only after the SSR module graph is fresh.
119
- notifyContentStoreUpdated()
120
- }
121
-
122
- const onEvent = () => {
123
- clearTimeout(debounce)
124
- debounce = setTimeout(invalidate, 80)
125
- // Re-attach: native fs.watch dies after the inode is replaced by an
126
- // atomic rename. Close current and restart so subsequent writes are
127
- // observed.
128
- fsWatcher?.close()
129
- fsWatcher = undefined
130
- if (!closed) startWatching()
131
- }
132
-
133
- const startWatching = () => {
134
- if (closed) return
135
- try {
136
- fsWatcher = watch(dataStorePath, onEvent)
137
- } catch {
138
- // File doesn't exist yet — retry when it appears
139
- setTimeout(startWatching, 2000)
140
- }
141
- }
142
-
143
- // Data store is created during content sync, which runs after server start
144
- setTimeout(startWatching, 3000)
145
-
146
- const origClose = server.close.bind(server)
147
- server.close = async () => {
148
- closed = true
149
- fsWatcher?.close()
150
- clearTimeout(debounce)
151
- return origClose()
152
- }
153
- },
154
- }
155
-
156
102
  // Note: We cannot use transformIndexHtml for static Astro builds because
157
103
  // Astro generates HTML files directly without going through Vite's HTML pipeline.
158
104
  // HTML processing is done in build-processor.ts after pages are generated.
159
105
  // Source location attributes are provided natively by Astro's compiler
160
106
  // (data-astro-source-file, data-astro-source-loc) in dev mode.
161
- return [virtualManifestPlugin, watcherPlugin, dataStoreWatchPlugin, createArrayTransformPlugin()]
107
+ return [virtualManifestPlugin, watcherPlugin, createArrayTransformPlugin()]
162
108
  }