@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.
@@ -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) => {
@@ -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 overflow-hidden select-none"
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
@@ -1,47 +1,129 @@
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),
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: () => z.string().describe('cms: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 */