@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.
- package/dist/editor.js +11397 -11351
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +5 -13
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +1 -9
- package/src/editor/components/fields.tsx +134 -116
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +31 -37
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- package/src/editor/components/prop-editor.tsx +67 -68
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/text-style-toolbar.tsx +2 -15
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +8 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
330
|
+
const sources: Array<{ slug: string; relPath: string }> = []
|
|
331
|
+
const takenSlugs = new Set<string>()
|
|
332
332
|
|
|
333
|
-
const
|
|
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
|
-
|
|
371
|
+
sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
|
|
343
372
|
)
|
|
344
373
|
|
|
345
|
-
for (let i = 0; i <
|
|
346
|
-
const
|
|
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,
|
|
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,
|
|
404
|
+
collectFieldObservations(fieldMap, frontmatter, sources.length)
|
|
377
405
|
}
|
|
378
406
|
|
|
379
|
-
const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos,
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
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 =
|
|
953
|
+
const ext = sources.some(s => s.relPath.endsWith('.json'))
|
|
896
954
|
? 'json' as const
|
|
897
|
-
:
|
|
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
|
-
|
|
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 <
|
|
906
|
-
const
|
|
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 =
|
|
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({
|
|
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,
|
|
983
|
+
collectFieldObservations(fieldMap, data, sources.length)
|
|
922
984
|
}
|
|
923
985
|
|
|
924
|
-
return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos,
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 =
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
</
|
|
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.
|