@nuasite/cms 0.41.0 → 0.42.1

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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.41.0",
17
+ "version": "0.42.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -1,3 +1,4 @@
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 { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
@@ -330,7 +331,7 @@ function collectFieldObservations(
330
331
  }
331
332
  }
332
333
 
333
- function buildCollectionDefinition(
334
+ function assembleCollectionDefinition(
334
335
  collectionName: string,
335
336
  contentDir: string,
336
337
  fieldMap: Map<string, FieldObservation>,
@@ -359,6 +360,84 @@ function buildCollectionDefinition(
359
360
  }
360
361
  }
361
362
 
363
+ function getCollectionSourceBasePath(basePath: string, collectionName: string, contentDir: string): string {
364
+ const projectRoot = getProjectRoot()
365
+ const defaultContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir)
366
+ const defaultCollectionPath = path.join(defaultContentDir, collectionName)
367
+ if (path.resolve(basePath) === path.resolve(defaultCollectionPath)) {
368
+ return path.join(contentDir, collectionName)
369
+ }
370
+
371
+ const relativeBase = path.relative(projectRoot, basePath)
372
+ if (relativeBase && !relativeBase.startsWith('..') && !path.isAbsolute(relativeBase)) {
373
+ return relativeBase
374
+ }
375
+ return basePath
376
+ }
377
+
378
+ async function buildCollectionDefinition(
379
+ basePath: string,
380
+ sources: Array<{ slug: string; relPath: string }>,
381
+ collectionName: string,
382
+ contentDir: string,
383
+ ): Promise<CollectionDefinition | null> {
384
+ if (sources.length === 0) return null
385
+
386
+ const sourceBasePath = getCollectionSourceBasePath(basePath, collectionName, contentDir)
387
+ const hasMd = sources.some(s => s.relPath.endsWith('.md'))
388
+ const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
389
+
390
+ const fieldMap = new Map<string, FieldObservation>()
391
+ const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
392
+ const entryInfos: CollectionEntryInfo[] = []
393
+ let hasDraft = false
394
+
395
+ const fileContents = await Promise.all(
396
+ sources.map(s => fs.readFile(path.join(basePath, s.relPath), 'utf-8')),
397
+ )
398
+
399
+ for (let i = 0; i < sources.length; i++) {
400
+ const source = sources[i]!
401
+ const content = fileContents[i]!
402
+ const frontmatter = parseFrontmatter(content)
403
+
404
+ const directives = parseFieldDirectives(content)
405
+ for (const [key, value] of Object.entries(directives)) {
406
+ if (!allDirectives[key]) {
407
+ allDirectives[key] = value
408
+ }
409
+ }
410
+
411
+ const entryInfo: CollectionEntryInfo = {
412
+ slug: source.slug,
413
+ sourcePath: path.join(sourceBasePath, source.relPath),
414
+ }
415
+ if (frontmatter) {
416
+ if (typeof frontmatter.title === 'string') {
417
+ entryInfo.title = frontmatter.title
418
+ }
419
+ if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
420
+ entryInfo.draft = true
421
+ }
422
+ entryInfo.data = frontmatter
423
+ }
424
+ entryInfos.push(entryInfo)
425
+
426
+ if (!frontmatter) continue
427
+
428
+ if (frontmatter.draft === true) hasDraft = true
429
+ collectFieldObservations(fieldMap, frontmatter, sources.length)
430
+ }
431
+
432
+ const def = assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
433
+ path: sourceBasePath,
434
+ supportsDraft: hasDraft,
435
+ fileExtension,
436
+ })
437
+ assignFieldMetadata(def.fields, allDirectives)
438
+ return def
439
+ }
440
+
362
441
  /**
363
442
  * Scan a single collection directory and infer its schema
364
443
  */
@@ -397,58 +476,86 @@ async function scanCollection(collectionPath: string, collectionName: string, co
397
476
  }
398
477
 
399
478
  if (sources.length === 0) return null
479
+ return await buildCollectionDefinition(collectionPath, sources, collectionName, contentDir)
480
+ } catch {
481
+ return null
482
+ }
483
+ }
400
484
 
401
- const hasMd = sources.some(s => s.relPath.endsWith('.md'))
402
- const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
403
-
404
- const fieldMap = new Map<string, FieldObservation>()
405
- const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
406
- const entryInfos: CollectionEntryInfo[] = []
407
- let hasDraft = false
408
-
409
- const fileContents = await Promise.all(
410
- sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
411
- )
412
-
413
- for (let i = 0; i < sources.length; i++) {
414
- const source = sources[i]!
415
- const content = fileContents[i]!
416
- const frontmatter = parseFrontmatter(content)
417
-
418
- const directives = parseFieldDirectives(content)
419
- for (const [key, value] of Object.entries(directives)) {
420
- if (!allDirectives[key]) {
421
- allDirectives[key] = value
422
- }
423
- }
424
-
425
- const entryInfo: CollectionEntryInfo = {
426
- slug: source.slug,
427
- sourcePath: path.join(contentDir, collectionName, source.relPath),
485
+ /** Convert a glob pattern (supports `*`, `**`, `?`, `{a,b}`) to an anchored RegExp. */
486
+ function globToRegExp(glob: string): RegExp {
487
+ let re = ''
488
+ for (let i = 0; i < glob.length; i++) {
489
+ const c = glob[i]!
490
+ if (c === '*') {
491
+ if (glob[i + 1] === '*') {
492
+ re += '.*'
493
+ i++
494
+ if (glob[i + 1] === '/') i++
495
+ } else {
496
+ re += '[^/]*'
428
497
  }
429
- if (frontmatter) {
430
- if (typeof frontmatter.title === 'string') {
431
- entryInfo.title = frontmatter.title
432
- }
433
- if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
434
- entryInfo.draft = true
435
- }
436
- entryInfo.data = frontmatter
498
+ } else if (c === '?') {
499
+ re += '[^/]'
500
+ } else if (c === '{') {
501
+ const end = glob.indexOf('}', i)
502
+ if (end === -1) {
503
+ re += '\\{'
504
+ } else {
505
+ const opts = glob.slice(i + 1, end).split(',').map(s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
506
+ re += `(?:${opts.join('|')})`
507
+ i = end
437
508
  }
438
- entryInfos.push(entryInfo)
439
-
440
- if (!frontmatter) continue
509
+ } else if ('.+^$()|[]\\'.includes(c)) {
510
+ re += `\\${c}`
511
+ } else {
512
+ re += c
513
+ }
514
+ }
515
+ return new RegExp(`^${re}$`)
516
+ }
441
517
 
442
- if (frontmatter.draft === true) hasDraft = true
443
- collectFieldObservations(fieldMap, frontmatter, sources.length)
518
+ /** Recursively list files under `dir`, returning forward-slash paths relative to `dir`. */
519
+ async function walkFiles(dir: string, prefix = ''): Promise<string[]> {
520
+ let dirEntries: Dirent[]
521
+ try {
522
+ dirEntries = await fs.readdir(dir, { withFileTypes: true })
523
+ } catch {
524
+ return []
525
+ }
526
+ const out: string[] = []
527
+ for (const entry of dirEntries) {
528
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
529
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
530
+ if (entry.isDirectory()) {
531
+ out.push(...await walkFiles(path.join(dir, entry.name), rel))
532
+ } else if (entry.isFile()) {
533
+ out.push(rel)
444
534
  }
535
+ }
536
+ return out
537
+ }
445
538
 
446
- const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
447
- supportsDraft: hasDraft,
448
- fileExtension,
449
- })
450
- assignFieldMetadata(def.fields, allDirectives)
451
- return def
539
+ /**
540
+ * Scan a collection declared in content config via a glob loader (base + pattern),
541
+ * which may share a base directory with another collection (nested layout).
542
+ * Runtime-agnostic: walks the filesystem and matches the glob (no Bun.Glob dependency).
543
+ */
544
+ async function scanGlobCollection(
545
+ collectionName: string,
546
+ baseRel: string,
547
+ pattern: string,
548
+ contentDir: string,
549
+ ): Promise<CollectionDefinition | null> {
550
+ try {
551
+ const absBase = path.join(getProjectRoot(), baseRel)
552
+ const matcher = globToRegExp(pattern)
553
+ const sources = (await walkFiles(absBase))
554
+ .filter(rel => (rel.endsWith('.md') || rel.endsWith('.mdx')) && matcher.test(rel))
555
+ .map(rel => ({ slug: rel.replace(/\.(md|mdx)$/, ''), relPath: rel }))
556
+
557
+ if (sources.length === 0) return null
558
+ return await buildCollectionDefinition(absBase, sources, collectionName, contentDir)
452
559
  } catch {
453
560
  return null
454
561
  }
@@ -820,7 +927,7 @@ async function scanDataCollection(collectionPath: string, collectionName: string
820
927
  collectFieldObservations(fieldMap, data, sources.length)
821
928
  }
822
929
 
823
- return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
930
+ return assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
824
931
  type: 'data',
825
932
  fileExtension: ext,
826
933
  })
@@ -859,6 +966,20 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
859
966
 
860
967
  // Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
861
968
  const parsed = await parseContentConfig()
969
+ for (const [collectionName, parsedCollection] of parsed) {
970
+ if (collections[collectionName]) continue
971
+ if (!parsedCollection.loaderBase || !parsedCollection.loaderPattern) continue
972
+ const definition = await scanGlobCollection(collectionName, parsedCollection.loaderBase, parsedCollection.loaderPattern, contentDir)
973
+ if (!definition) continue
974
+ // Nest under the collection that owns the shared base directory (e.g. jsem-otazky -> jsem),
975
+ // so the CMS browser can group it under its parent page instead of listing it flat.
976
+ const baseName = parsedCollection.loaderBase.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
977
+ if (baseName && baseName !== collectionName && collections[baseName]) {
978
+ definition.parentCollection = baseName
979
+ }
980
+ collections[collectionName] = definition
981
+ }
982
+
862
983
  applyParsedConfig(collections, parsed)
863
984
  detectReferenceFields(collections, parsed)
864
985
  detectDerivedHrefFields(collections)
@@ -29,6 +29,8 @@ export interface ParsedField {
29
29
  export interface ParsedCollection {
30
30
  name: string
31
31
  fields: ParsedField[]
32
+ loaderPattern?: string
33
+ loaderBase?: string
32
34
  }
33
35
 
34
36
  export type ParsedConfig = Map<string, ParsedCollection>
@@ -129,6 +131,7 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
129
131
  const bindings: Bindings = new Map()
130
132
  const collectionDecls = new Map<string, t.ObjectExpression>()
131
133
  const exportMap = new Map<string, string>() // varName → collectionName
134
+ const inlineCollections = new Map<string, t.ObjectExpression>() // collectionName → defineCollection arg (inline form)
132
135
 
133
136
  for (const stmt of ast.program.body) {
134
137
  const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
@@ -151,6 +154,12 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
151
154
  if (!key) continue
152
155
  if (prop.value.type === 'Identifier') {
153
156
  exportMap.set(prop.value.name, key)
157
+ } else if (prop.value.type === 'CallExpression' && isDefineCollectionCallee(prop.value.callee)) {
158
+ // Inline form: `collections = { name: defineCollection({...}) }`
159
+ const inlineArg = prop.value.arguments[0]
160
+ if (inlineArg?.type === 'ObjectExpression') {
161
+ inlineCollections.set(key, inlineArg)
162
+ }
154
163
  }
155
164
  }
156
165
  continue
@@ -165,23 +174,57 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
165
174
  }
166
175
  }
167
176
 
177
+ // Unify both styles: inline `name: defineCollection({...})` and the
178
+ // `const x = defineCollection({...}); collections = { name: x }` reference form.
179
+ const collectionObjects = new Map<string, t.ObjectExpression>(inlineCollections)
168
180
  for (const [varName, collectionName] of exportMap) {
169
181
  const decl = collectionDecls.get(varName)
170
- if (!decl) continue
182
+ if (decl) collectionObjects.set(collectionName, decl)
183
+ }
184
+
185
+ for (const [collectionName, decl] of collectionObjects) {
186
+ const loaderProperty = decl.properties.find(
187
+ p =>
188
+ p.type === 'ObjectProperty'
189
+ && propertyKeyName(p.key) === 'loader',
190
+ ) as t.ObjectProperty | undefined
191
+ const loaderOptions = loaderProperty ? extractGlobLoaderOptions(loaderProperty.value, bindings) : {}
192
+ const loaderPattern = loaderOptions.pattern
193
+ const loaderBase = loaderOptions.base
171
194
 
172
195
  const schemaProperty = decl.properties.find(
173
196
  p =>
174
197
  p.type === 'ObjectProperty'
175
198
  && propertyKeyName(p.key) === 'schema',
176
199
  ) as t.ObjectProperty | undefined
177
- if (!schemaProperty) continue
200
+ if (!schemaProperty) {
201
+ if (!loaderPattern) continue
202
+ result.set(collectionName, {
203
+ name: collectionName,
204
+ fields: [],
205
+ loaderPattern,
206
+ loaderBase,
207
+ })
208
+ continue
209
+ }
178
210
 
179
211
  const schemaObject = unwrapSchemaToObject(schemaProperty.value, bindings)
180
- if (!schemaObject) continue
212
+ if (!schemaObject) {
213
+ if (!loaderPattern) continue
214
+ result.set(collectionName, {
215
+ name: collectionName,
216
+ fields: [],
217
+ loaderPattern,
218
+ loaderBase,
219
+ })
220
+ continue
221
+ }
181
222
 
182
223
  result.set(collectionName, {
183
224
  name: collectionName,
184
225
  fields: parseSchemaFields(schemaObject, bindings),
226
+ loaderPattern,
227
+ loaderBase,
185
228
  })
186
229
  }
187
230
 
@@ -198,6 +241,41 @@ function propertyKeyName(key: t.Node): string | null {
198
241
  return null
199
242
  }
200
243
 
244
+ function extractGlobLoaderOptions(node: t.Node, bindings: Bindings): { pattern?: string; base?: string } {
245
+ const resolved = resolveExpression(node, bindings)
246
+ if (resolved.type !== 'CallExpression') return {}
247
+ if (!isGlobCallee(resolved.callee)) return {}
248
+
249
+ const arg = resolved.arguments[0]
250
+ if (!arg) return {}
251
+ const options = resolveExpression(arg, bindings)
252
+ if (options.type !== 'ObjectExpression') return {}
253
+
254
+ const result: { pattern?: string; base?: string } = {}
255
+ for (const prop of options.properties) {
256
+ if (prop.type !== 'ObjectProperty') continue
257
+ const key = propertyKeyName(prop.key)
258
+ if (key !== 'pattern' && key !== 'base') continue
259
+ const value = extractStaticString(prop.value, bindings)
260
+ if (value !== undefined) result[key] = value
261
+ }
262
+
263
+ return result
264
+ }
265
+
266
+ function extractStaticString(node: t.Node, bindings: Bindings): string | undefined {
267
+ const resolved = resolveExpression(node, bindings)
268
+ if (resolved.type === 'StringLiteral') return resolved.value
269
+ if (resolved.type === 'TemplateLiteral' && resolved.expressions.length === 0) {
270
+ return resolved.quasis[0]?.value.cooked ?? resolved.quasis[0]?.value.raw
271
+ }
272
+ return undefined
273
+ }
274
+
275
+ function isGlobCallee(callee: t.Node): boolean {
276
+ return callee.type === 'Identifier' && callee.name === 'glob'
277
+ }
278
+
201
279
  /**
202
280
  * Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
203
281
  * Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
@@ -1,6 +1,7 @@
1
1
  import { signal } from '@preact/signals'
2
2
  import { useMemo, useState } from 'preact/hooks'
3
3
  import { useSearchFilter } from '../hooks/useSearchFilter'
4
+ import { cn } from '../lib/cn'
4
5
  import { deleteMarkdownPage } from '../markdown-api'
5
6
  import {
6
7
  closeCollectionsBrowser,
@@ -241,20 +242,35 @@ export function CollectionsBrowser() {
241
242
  )
242
243
  }
243
244
 
244
- // Collection list
245
+ // Collection list — group nested (child) collections under their parent
246
+ const ordered: Array<{ col: typeof collections[number]; nested: boolean }> = []
247
+ for (const col of collections.filter(c => !c.parentCollection)) {
248
+ ordered.push({ col, nested: false })
249
+ for (const child of collections.filter(c => c.parentCollection === col.name)) {
250
+ ordered.push({ col: child, nested: true })
251
+ }
252
+ }
253
+ // Append any orphaned children whose parent isn't present, so nothing is hidden.
254
+ for (const col of collections.filter(c => c.parentCollection && !collections.some(p => p.name === c.parentCollection))) {
255
+ ordered.push({ col, nested: false })
256
+ }
257
+
245
258
  return (
246
259
  <ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
247
260
  <ModalHeader title="Collections" onClose={handleClose} />
248
261
  <div class="p-5 space-y-2 overflow-y-auto flex-1 min-h-0">
249
- {collections.map((col) => (
262
+ {ordered.map(({ col, nested }) => (
250
263
  <button
251
264
  key={col.name}
252
265
  type="button"
253
266
  onClick={() => selectBrowserCollection(col.name)}
254
- class="group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
267
+ class={cn(
268
+ 'group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer',
269
+ nested && 'ml-8 w-[calc(100%-2rem)] border-l-2 border-l-cms-primary/40',
270
+ )}
255
271
  data-cms-ui
256
272
  >
257
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center">
273
+ <div class={cn('shrink-0 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center', nested ? 'w-8 h-8' : 'w-10 h-10')}>
258
274
  <CollectionIcon />
259
275
  </div>
260
276
  <div class="flex-1 min-w-0">
@@ -573,7 +573,35 @@ export function SchemaFrontmatterField({
573
573
  </div>
574
574
  )
575
575
 
576
- case 'date':
576
+ case 'date': {
577
+ // A `date` field's value is often a full datetime (e.g. "2026-04-14T08:35:00"),
578
+ // which an <input type="date"> can't display (it needs YYYY-MM-DD). Show only the
579
+ // date part, but preserve the original time component on change so editing the date
580
+ // doesn't silently drop the time.
581
+ const raw = value == null ? '' : String(value)
582
+ // Preserve the full time component on change — including fractional seconds and any
583
+ // timezone designator (Z or ±HH:MM) — so editing only the date never drops them.
584
+ const timeSuffix = raw.match(/T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?/)?.[0] ?? ''
585
+ return (
586
+ <div class="flex flex-col gap-1" data-cms-ui>
587
+ <label class="text-xs text-white/60 font-medium">{label}</label>
588
+ <input
589
+ type="date"
590
+ value={raw.slice(0, 10)}
591
+ min={hints?.min != null ? String(hints.min) : undefined}
592
+ max={hints?.max != null ? String(hints.max) : undefined}
593
+ required={field.required}
594
+ onInput={(e) => {
595
+ const d = (e.target as HTMLInputElement).value
596
+ onChange(d ? `${d}${timeSuffix}` : '')
597
+ }}
598
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
599
+ data-cms-ui
600
+ />
601
+ </div>
602
+ )
603
+ }
604
+
577
605
  case 'datetime':
578
606
  case 'time':
579
607
  case 'month':
@@ -60,7 +60,7 @@ export function MarkdownEditorOverlay() {
60
60
  : activeCollectionDef?.fileExtension === 'mdx'
61
61
 
62
62
  const [isSaving, setIsSaving] = useState(false)
63
- const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection)
63
+ const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection || config.value.openMetadataByDefault === true)
64
64
  // Track whether the user has manually edited the slug (disables auto-slug from title)
65
65
  const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
66
66
  // Preview mode state
@@ -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
  }