@nuasite/cms 0.29.0 → 0.31.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.
Files changed (42) hide show
  1. package/dist/editor.js +11397 -11351
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +87 -25
  4. package/src/editor/components/attribute-editor.tsx +2 -10
  5. package/src/editor/components/bg-image-overlay.tsx +2 -10
  6. package/src/editor/components/collections-browser.tsx +5 -13
  7. package/src/editor/components/color-toolbar.tsx +2 -9
  8. package/src/editor/components/confirm-dialog.tsx +4 -12
  9. package/src/editor/components/create-page-modal.tsx +1 -9
  10. package/src/editor/components/fields.tsx +134 -116
  11. package/src/editor/components/image-overlay.tsx +3 -14
  12. package/src/editor/components/link-edit-popover.tsx +3 -6
  13. package/src/editor/components/markdown-editor-overlay.tsx +31 -37
  14. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  15. package/src/editor/components/mdx-component-picker.tsx +3 -6
  16. package/src/editor/components/media-library.tsx +15 -37
  17. package/src/editor/components/modal-shell.tsx +34 -5
  18. package/src/editor/components/plain-text-chip-utils.ts +14 -0
  19. package/src/editor/components/plain-text-chip.tsx +61 -0
  20. package/src/editor/components/prop-editor.tsx +67 -68
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/text-style-toolbar.tsx +2 -15
  25. package/src/editor/components/toolbar.tsx +2 -1
  26. package/src/editor/constants.ts +33 -0
  27. package/src/editor/dom.ts +37 -0
  28. package/src/editor/editor.ts +90 -5
  29. package/src/editor/hooks/index.ts +4 -0
  30. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  31. package/src/editor/hooks/useSearchFilter.ts +21 -0
  32. package/src/editor/index.tsx +9 -0
  33. package/src/handlers/source-writer.ts +75 -21
  34. package/src/html-processor.ts +75 -94
  35. package/src/index.ts +5 -0
  36. package/src/rehype-cms-marker.ts +15 -0
  37. package/src/source-finder/ast-extractors.ts +37 -0
  38. package/src/source-finder/cache.ts +23 -0
  39. package/src/source-finder/search-index.ts +304 -13
  40. package/src/source-finder/snippet-utils.ts +179 -2
  41. package/src/source-finder/types.ts +3 -0
  42. package/src/source-finder/variable-extraction.ts +8 -1
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.29.0",
17
+ "version": "0.31.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -325,12 +325,41 @@ function buildCollectionDefinition(
325
325
  */
326
326
  async function scanCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
327
327
  try {
328
- const entries = await fs.readdir(collectionPath, { withFileTypes: true })
329
- const markdownFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')))
328
+ const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
330
329
 
331
- if (markdownFiles.length === 0) return null
330
+ const sources: Array<{ slug: string; relPath: string }> = []
331
+ const takenSlugs = new Set<string>()
332
332
 
333
- const hasMd = markdownFiles.some(f => f.name.endsWith('.md'))
333
+ for (const entry of dirEntries) {
334
+ if (!entry.isFile()) continue
335
+ if (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) continue
336
+ const slug = entry.name.replace(/\.(md|mdx)$/, '')
337
+ sources.push({ slug, relPath: entry.name })
338
+ takenSlugs.add(slug)
339
+ }
340
+
341
+ // Hugo-style layout: <slug>/index.md(x). Flat files win on slug conflict.
342
+ const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
343
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
344
+ if (takenSlugs.has(dir.name)) return null
345
+ for (const ext of ['md', 'mdx'] as const) {
346
+ const relPath = path.join(dir.name, `index.${ext}`)
347
+ try {
348
+ await fs.access(path.join(collectionPath, relPath))
349
+ return { slug: dir.name, relPath }
350
+ } catch {
351
+ // try next extension
352
+ }
353
+ }
354
+ return null
355
+ }))
356
+ for (const entry of indexLookups) {
357
+ if (entry) sources.push(entry)
358
+ }
359
+
360
+ if (sources.length === 0) return null
361
+
362
+ const hasMd = sources.some(s => s.relPath.endsWith('.md'))
334
363
  const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
335
364
 
336
365
  const fieldMap = new Map<string, FieldObservation>()
@@ -339,11 +368,11 @@ async function scanCollection(collectionPath: string, collectionName: string, co
339
368
  let hasDraft = false
340
369
 
341
370
  const fileContents = await Promise.all(
342
- markdownFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8')),
371
+ sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
343
372
  )
344
373
 
345
- for (let i = 0; i < markdownFiles.length; i++) {
346
- const file = markdownFiles[i]!
374
+ for (let i = 0; i < sources.length; i++) {
375
+ const source = sources[i]!
347
376
  const content = fileContents[i]!
348
377
  const frontmatter = parseFrontmatter(content)
349
378
 
@@ -354,10 +383,9 @@ async function scanCollection(collectionPath: string, collectionName: string, co
354
383
  }
355
384
  }
356
385
 
357
- const slug = file.name.replace(/\.(md|mdx)$/, '')
358
386
  const entryInfo: CollectionEntryInfo = {
359
- slug,
360
- sourcePath: path.join(contentDir, collectionName, file.name),
387
+ slug: source.slug,
388
+ sourcePath: path.join(contentDir, collectionName, source.relPath),
361
389
  }
362
390
  if (frontmatter) {
363
391
  if (typeof frontmatter.title === 'string') {
@@ -373,10 +401,10 @@ async function scanCollection(collectionPath: string, collectionName: string, co
373
401
  if (!frontmatter) continue
374
402
 
375
403
  if (frontmatter.draft === true) hasDraft = true
376
- collectFieldObservations(fieldMap, frontmatter, markdownFiles.length)
404
+ collectFieldObservations(fieldMap, frontmatter, sources.length)
377
405
  }
378
406
 
379
- const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, markdownFiles.length, {
407
+ const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
380
408
  supportsDraft: hasDraft,
381
409
  fileExtension,
382
410
  })
@@ -886,42 +914,76 @@ function detectDerivedHrefFields(collections: Record<string, CollectionDefinitio
886
914
  */
887
915
  async function scanDataCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
888
916
  try {
889
- const entries = await fs.readdir(collectionPath, { withFileTypes: true })
890
- const dataFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.json') || e.name.endsWith('.yaml') || e.name.endsWith('.yml')))
891
- if (dataFiles.length === 0) return null
917
+ const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
918
+
919
+ const sources: Array<{ slug: string; relPath: string }> = []
920
+ const takenSlugs = new Set<string>()
921
+
922
+ for (const entry of dirEntries) {
923
+ if (!entry.isFile()) continue
924
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml')) continue
925
+ const slug = entry.name.replace(/\.(json|ya?ml)$/, '')
926
+ sources.push({ slug, relPath: entry.name })
927
+ takenSlugs.add(slug)
928
+ }
929
+
930
+ // Hugo-style layout: <slug>/index.{json,yaml,yml}. Flat files win on slug conflict.
931
+ const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
932
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
933
+ if (takenSlugs.has(dir.name)) return null
934
+ for (const indexExt of ['json', 'yaml', 'yml'] as const) {
935
+ const relPath = path.join(dir.name, `index.${indexExt}`)
936
+ try {
937
+ await fs.access(path.join(collectionPath, relPath))
938
+ return { slug: dir.name, relPath }
939
+ } catch {
940
+ // try next extension
941
+ }
942
+ }
943
+ return null
944
+ }))
945
+ for (const entry of indexLookups) {
946
+ if (entry) sources.push(entry)
947
+ }
948
+
949
+ if (sources.length === 0) return null
892
950
 
893
951
  const fieldMap = new Map<string, FieldObservation>()
894
952
  const entryInfos: CollectionEntryInfo[] = []
895
- const ext = dataFiles.some(file => file.name.endsWith('.json'))
953
+ const ext = sources.some(s => s.relPath.endsWith('.json'))
896
954
  ? 'json' as const
897
- : dataFiles.some(file => file.name.endsWith('.yaml'))
955
+ : sources.some(s => s.relPath.endsWith('.yaml'))
898
956
  ? 'yaml' as const
899
957
  : 'yml' as const
900
958
 
901
959
  const fileContents = await Promise.all(
902
- dataFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8').catch(() => null)),
960
+ sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8').catch(() => null)),
903
961
  )
904
962
 
905
- for (let i = 0; i < dataFiles.length; i++) {
906
- const file = dataFiles[i]!
963
+ for (let i = 0; i < sources.length; i++) {
964
+ const source = sources[i]!
907
965
  const raw = fileContents[i]!
908
966
  if (raw === null) continue
909
967
  let data: Record<string, unknown> | null = null
910
968
  try {
911
- data = file.name.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
969
+ data = source.relPath.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
912
970
  } catch {
913
971
  continue
914
972
  }
915
973
  if (!data || typeof data !== 'object') continue
916
974
 
917
- const slug = file.name.replace(/\.(json|ya?ml)$/, '')
918
975
  const title = typeof data.name === 'string' ? data.name : typeof data.title === 'string' ? data.title : undefined
919
- entryInfos.push({ slug, title, sourcePath: path.join(contentDir, collectionName, file.name), data })
976
+ entryInfos.push({
977
+ slug: source.slug,
978
+ title,
979
+ sourcePath: path.join(contentDir, collectionName, source.relPath),
980
+ data,
981
+ })
920
982
 
921
- collectFieldObservations(fieldMap, data, dataFiles.length)
983
+ collectFieldObservations(fieldMap, data, sources.length)
922
984
  }
923
985
 
924
- return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, dataFiles.length, {
986
+ return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
925
987
  type: 'data',
926
988
  fileExtension: ext,
927
989
  })
@@ -4,6 +4,7 @@ import * as signals from '../signals'
4
4
  import { saveAttributeEditsToStorage } from '../storage'
5
5
  import type { Attribute } from '../types'
6
6
  import { ComboBoxField, FieldLabel, ImageField, NumberField, SelectField, TextField, ToggleField } from './fields'
7
+ import { CloseButton } from './modal-shell'
7
8
 
8
9
  // ============================================================================
9
10
  // Attribute Field Configuration
@@ -542,16 +543,7 @@ export function AttributeEditor({ onClose }: AttributeEditorProps) {
542
543
  </span>
543
544
  )}
544
545
  </div>
545
- <button
546
- type="button"
547
- onClick={handleClose}
548
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
549
- data-cms-ui
550
- >
551
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
552
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
553
- </svg>
554
- </button>
546
+ <CloseButton onClick={handleClose} size="sm" />
555
547
  </div>
556
548
 
557
549
  {/* Content */}
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
5
5
  import * as signals from '../signals'
6
6
  import { saveBgImageEditsToStorage } from '../storage'
7
7
  import { FieldLabel, ImageField, SelectField } from './fields'
8
+ import { CloseButton } from './modal-shell'
8
9
 
9
10
  export interface BgImageOverlayProps {
10
11
  visible: boolean
@@ -232,16 +233,7 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
232
233
  </span>
233
234
  )}
234
235
  </div>
235
- <button
236
- type="button"
237
- onClick={handleClose}
238
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
239
- data-cms-ui
240
- >
241
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
243
- </svg>
244
- </button>
236
+ <CloseButton onClick={handleClose} size="sm" />
245
237
  </div>
246
238
 
247
239
  {/* Content */}
@@ -1,5 +1,6 @@
1
1
  import { signal } from '@preact/signals'
2
2
  import { useMemo, useState } from 'preact/hooks'
3
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
4
  import { deleteMarkdownPage } from '../markdown-api'
4
5
  import {
5
6
  closeCollectionsBrowser,
@@ -12,7 +13,7 @@ import {
12
13
  selectedBrowserCollection,
13
14
  } from '../signals'
14
15
  import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
15
- import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
16
+ import { CloseButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
16
17
 
17
18
  const deletingEntry = signal<string | null>(null)
18
19
  const confirmDeleteSlug = signal<string | null>(null)
@@ -33,11 +34,7 @@ export function CollectionsBrowser() {
33
34
  const selectedDef = selected ? collectionDefinitions[selected] : undefined
34
35
  const entries = selectedDef?.entries ?? EMPTY_ENTRIES
35
36
 
36
- const filteredEntries = useMemo(() => {
37
- if (!search) return entries
38
- const q = search.toLowerCase()
39
- return entries.filter(e => (e.title || '').toLowerCase().includes(q) || e.slug.toLowerCase().includes(q))
40
- }, [entries, search])
37
+ const filteredEntries = useSearchFilter(entries, search, e => `${e.title ?? ''} ${e.slug}`)
41
38
 
42
39
  if (!visible) return null
43
40
 
@@ -116,14 +113,9 @@ export function CollectionsBrowser() {
116
113
  <h2 class="text-lg font-semibold text-white">{def.label}</h2>
117
114
  </div>
118
115
  <div class="flex items-center gap-2">
119
- <button
120
- type="button"
121
- onClick={handleAddNew}
122
- class="px-3 py-1.5 text-sm font-medium text-black bg-cms-primary hover:bg-cms-primary/80 rounded-cms-pill transition-colors"
123
- data-cms-ui
124
- >
116
+ <PrimaryButton onClick={handleAddNew} className="px-3 py-1.5">
125
117
  + Add New
126
- </button>
118
+ </PrimaryButton>
127
119
  <CloseButton onClick={handleClose} />
128
120
  </div>
129
121
  </div>
@@ -12,6 +12,7 @@ import { CSS, Z_INDEX } from '../constants'
12
12
  import { cn } from '../lib/cn'
13
13
  import * as signals from '../signals'
14
14
  import type { Attribute, AvailableColors } from '../types'
15
+ import { CloseButton } from './modal-shell'
15
16
 
16
17
  export interface ColorToolbarProps {
17
18
  visible: boolean
@@ -247,15 +248,7 @@ export function ColorToolbar({
247
248
  {/* Header */}
248
249
  <div class="flex items-center justify-between">
249
250
  <span class="font-medium text-white">Element Colors</span>
250
- <button
251
- type="button"
252
- onClick={onClose}
253
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
254
- >
255
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
256
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
257
- </svg>
258
- </button>
251
+ {onClose && <CloseButton onClick={onClose} size="sm" />}
259
252
  </div>
260
253
 
261
254
  {/* Background color section */}
@@ -1,6 +1,6 @@
1
1
  import { cn } from '../lib/cn'
2
2
  import { confirmDialogState } from '../signals'
3
- import { ModalBackdrop } from './modal-shell'
3
+ import { CancelButton, ModalBackdrop, ModalFooter } from './modal-shell'
4
4
 
5
5
  export function ConfirmDialog() {
6
6
  const state = confirmDialogState.value
@@ -27,16 +27,8 @@ export function ConfirmDialog() {
27
27
  <p class="text-sm text-white/70 leading-relaxed">{state.message}</p>
28
28
  </div>
29
29
 
30
- {/* Footer */}
31
- <div class="flex items-center justify-end gap-3 p-5 pt-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
32
- <button
33
- type="button"
34
- onClick={handleCancel}
35
- class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
36
- data-cms-ui
37
- >
38
- {state.cancelLabel}
39
- </button>
30
+ <ModalFooter>
31
+ <CancelButton onClick={handleCancel} label={state.cancelLabel} />
40
32
  <button
41
33
  type="button"
42
34
  onClick={handleConfirm}
@@ -50,7 +42,7 @@ export function ConfirmDialog() {
50
42
  >
51
43
  {state.confirmLabel}
52
44
  </button>
53
- </div>
45
+ </ModalFooter>
54
46
  </ModalBackdrop>
55
47
  )
56
48
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from '../signals'
15
15
  import type { LayoutInfo } from '../types'
16
16
  import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
17
+ import { Spinner } from './spinner'
17
18
 
18
19
  export function CreatePageModal() {
19
20
  const visible = isCreatePageOpen.value
@@ -494,15 +495,6 @@ function PageCreatingOverlay({ phase, slug }: { phase: 'creating' | 'preparing';
494
495
  )
495
496
  }
496
497
 
497
- function Spinner() {
498
- return (
499
- <svg class="w-8 h-8 animate-spin text-cms-primary" viewBox="0 0 24 24" fill="none" data-cms-ui>
500
- <circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
501
- <path class="opacity-80" d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
502
- </svg>
503
- )
504
- }
505
-
506
498
  /**
507
499
  * Poll a URL until the dev server returns a non-404 response,
508
500
  * so navigation doesn't land on a 404 while Astro processes the new file.