@nuasite/cms 0.40.0 → 0.42.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.
@@ -20,6 +20,7 @@ import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-p
20
20
  import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
21
21
  import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
22
22
  import { STRINGS } from '../strings'
23
+ import { setBulletListStyleCommand, styledListPlugin } from '../styled-list-plugin'
23
24
  import { LinkEditPopover } from './link-edit-popover'
24
25
  import { MdxComponentIcon } from './mdx-block-view'
25
26
  import { MdxComponentPicker } from './mdx-component-picker'
@@ -48,6 +49,7 @@ export function MarkdownInlineEditor({
48
49
  const [isReady, setIsReady] = useState(false)
49
50
  const [isDragging, setIsDragging] = useState(false)
50
51
  const [uploadProgress, setUploadProgress] = useState<number | null>(null)
52
+ const listStyles = (config.value.listStyles ?? []).filter(style => style.label && style.class)
51
53
 
52
54
  // Track active formatting for toolbar highlighting
53
55
  const [activeFormats, setActiveFormats] = useState<ActiveFormats>(defaultActiveFormats)
@@ -91,6 +93,15 @@ export function MarkdownInlineEditor({
91
93
  })
92
94
  })
93
95
  .use(commonmark)
96
+
97
+ // Styled bullet lists are opt-in: only load the plugin (and its `-` bullet
98
+ // normalization) when the site configures list styles, so sites that don't use
99
+ // the feature keep their previous list serialization untouched.
100
+ if (listStyles.length > 0) {
101
+ builder.use(styledListPlugin)
102
+ }
103
+
104
+ builder
94
105
  .use(gfm)
95
106
  .use(listener)
96
107
 
@@ -124,6 +135,21 @@ export function MarkdownInlineEditor({
124
135
  }
125
136
  }, [])
126
137
 
138
+ // Adopt content that streams in AFTER the editor mounted with a placeholder.
139
+ // The collections browser opens the modal immediately (empty), then fetches
140
+ // the entry body, so `initialContent` changes once content arrives. The
141
+ // Milkdown instance is created once, so re-seed it here — but only while the
142
+ // user hasn't edited the placeholder yet, so in-progress edits are never lost.
143
+ useEffect(() => {
144
+ if (!isReady) return
145
+ if (initialContent === initialContentRef.current) return
146
+ if (contentRef.current === initialContentRef.current) {
147
+ editorInstanceRef.current?.action(replaceAll(initialContent))
148
+ setContent(initialContent)
149
+ }
150
+ initialContentRef.current = initialContent
151
+ }, [initialContent, isReady])
152
+
127
153
  const handleSave = useCallback(() => {
128
154
  onSave(content)
129
155
  resetMarkdownEditorState()
@@ -235,6 +261,15 @@ export function MarkdownInlineEditor({
235
261
  }
236
262
  }, [runCommand, checkInList])
237
263
 
264
+ const handleListStyle = useCallback((listStyle: string | null) => {
265
+ if (!editorInstanceRef.current) return
266
+ try {
267
+ editorInstanceRef.current.action(callCommand(setBulletListStyleCommand.key, listStyle))
268
+ } catch (error) {
269
+ console.error('Failed to set list style:', error)
270
+ }
271
+ }, [])
272
+
238
273
  const handleInsertHeading = useCallback((level: number) => {
239
274
  if (!editorInstanceRef.current) return
240
275
  try {
@@ -537,6 +572,21 @@ export function MarkdownInlineEditor({
537
572
  </text>
538
573
  </svg>
539
574
  </ToolbarButton>
575
+ {listStyles.length > 0 && (
576
+ <select
577
+ class={cn(
578
+ 'h-8 max-w-40 rounded-cms-sm border border-white/15 bg-cms-dark px-2 text-xs text-white/90 outline-none transition-colors',
579
+ 'hover:bg-white/10 focus:border-cms-primary focus:ring-1 focus:ring-cms-primary',
580
+ !activeFormats.bulletList && 'opacity-60',
581
+ )}
582
+ title="List style"
583
+ value={activeFormats.listStyle ?? ''}
584
+ onChange={(event) => handleListStyle((event.currentTarget as HTMLSelectElement).value || null)}
585
+ >
586
+ <option value="">Default</option>
587
+ {listStyles.map(style => <option key={style.class} value={style.class}>{style.label}</option>)}
588
+ </select>
589
+ )}
540
590
  <ToolbarButton
541
591
  onClick={handleQuote}
542
592
  title="Quote"
@@ -28,7 +28,7 @@ export interface ToolbarProps {
28
28
  collectionDefinitions?: Record<string, CollectionDefinition>
29
29
  }
30
30
 
31
- type MenuItem = { label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean }
31
+ type MenuItem = { label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean; indented?: boolean }
32
32
  type MenuSection = { label: string; icon: ComponentChildren; items: MenuItem[] }
33
33
 
34
34
  const GridIcon = () => (
@@ -98,9 +98,23 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
98
98
  if (collectionDefinitions) {
99
99
  const entries = Object.entries(collectionDefinitions)
100
100
  if (entries.length > 0) {
101
- const contentItems: MenuItem[] = entries.map(([name, def]) => ({
101
+ // Group nested (child) collections under their parent, child entries indented.
102
+ const ordered: Array<[string, CollectionDefinition, boolean]> = []
103
+ for (const [name, def] of entries.filter(([, d]) => !d.parentCollection)) {
104
+ ordered.push([name, def, false])
105
+ for (const [childName, childDef] of entries.filter(([, d]) => d.parentCollection === name)) {
106
+ ordered.push([childName, childDef, true])
107
+ }
108
+ }
109
+ // Orphaned children whose parent isn't present stay top-level so nothing is hidden.
110
+ for (const [name, def] of entries.filter(([, d]) => d.parentCollection && !collectionDefinitions[d.parentCollection])) {
111
+ ordered.push([name, def, false])
112
+ }
113
+
114
+ const contentItems: MenuItem[] = ordered.map(([name, def, indented]) => ({
102
115
  label: def.label,
103
116
  icon: <GridIcon />,
117
+ indented,
104
118
  onClick: () => callbacks.onOpenCollection?.(name),
105
119
  }))
106
120
 
@@ -410,6 +424,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
410
424
  }}
411
425
  class={cn(
412
426
  'w-full pl-9 pr-4 py-2 text-sm text-left transition-colors cursor-pointer flex items-center gap-3',
427
+ item.indented && 'pl-16 text-xs text-white/45',
413
428
  item.isActive
414
429
  ? 'bg-white/20 text-white'
415
430
  : 'text-white/60 hover:bg-white/10 hover:text-white',
@@ -10,6 +10,7 @@ export interface ActiveFormats {
10
10
  linkHref: string | null
11
11
  bulletList: boolean
12
12
  orderedList: boolean
13
+ listStyle: string | null
13
14
  blockquote: boolean
14
15
  heading: number | null
15
16
  }
@@ -22,6 +23,7 @@ export const defaultActiveFormats: ActiveFormats = {
22
23
  linkHref: null,
23
24
  bulletList: false,
24
25
  orderedList: false,
26
+ listStyle: null,
25
27
  blockquote: false,
26
28
  heading: null,
27
29
  }
@@ -71,12 +73,16 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
71
73
  // Check block types (lists, blockquote, heading)
72
74
  let bulletList = false
73
75
  let orderedList = false
76
+ let listStyle: string | null = null
74
77
  let blockquote = false
75
78
  let heading: number | null = null
76
79
 
77
80
  for (let depth = $from.depth; depth > 0; depth--) {
78
81
  const node = $from.node(depth)
79
- if (node.type.name === 'bullet_list') bulletList = true
82
+ if (node.type.name === 'bullet_list') {
83
+ bulletList = true
84
+ listStyle = typeof node.attrs.listStyle === 'string' ? node.attrs.listStyle : null
85
+ }
80
86
  if (node.type.name === 'ordered_list') orderedList = true
81
87
  if (node.type.name === 'blockquote') blockquote = true
82
88
  }
@@ -85,7 +91,7 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
85
91
  heading = $from.parent.attrs.level as number
86
92
  }
87
93
 
88
- return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
94
+ return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, listStyle, blockquote, heading }
89
95
  }
90
96
 
91
97
  /**
@@ -150,6 +156,7 @@ function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
150
156
  && a.linkHref === b.linkHref
151
157
  && a.bulletList === b.bulletList
152
158
  && a.orderedList === b.orderedList
159
+ && a.listStyle === b.listStyle
153
160
  && a.blockquote === b.blockquote
154
161
  && a.heading === b.heading
155
162
  }
@@ -0,0 +1,233 @@
1
+ import { remarkStringifyOptionsCtx } from '@milkdown/core'
2
+ import { bulletListAttr, bulletListSchema } from '@milkdown/preset-commonmark'
3
+ import type { Node as PmNode } from '@milkdown/prose/model'
4
+ import type { Command } from '@milkdown/prose/state'
5
+ import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
6
+ import { $command, $remark } from '@milkdown/utils'
7
+
8
+ const LIST_DIRECTIVE_NAME = 'list'
9
+
10
+ function normalizeListStyleClass(value: unknown): string | null {
11
+ if (typeof value !== 'string') return null
12
+ const [className] = value.trim().split(/\s+/)
13
+ return className || null
14
+ }
15
+
16
+ function getDirectiveClass(node: any): string | null {
17
+ const className = node.attributes?.class
18
+ return normalizeListStyleClass(className)
19
+ }
20
+
21
+ function isParagraphText(node: any, value?: string): boolean {
22
+ if (node?.type !== 'paragraph' || node.children?.length !== 1) return false
23
+ const text = node.children[0]
24
+ if (text?.type !== 'text') return false
25
+ return value === undefined ? true : text.value === value
26
+ }
27
+
28
+ function parseOpeningDirective(node: any): string | null {
29
+ if (!isParagraphText(node)) return null
30
+ const value = node.children[0].value
31
+ const match = /^:::list\{\.([^}\s]+)\}$/.exec(value)
32
+ return match?.[1] ?? null
33
+ }
34
+
35
+ function stripRawClosingDirective(listNode: any, siblings: any[], indexAfterList: number): boolean {
36
+ if (isParagraphText(siblings[indexAfterList], ':::')) {
37
+ siblings.splice(indexAfterList, 1)
38
+ return true
39
+ }
40
+
41
+ const lastItem = listNode.children?.at(-1)
42
+ const lastBlock = lastItem?.children?.at(-1)
43
+ if (!lastBlock || lastBlock.type !== 'paragraph') return false
44
+ const lastText = lastBlock.children?.at(-1)
45
+ if (lastText?.type !== 'text' || typeof lastText.value !== 'string') return false
46
+
47
+ if (lastText.value === ':::') {
48
+ lastBlock.children.pop()
49
+ } else if (lastText.value.endsWith('\n:::')) {
50
+ lastText.value = lastText.value.slice(0, -4)
51
+ } else {
52
+ return false
53
+ }
54
+
55
+ if (lastBlock.children.length === 0) lastItem.children.pop()
56
+ return true
57
+ }
58
+
59
+ function transformListDirectives(parent: any): void {
60
+ if (!Array.isArray(parent?.children)) return
61
+
62
+ for (let index = 0; index < parent.children.length; index++) {
63
+ const child = parent.children[index]
64
+
65
+ if (child?.type === 'containerDirective' && child.name === LIST_DIRECTIVE_NAME) {
66
+ const className = getDirectiveClass(child)
67
+ const list = child.children?.length === 1 ? child.children[0] : null
68
+ if (className && list?.type === 'list' && !list.ordered) {
69
+ parent.children[index] = {
70
+ ...list,
71
+ listStyle: className,
72
+ }
73
+ continue
74
+ }
75
+ }
76
+
77
+ const rawClassName = parseOpeningDirective(child)
78
+ const rawList = parent.children[index + 1]
79
+ if (rawClassName && rawList?.type === 'list' && !rawList.ordered && stripRawClosingDirective(rawList, parent.children, index + 2)) {
80
+ parent.children.splice(index, 1)
81
+ parent.children[index] = {
82
+ ...rawList,
83
+ listStyle: rawClassName,
84
+ }
85
+ continue
86
+ }
87
+
88
+ transformListDirectives(child)
89
+ }
90
+ }
91
+
92
+ function listDirectiveHandler(node: any, _parent: any, state: any, info: any): string {
93
+ const className = normalizeListStyleClass(node.attributes?.class)
94
+ const children = state.containerFlow(node, info)
95
+ return `:::list${className ? `{.${className}}` : ''}\n${children}\n:::`
96
+ }
97
+
98
+ function remarkListDirective(this: { data: () => any }) {
99
+ const data = this.data()
100
+ const toMarkdownExtensions = data.toMarkdownExtensions || (data.toMarkdownExtensions = [])
101
+ toMarkdownExtensions.push({
102
+ handlers: {
103
+ containerDirective: listDirectiveHandler,
104
+ },
105
+ })
106
+
107
+ return (tree: any) => {
108
+ transformListDirectives(tree)
109
+ }
110
+ }
111
+
112
+ export const remarkListDirectivePlugin: any = $remark('remarkListDirective', () => remarkListDirective as any)
113
+
114
+ export const styledBulletListSchema = bulletListSchema.extendSchema((prev) => (ctx) => {
115
+ const schema = prev(ctx)
116
+ return {
117
+ ...schema,
118
+ attrs: {
119
+ ...schema.attrs,
120
+ listStyle: {
121
+ default: null,
122
+ validate: 'string|null',
123
+ },
124
+ },
125
+ parseDOM: [
126
+ {
127
+ tag: 'ul',
128
+ getAttrs: (dom) => {
129
+ const previousAttrs = schema.parseDOM?.[0]?.getAttrs?.(dom) ?? {}
130
+ const className = dom instanceof HTMLElement ? normalizeListStyleClass(dom.className) : null
131
+ return {
132
+ ...(typeof previousAttrs === 'object' ? previousAttrs : {}),
133
+ listStyle: className,
134
+ }
135
+ },
136
+ },
137
+ ],
138
+ toDOM: (node: PmNode) => {
139
+ const className = normalizeListStyleClass(node.attrs.listStyle)
140
+ return [
141
+ 'ul',
142
+ {
143
+ ...ctx.get(bulletListAttr.key)(node),
144
+ ...(className ? { class: className } : {}),
145
+ 'data-spread': node.attrs.spread,
146
+ },
147
+ 0,
148
+ ]
149
+ },
150
+ parseMarkdown: {
151
+ match: ({ type, ordered }: MarkdownNode & { ordered?: boolean }) => type === 'list' && !ordered,
152
+ runner: (state, node: MarkdownNode & { spread?: boolean; listStyle?: string }, type) => {
153
+ state.openNode(type, {
154
+ spread: node.spread ?? false,
155
+ listStyle: normalizeListStyleClass(node.listStyle),
156
+ }).next(node.children).closeNode()
157
+ },
158
+ },
159
+ toMarkdown: {
160
+ match: (node: PmNode) => node.type.name === 'bullet_list',
161
+ runner: (state: SerializerState, node: PmNode) => {
162
+ const listStyle = normalizeListStyleClass(node.attrs.listStyle)
163
+ if (listStyle) {
164
+ state.openNode('containerDirective', undefined, {
165
+ name: LIST_DIRECTIVE_NAME,
166
+ attributes: { class: listStyle },
167
+ })
168
+ }
169
+
170
+ state
171
+ .openNode('list', undefined, {
172
+ ordered: false,
173
+ spread: node.attrs.spread === true,
174
+ })
175
+ .next(node.content)
176
+ .closeNode()
177
+
178
+ if (listStyle) state.closeNode()
179
+ },
180
+ },
181
+ }
182
+ })
183
+
184
+ export const setBulletListStyleCommand = $command('SetBulletListStyle', () => {
185
+ return (listStyle?: string | null): Command => {
186
+ return (state, dispatch) => {
187
+ const bulletListType = state.schema.nodes.bullet_list
188
+ if (!bulletListType) return false
189
+
190
+ const nextStyle = normalizeListStyleClass(listStyle)
191
+ const positions = new Map<number, PmNode>()
192
+ const { from, to, $from } = state.selection
193
+
194
+ for (let depth = $from.depth; depth > 0; depth--) {
195
+ const node = $from.node(depth)
196
+ if (node.type === bulletListType) {
197
+ positions.set($from.before(depth), node)
198
+ break
199
+ }
200
+ }
201
+
202
+ state.doc.nodesBetween(from, to, (node, pos) => {
203
+ if (node.type === bulletListType) positions.set(pos, node)
204
+ })
205
+
206
+ if (positions.size === 0) return false
207
+
208
+ if (dispatch) {
209
+ let tr = state.tr
210
+ for (const [pos, node] of positions) {
211
+ tr = tr.setNodeMarkup(pos, undefined, {
212
+ ...node.attrs,
213
+ listStyle: nextStyle,
214
+ })
215
+ }
216
+ dispatch(tr)
217
+ }
218
+ return true
219
+ }
220
+ }
221
+ })
222
+
223
+ export const styledListPlugin = [
224
+ remarkListDirectivePlugin,
225
+ styledBulletListSchema,
226
+ setBulletListStyleCommand,
227
+ (ctx: any) => () => {
228
+ ctx.update(remarkStringifyOptionsCtx, (options: any) => ({
229
+ ...options,
230
+ bullet: '-',
231
+ }))
232
+ },
233
+ ].flat()
@@ -52,6 +52,9 @@ export interface CmsConfig {
52
52
  theme?: CmsThemeConfig
53
53
  themePreset?: CmsThemePreset
54
54
  features?: CmsFeatures
55
+ listStyles?: Array<{ label: string; class: string }>
56
+ /** Open the entry editor's metadata (frontmatter) panel by default instead of collapsed. */
57
+ openMetadataByDefault?: boolean
55
58
  /** Maximum upload size in bytes for media uploads (injected by the integration). */
56
59
  maxUploadSize?: number
57
60
  /**
@@ -55,6 +55,10 @@ export interface ImageHints {
55
55
  accept?: string
56
56
  }
57
57
 
58
+ export interface FileHints {
59
+ accept?: string
60
+ }
61
+
58
62
  // --- Internals ---
59
63
 
60
64
  type OrderByDirection = 'asc' | 'desc'
@@ -122,6 +126,8 @@ export const n = {
122
126
  },
123
127
  /** Image picker (opens media library). Accepts hints for the scanner; no Zod validation applied. */
124
128
  image: (_hints?: ImageHints) => withOrderBy(z.string().describe('cms:image')),
129
+ /** File picker (opens media library for any file type — PDFs, docs, etc.). Accepts hints for the scanner; no Zod validation applied. */
130
+ file: (_hints?: FileHints) => withOrderBy(z.string().describe('cms:file')),
125
131
  /** URL input */
126
132
  url: (hints?: TextHints) => stringField('url', hints),
127
133
  /** Email input */
@@ -130,6 +136,15 @@ export const n = {
130
136
  tel: (hints?: TextHints) => stringField('tel', hints),
131
137
  /** Color picker */
132
138
  color: () => withOrderBy(z.string().describe('cms:color')),
139
+ /** Year picker (integer input). Defaults to 1900–2100 when no min/max given. */
140
+ year: (hints?: NumberHints) => {
141
+ let schema = z.number()
142
+ if (hints?.min != null) schema = schema.min(hints.min)
143
+ if (hints?.max != null) schema = schema.max(hints.max)
144
+ return withOrderBy(schema.describe('cms:year'))
145
+ },
146
+ /** Month picker (YYYY-MM). Accepts hints for the scanner; no Zod validation applied. */
147
+ month: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:month')),
133
148
  /** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
134
149
  date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
135
150
  /** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
@@ -1,7 +1,9 @@
1
+ import type { Dirent } from 'node:fs'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import yaml from 'yaml'
4
5
  import { getProjectRoot } from '../config'
6
+ import { parseContentConfig } from '../content-config-ast'
5
7
  import type { ComponentDefinition } from '../types'
6
8
  import { acquireFileLock, isNodeError, relativeImportPath, resolveAndValidatePath, slugify } from '../utils'
7
9
 
@@ -149,7 +151,10 @@ export async function handleCreateMarkdown(
149
151
  return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
150
152
  }
151
153
  const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
152
- const filePath = `src/content/${collection}/${normalizedSlug}.${ext}`
154
+ const layout = isData ? 'flat' : await detectCollectionMarkdownLayout(collection)
155
+ const filePath = layout === 'index'
156
+ ? `src/content/${collection}/${normalizedSlug}/index.${ext}`
157
+ : `src/content/${collection}/${normalizedSlug}.${ext}`
153
158
  const fullPath = resolveAndValidatePath(filePath)
154
159
 
155
160
  let fileContent: string
@@ -275,6 +280,75 @@ function isDataFile(filePath: string): boolean {
275
280
  return filePath.endsWith('.json') || filePath.endsWith('.yaml') || filePath.endsWith('.yml')
276
281
  }
277
282
 
283
+ type MarkdownCollectionLayout = 'flat' | 'index'
284
+
285
+ async function detectCollectionMarkdownLayout(collection: string): Promise<MarkdownCollectionLayout> {
286
+ const existingLayout = await inferLayoutFromExistingEntries(collection)
287
+ if (existingLayout) return existingLayout
288
+
289
+ const configLayout = await inferLayoutFromContentConfig(collection)
290
+ if (configLayout) return configLayout
291
+
292
+ return 'flat'
293
+ }
294
+
295
+ async function inferLayoutFromExistingEntries(collection: string): Promise<MarkdownCollectionLayout | null> {
296
+ const collectionPath = path.join(getProjectRoot(), 'src', 'content', collection)
297
+
298
+ let dirEntries: Dirent[]
299
+ try {
300
+ dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
301
+ } catch {
302
+ return null
303
+ }
304
+
305
+ let flatCount = 0
306
+ let indexCount = 0
307
+ const flatSlugs = new Set<string>()
308
+
309
+ for (const entry of dirEntries) {
310
+ if (!entry.isFile()) continue
311
+ const match = entry.name.match(/^(.+)\.(md|mdx)$/)
312
+ if (!match) continue
313
+ flatCount++
314
+ flatSlugs.add(match[1]!)
315
+ }
316
+
317
+ const subdirs = dirEntries.filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
318
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
319
+ if (flatSlugs.has(dir.name)) return false
320
+ for (const ext of ['md', 'mdx'] as const) {
321
+ try {
322
+ await fs.access(path.join(collectionPath, dir.name, `index.${ext}`))
323
+ return true
324
+ } catch {
325
+ // try next extension
326
+ }
327
+ }
328
+ return false
329
+ }))
330
+ indexCount = indexLookups.filter(Boolean).length
331
+
332
+ if (indexCount > flatCount) return 'index'
333
+ if (flatCount > 0) return 'flat'
334
+ return null
335
+ }
336
+
337
+ async function inferLayoutFromContentConfig(collection: string): Promise<MarkdownCollectionLayout | null> {
338
+ try {
339
+ const parsed = await parseContentConfig()
340
+ const pattern = parsed.get(collection)?.loaderPattern
341
+ if (!pattern) return null
342
+ return isIndexStyleGlobPattern(pattern) ? 'index' : 'flat'
343
+ } catch {
344
+ return null
345
+ }
346
+ }
347
+
348
+ function isIndexStyleGlobPattern(pattern: string): boolean {
349
+ return pattern.includes('index.{') || pattern.includes('*/index') || pattern.includes('**/index')
350
+ }
351
+
278
352
  function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
279
353
  const trimmed = raw.trimStart()
280
354
  if (!trimmed.startsWith('---')) {
@@ -1,4 +1,6 @@
1
1
  import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
2
+ import path from 'node:path'
3
+ import { getProjectRoot } from './config'
2
4
  import { processSeoFromHtml } from './seo-processor'
3
5
 
4
6
  import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
@@ -283,6 +285,7 @@ export async function processHtml(
283
285
  const components: Record<string, ComponentInstance> = {}
284
286
  const sourceLocationMap = new Map<string, { file: string; line: number }>()
285
287
  const markedComponentRoots = new Set<HTMLNode>()
288
+ const markdownRegionPaths = new Map<string, string>()
286
289
  let collectionWrapperId: string | undefined
287
290
  const componentCountPerParent = new Map<string, Map<string, number>>()
288
291
 
@@ -462,6 +465,7 @@ export async function processHtml(
462
465
  // Collection wrapper detection pass: find the element that wraps markdown content
463
466
  // This needs to run BEFORE image marking so we can skip images inside markdown
464
467
  let markdownWrapperNode: HTMLNode | null = null
468
+ const markdownWrapperNodes = new Set<HTMLNode>()
465
469
 
466
470
  // Three strategies in priority order:
467
471
  // 0. Rehype marker: the rehype-cms-marker plugin marks the first rendered element
@@ -473,16 +477,24 @@ export async function processHtml(
473
477
  let foundWrapper = false
474
478
 
475
479
  // Strategy 0: Rehype marker — most reliable
476
- const markerEl = root.querySelector('[data-cms-markdown-content]')
477
- if (markerEl) {
480
+ const markerEls = root.querySelectorAll('[data-cms-markdown-content]')
481
+ for (const markerEl of markerEls) {
482
+ const markerPath = markerEl.getAttribute('data-cms-markdown-content') ?? ''
478
483
  markerEl.removeAttribute('data-cms-markdown-content')
479
484
  const parent = markerEl.parentNode as HTMLNode | null
480
485
  if (parent && parent.tagName) {
481
486
  const id = getNextId()
482
487
  parent.setAttribute(attributeName, id)
483
488
  parent.setAttribute('data-cms-markdown', 'true')
484
- collectionWrapperId = id
485
- markdownWrapperNode = parent
489
+ if (markerPath) {
490
+ const relPath = path.relative(getProjectRoot(), markerPath).split(path.sep).join('/').replace(/\\/g, '/')
491
+ markdownRegionPaths.set(id, relPath)
492
+ }
493
+ if (!collectionWrapperId) {
494
+ collectionWrapperId = id
495
+ markdownWrapperNode = parent
496
+ }
497
+ markdownWrapperNodes.add(parent)
486
498
  foundWrapper = true
487
499
  }
488
500
  }
@@ -517,6 +529,7 @@ export async function processHtml(
517
529
  node.setAttribute('data-cms-markdown', 'true')
518
530
  collectionWrapperId = id
519
531
  markdownWrapperNode = node
532
+ markdownWrapperNodes.add(node)
520
533
  foundWrapper = true
521
534
  }
522
535
  }
@@ -584,6 +597,7 @@ export async function processHtml(
584
597
  bestWrapper.setAttribute('data-cms-markdown', 'true')
585
598
  collectionWrapperId = id
586
599
  markdownWrapperNode = bestWrapper
600
+ markdownWrapperNodes.add(bestWrapper)
587
601
  foundWrapper = true
588
602
  }
589
603
  }
@@ -635,6 +649,7 @@ export async function processHtml(
635
649
  best.node.setAttribute('data-cms-markdown', 'true')
636
650
  collectionWrapperId = id
637
651
  markdownWrapperNode = best.node
652
+ markdownWrapperNodes.add(best.node)
638
653
  foundWrapper = true
639
654
  }
640
655
  }
@@ -644,10 +659,10 @@ export async function processHtml(
644
659
 
645
660
  // Helper function to check if a node is inside the markdown wrapper
646
661
  const isInsideMarkdownWrapper = (node: HTMLNode): boolean => {
647
- if (!markdownWrapperNode) return false
662
+ if (!markdownWrapperNode && markdownWrapperNodes.size === 0) return false
648
663
  let current = node.parentNode as HTMLNode | null
649
664
  while (current) {
650
- if (current === markdownWrapperNode) return true
665
+ if (current === markdownWrapperNode || markdownWrapperNodes.has(current)) return true
651
666
  current = current.parentNode as HTMLNode | null
652
667
  }
653
668
  return false
@@ -998,7 +1013,7 @@ export async function processHtml(
998
1013
  // Add collection info for the wrapper entry
999
1014
  collectionName: isCollectionWrapper ? collectionInfo?.name : undefined,
1000
1015
  collectionSlug: isCollectionWrapper ? collectionInfo?.slug : undefined,
1001
- contentPath: isCollectionWrapper ? collectionInfo?.contentPath : undefined,
1016
+ contentPath: markdownRegionPaths.get(id) ?? (isCollectionWrapper ? collectionInfo?.contentPath : undefined),
1002
1017
  // Robustness fields
1003
1018
  stableId,
1004
1019
  // Image metadata for image entries
package/src/index.ts CHANGED
@@ -34,6 +34,9 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
34
34
  theme?: Record<string, string>
35
35
  themePreset?: string
36
36
  features?: CmsFeatures
37
+ listStyles?: Array<{ label: string; class: string }>
38
+ /** Open the entry editor's metadata (frontmatter) panel by default instead of collapsed. */
39
+ openMetadataByDefault?: boolean
37
40
  /**
38
41
  * Describes the host site's color theme. The CMS draws editor chrome and outlines
39
42
  * in a contrasting color. 'auto' (default) detects via prefers-color-scheme and
@@ -58,10 +61,12 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
58
61
  */
59
62
  mdxComponentDirs?: string[]
60
63
  /**
61
- * Per-collection field overrides for position and grouping.
64
+ * Per-collection overrides for the CMS browser.
62
65
  * Highest priority — overrides scanner defaults and frontmatter comment directives.
63
66
  */
64
67
  collections?: Record<string, {
68
+ /** Display label shown in the CMS (overrides the name-derived default, e.g. "Jsem Otazky" → "Otázky"). */
69
+ label?: string
65
70
  fields?: Record<string, { position?: 'sidebar' | 'header'; group?: string }>
66
71
  }>
67
72
  /**
@@ -185,7 +190,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
185
190
  if (options.collections) {
186
191
  for (const [collectionName, overrides] of Object.entries(options.collections)) {
187
192
  const def = collectionDefinitions[collectionName]
188
- if (!def || !overrides.fields) continue
193
+ if (!def) continue
194
+ if (overrides.label) def.label = overrides.label
195
+ if (!overrides.fields) continue
189
196
  for (const field of def.fields) {
190
197
  const fieldOverride = overrides.fields[field.name]
191
198
  if (!fieldOverride) continue