@nuasite/cms 0.5.0 → 0.6.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/README.md +11 -11
- package/dist/editor.js +4608 -4448
- package/package.json +1 -1
- package/src/build-processor.ts +38 -1
- package/src/dev-middleware.ts +47 -2
- package/src/editor/components/collections-browser.tsx +10 -6
- package/src/editor/components/fields.tsx +7 -7
- package/src/editor/components/frontmatter-fields.tsx +163 -2
- package/src/editor/components/seo-editor.tsx +2 -1
- package/src/editor/components/toast/toast.tsx +19 -2
- package/src/editor/dom.ts +16 -5
- package/src/editor/editor.ts +18 -1
- package/src/editor/index.tsx +4 -2
- package/src/editor/types.ts +2 -0
- package/src/handlers/array-ops.ts +102 -0
- package/src/index.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +42 -0
- package/src/source-finder/element-finder.ts +18 -4
- package/src/source-finder/snippet-utils.ts +62 -1
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +2 -2
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'node:fs/promises'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { getProjectRoot } from './config'
|
|
7
|
-
import {
|
|
7
|
+
import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
|
|
8
|
+
import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
|
|
8
9
|
import { extractComponentName, processHtml } from './html-processor'
|
|
9
10
|
import type { ManifestWriter } from './manifest-writer'
|
|
10
11
|
import { generateComponentPreviews } from './preview-generator'
|
|
@@ -640,6 +641,42 @@ async function processFile(
|
|
|
640
641
|
comp.props = extractPropsFromSource(pageLines, invLine, comp.componentName)
|
|
641
642
|
}
|
|
642
643
|
}
|
|
644
|
+
|
|
645
|
+
// Resolve spread props for array-rendered components
|
|
646
|
+
const componentGroups = new Map<string, typeof result.components[string][]>()
|
|
647
|
+
for (const comp of Object.values(result.components)) {
|
|
648
|
+
const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
|
|
649
|
+
if (!componentGroups.has(key)) componentGroups.set(key, [])
|
|
650
|
+
componentGroups.get(key)!.push(comp)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (const group of componentGroups.values()) {
|
|
654
|
+
if (group.length <= 1) continue
|
|
655
|
+
if (!group.some(c => Object.keys(c.props).length === 0)) continue
|
|
656
|
+
|
|
657
|
+
const firstComp = group[0]!
|
|
658
|
+
const invLine = findComponentInvocationLine(pageLines, firstComp.componentName, 0)
|
|
659
|
+
if (invLine < 0) continue
|
|
660
|
+
|
|
661
|
+
const pattern = detectArrayPattern(pageLines, invLine)
|
|
662
|
+
if (!pattern) continue
|
|
663
|
+
|
|
664
|
+
const fmEnd = findFrontmatterEnd(pageLines)
|
|
665
|
+
if (fmEnd === 0) continue
|
|
666
|
+
|
|
667
|
+
const frontmatterContent = pageLines.slice(1, fmEnd - 1).join('\n')
|
|
668
|
+
|
|
669
|
+
const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
|
|
670
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
671
|
+
const comp = sorted[i]!
|
|
672
|
+
if (Object.keys(comp.props).length > 0) continue
|
|
673
|
+
|
|
674
|
+
const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
|
|
675
|
+
if (arrayProps) {
|
|
676
|
+
comp.props = arrayProps
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
643
680
|
} catch {
|
|
644
681
|
// Could not read page source — leave props empty
|
|
645
682
|
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -3,10 +3,11 @@ import fs from 'node:fs/promises'
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { getProjectRoot } from './config'
|
|
6
|
-
import { handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
|
|
6
|
+
import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
|
|
7
7
|
import {
|
|
8
8
|
extractPropsFromSource,
|
|
9
9
|
findComponentInvocationLine,
|
|
10
|
+
findFrontmatterEnd,
|
|
10
11
|
getPageFileCandidates,
|
|
11
12
|
handleInsertComponent,
|
|
12
13
|
handleRemoveComponent,
|
|
@@ -429,7 +430,7 @@ async function handleCmsApiRoute(
|
|
|
429
430
|
|
|
430
431
|
// GET /_nua/cms/deployment/status
|
|
431
432
|
if (route === 'deployment/status' && req.method === 'GET') {
|
|
432
|
-
sendJson(res, { currentDeployment: null, pendingCount: 0 })
|
|
433
|
+
sendJson(res, { currentDeployment: null, pendingCount: 0, deploymentEnabled: false })
|
|
433
434
|
return
|
|
434
435
|
}
|
|
435
436
|
|
|
@@ -536,6 +537,50 @@ async function processHtmlForDev(
|
|
|
536
537
|
}
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
// Resolve spread props for array-rendered components.
|
|
541
|
+
// Group components by (name, invocationSourcePath) to detect array patterns.
|
|
542
|
+
const componentGroups = new Map<string, typeof result.components[string][]>()
|
|
543
|
+
for (const comp of Object.values(result.components)) {
|
|
544
|
+
const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
|
|
545
|
+
if (!componentGroups.has(key)) componentGroups.set(key, [])
|
|
546
|
+
componentGroups.get(key)!.push(comp)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const group of componentGroups.values()) {
|
|
550
|
+
if (group.length <= 1) continue
|
|
551
|
+
// Only process groups where at least one component has empty props (spread case)
|
|
552
|
+
if (!group.some(c => Object.keys(c.props).length === 0)) continue
|
|
553
|
+
|
|
554
|
+
const firstComp = group[0]!
|
|
555
|
+
const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
|
|
556
|
+
const lines = await readLines(path.resolve(projectRoot, filePath))
|
|
557
|
+
if (!lines) continue
|
|
558
|
+
|
|
559
|
+
// Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
|
|
560
|
+
const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
|
|
561
|
+
if (invLine < 0) continue
|
|
562
|
+
|
|
563
|
+
const pattern = detectArrayPattern(lines, invLine)
|
|
564
|
+
if (!pattern) continue
|
|
565
|
+
|
|
566
|
+
const fmEnd = findFrontmatterEnd(lines)
|
|
567
|
+
if (fmEnd === 0) continue
|
|
568
|
+
|
|
569
|
+
const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
|
|
570
|
+
|
|
571
|
+
// Sort group by invocationIndex to match array element order
|
|
572
|
+
const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
|
|
573
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
574
|
+
const comp = sorted[i]!
|
|
575
|
+
if (Object.keys(comp.props).length > 0) continue
|
|
576
|
+
|
|
577
|
+
const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
|
|
578
|
+
if (arrayProps) {
|
|
579
|
+
comp.props = arrayProps
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
539
584
|
// Build collection entry if this is a collection page
|
|
540
585
|
let collectionEntry: CollectionEntry | undefined
|
|
541
586
|
if (collectionInfo && mdContent) {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
closeCollectionsBrowser,
|
|
4
4
|
isCollectionsBrowserOpen,
|
|
5
5
|
manifest,
|
|
6
|
+
openMarkdownEditorForEntry,
|
|
6
7
|
openMarkdownEditorForNewPage,
|
|
7
8
|
selectBrowserCollection,
|
|
8
9
|
selectedBrowserCollection,
|
|
@@ -16,7 +17,7 @@ export function CollectionsBrowser() {
|
|
|
16
17
|
const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
|
|
17
18
|
|
|
18
19
|
const collections = useMemo(() => {
|
|
19
|
-
return Object.values(collectionDefinitions)
|
|
20
|
+
return Object.values(collectionDefinitions).sort((a, b) => a.label.localeCompare(b.label))
|
|
20
21
|
}, [collectionDefinitions])
|
|
21
22
|
|
|
22
23
|
if (!visible) return null
|
|
@@ -38,11 +39,14 @@ export function CollectionsBrowser() {
|
|
|
38
39
|
|
|
39
40
|
const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
|
|
40
41
|
closeCollectionsBrowser()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
if (pathname) {
|
|
43
|
+
// Navigate to the collection detail page to edit inline.
|
|
44
|
+
savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
|
|
45
|
+
window.location.href = pathname
|
|
46
|
+
} else {
|
|
47
|
+
// No detail page exists for this entry — open the markdown editor inline.
|
|
48
|
+
openMarkdownEditorForEntry(selected, slug, sourcePath, def)
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
const handleAddNew = () => {
|
|
@@ -54,7 +54,7 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
|
|
|
54
54
|
placeholder={placeholder}
|
|
55
55
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
56
56
|
class={cn(
|
|
57
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
57
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
58
58
|
isDirty
|
|
59
59
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
60
60
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -90,7 +90,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
90
90
|
placeholder={placeholder}
|
|
91
91
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
92
92
|
class={cn(
|
|
93
|
-
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-
|
|
93
|
+
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
94
94
|
isDirty
|
|
95
95
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
96
96
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -100,7 +100,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
100
100
|
<button
|
|
101
101
|
type="button"
|
|
102
102
|
onClick={onBrowse}
|
|
103
|
-
class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-
|
|
103
|
+
class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
|
|
104
104
|
data-cms-ui
|
|
105
105
|
>
|
|
106
106
|
Browse
|
|
@@ -132,7 +132,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
|
|
|
132
132
|
value={value ?? ''}
|
|
133
133
|
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
|
134
134
|
class={cn(
|
|
135
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
135
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
|
|
136
136
|
isDirty
|
|
137
137
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
138
138
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -220,7 +220,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
|
|
|
220
220
|
onChange(val === '' ? undefined : Number(val))
|
|
221
221
|
}}
|
|
222
222
|
class={cn(
|
|
223
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
223
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
224
224
|
isDirty
|
|
225
225
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
226
226
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -341,7 +341,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
341
341
|
onKeyDown={handleKeyDown}
|
|
342
342
|
autocomplete="off"
|
|
343
343
|
class={cn(
|
|
344
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
344
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
345
345
|
isDirty
|
|
346
346
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
347
347
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -351,7 +351,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
351
351
|
{showDropdown && (
|
|
352
352
|
<div
|
|
353
353
|
ref={listRef}
|
|
354
|
-
class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-
|
|
354
|
+
class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
|
|
355
355
|
data-cms-ui
|
|
356
356
|
>
|
|
357
357
|
{filtered.map((opt, i) => (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks'
|
|
1
2
|
import { markdownEditorState, openMediaLibraryWithCallback, updateMarkdownFrontmatter } from '../signals'
|
|
2
3
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
3
4
|
import { ComboBoxField, ImageField, NumberField, TextField, ToggleField } from './fields'
|
|
@@ -87,6 +88,17 @@ export function FrontmatterField({
|
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// Object field - render nested fields with add/remove
|
|
92
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
93
|
+
return (
|
|
94
|
+
<ObjectFields
|
|
95
|
+
label={label}
|
|
96
|
+
value={value as Record<string, unknown>}
|
|
97
|
+
onChange={onChange}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
// String field (default) - check if it's a long text (excerpt, etc.)
|
|
91
103
|
const isLongText = fieldKey.toLowerCase().includes('excerpt')
|
|
92
104
|
|| fieldKey.toLowerCase().includes('description')
|
|
@@ -158,7 +170,7 @@ export function CreateModeFrontmatter({
|
|
|
158
170
|
}
|
|
159
171
|
}}
|
|
160
172
|
placeholder="url-friendly-slug"
|
|
161
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-
|
|
173
|
+
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
|
|
162
174
|
data-cms-ui
|
|
163
175
|
/>
|
|
164
176
|
<p class="mt-1 text-xs text-white/40">
|
|
@@ -214,7 +226,7 @@ export function EditModeFrontmatter({
|
|
|
214
226
|
<input
|
|
215
227
|
type="text"
|
|
216
228
|
value={page.slug}
|
|
217
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-
|
|
229
|
+
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white/50 focus:outline-none cursor-not-allowed"
|
|
218
230
|
disabled
|
|
219
231
|
data-cms-ui
|
|
220
232
|
/>
|
|
@@ -415,6 +427,32 @@ export function SchemaFrontmatterField({
|
|
|
415
427
|
)
|
|
416
428
|
}
|
|
417
429
|
|
|
430
|
+
case 'object': {
|
|
431
|
+
const obj = (value ?? {}) as Record<string, unknown>
|
|
432
|
+
const nestedFields = field.fields ?? []
|
|
433
|
+
if (nestedFields.length > 0) {
|
|
434
|
+
// Schema-defined nested fields + any extra keys from the actual value
|
|
435
|
+
const schemaNames = new Set(nestedFields.map((f) => f.name))
|
|
436
|
+
const extraKeys = Object.keys(obj).filter((k) => !schemaNames.has(k))
|
|
437
|
+
return (
|
|
438
|
+
<ObjectFields
|
|
439
|
+
label={label}
|
|
440
|
+
value={obj}
|
|
441
|
+
onChange={onChange}
|
|
442
|
+
schemaFields={nestedFields}
|
|
443
|
+
extraKeys={extraKeys}
|
|
444
|
+
/>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
return (
|
|
448
|
+
<ObjectFields
|
|
449
|
+
label={label}
|
|
450
|
+
value={obj}
|
|
451
|
+
onChange={onChange}
|
|
452
|
+
/>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
418
456
|
default:
|
|
419
457
|
return (
|
|
420
458
|
<div class="flex flex-col gap-1" data-cms-ui>
|
|
@@ -431,6 +469,129 @@ export function SchemaFrontmatterField({
|
|
|
431
469
|
}
|
|
432
470
|
}
|
|
433
471
|
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// Object Fields — renders nested fields with add/remove key support
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
interface ObjectFieldsProps {
|
|
477
|
+
label: string
|
|
478
|
+
value: Record<string, unknown>
|
|
479
|
+
onChange: (value: unknown) => void
|
|
480
|
+
schemaFields?: FieldDefinition[]
|
|
481
|
+
extraKeys?: string[]
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: ObjectFieldsProps) {
|
|
485
|
+
const [newKey, setNewKey] = useState('')
|
|
486
|
+
const obj = value ?? {}
|
|
487
|
+
|
|
488
|
+
const handleRemoveKey = (key: string) => {
|
|
489
|
+
const { [key]: _, ...rest } = obj
|
|
490
|
+
onChange(rest)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const handleAddKey = () => {
|
|
494
|
+
const trimmed = newKey.trim()
|
|
495
|
+
if (!trimmed || trimmed in obj) return
|
|
496
|
+
onChange({ ...obj, [trimmed]: '' })
|
|
497
|
+
setNewKey('')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div class="flex flex-col gap-2 col-span-2" data-cms-ui>
|
|
502
|
+
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
503
|
+
<div class="space-y-2 pl-3 border-l-2 border-white/10">
|
|
504
|
+
{schemaFields
|
|
505
|
+
? (
|
|
506
|
+
<>
|
|
507
|
+
{schemaFields.map((subField) => (
|
|
508
|
+
<div key={subField.name} class="flex items-end gap-2">
|
|
509
|
+
<div class="flex-1 min-w-0">
|
|
510
|
+
<SchemaFrontmatterField
|
|
511
|
+
field={subField}
|
|
512
|
+
value={obj[subField.name]}
|
|
513
|
+
onChange={(newValue) => onChange({ ...obj, [subField.name]: newValue })}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
))}
|
|
518
|
+
{(extraKeys ?? []).map((key) => (
|
|
519
|
+
<div key={key} class="flex items-end gap-2">
|
|
520
|
+
<div class="flex-1 min-w-0">
|
|
521
|
+
<FrontmatterField
|
|
522
|
+
fieldKey={key}
|
|
523
|
+
value={obj[key]}
|
|
524
|
+
onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
onClick={() => handleRemoveKey(key)}
|
|
530
|
+
class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
|
|
531
|
+
title={`Remove ${key}`}
|
|
532
|
+
data-cms-ui
|
|
533
|
+
>
|
|
534
|
+
<RemoveIcon />
|
|
535
|
+
</button>
|
|
536
|
+
</div>
|
|
537
|
+
))}
|
|
538
|
+
</>
|
|
539
|
+
)
|
|
540
|
+
: Object.entries(obj).map(([key, subValue]) => (
|
|
541
|
+
<div key={key} class="flex items-end gap-2">
|
|
542
|
+
<div class="flex-1 min-w-0">
|
|
543
|
+
<FrontmatterField
|
|
544
|
+
fieldKey={key}
|
|
545
|
+
value={subValue}
|
|
546
|
+
onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
onClick={() => handleRemoveKey(key)}
|
|
552
|
+
class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
|
|
553
|
+
title={`Remove ${key}`}
|
|
554
|
+
data-cms-ui
|
|
555
|
+
>
|
|
556
|
+
<RemoveIcon />
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
))
|
|
560
|
+
}
|
|
561
|
+
{/* Add new key */}
|
|
562
|
+
<div class="flex items-center gap-2 pt-1">
|
|
563
|
+
<input
|
|
564
|
+
type="text"
|
|
565
|
+
value={newKey}
|
|
566
|
+
onInput={(e) => setNewKey((e.target as HTMLInputElement).value)}
|
|
567
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKey() } }}
|
|
568
|
+
placeholder="New field name..."
|
|
569
|
+
class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
|
|
570
|
+
data-cms-ui
|
|
571
|
+
/>
|
|
572
|
+
<button
|
|
573
|
+
type="button"
|
|
574
|
+
onClick={handleAddKey}
|
|
575
|
+
disabled={!newKey.trim() || newKey.trim() in obj}
|
|
576
|
+
class="px-2 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
577
|
+
data-cms-ui
|
|
578
|
+
>
|
|
579
|
+
+ Add
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function RemoveIcon() {
|
|
588
|
+
return (
|
|
589
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
590
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
591
|
+
</svg>
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
434
595
|
// ============================================================================
|
|
435
596
|
// Helper Functions
|
|
436
597
|
// ============================================================================
|
|
@@ -175,7 +175,8 @@ export function SeoEditor() {
|
|
|
175
175
|
})
|
|
176
176
|
|
|
177
177
|
if (result.errors && result.errors.length > 0) {
|
|
178
|
-
|
|
178
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
179
|
+
showToast(`SEO save failed: ${details}`, 'error')
|
|
179
180
|
} else {
|
|
180
181
|
showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
|
|
181
182
|
clearPendingSeoChanges()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'preact/hooks'
|
|
1
|
+
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
2
2
|
import { TIMING } from '../../constants'
|
|
3
3
|
import type { ToastMessage } from './types'
|
|
4
4
|
|
|
@@ -8,8 +8,16 @@ interface ToastProps extends ToastMessage {
|
|
|
8
8
|
|
|
9
9
|
export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
10
10
|
const [isVisible, setIsVisible] = useState(true)
|
|
11
|
+
const persistent = type === 'error'
|
|
12
|
+
|
|
13
|
+
const dismiss = useCallback(() => {
|
|
14
|
+
setIsVisible(false)
|
|
15
|
+
setTimeout(() => onRemove(id), TIMING.TOAST_FADE_DURATION_MS)
|
|
16
|
+
}, [id, onRemove])
|
|
11
17
|
|
|
12
18
|
useEffect(() => {
|
|
19
|
+
if (persistent) return
|
|
20
|
+
|
|
13
21
|
const hideTimer = setTimeout(() => {
|
|
14
22
|
setIsVisible(false)
|
|
15
23
|
}, TIMING.TOAST_VISIBLE_DURATION_MS)
|
|
@@ -22,7 +30,7 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
22
30
|
clearTimeout(hideTimer)
|
|
23
31
|
clearTimeout(removeTimer)
|
|
24
32
|
}
|
|
25
|
-
}, [id, onRemove])
|
|
33
|
+
}, [id, onRemove, persistent])
|
|
26
34
|
|
|
27
35
|
const typeClasses = {
|
|
28
36
|
error: 'bg-cms-dark border-l-4 border-l-cms-error text-white',
|
|
@@ -44,6 +52,15 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
44
52
|
{type === 'error' && <span class="text-cms-error text-lg">✕</span>}
|
|
45
53
|
{type === 'info' && <span class="w-2.5 h-2.5 rounded-full bg-white/50 shrink-0" />}
|
|
46
54
|
{message}
|
|
55
|
+
{persistent && (
|
|
56
|
+
<button
|
|
57
|
+
onClick={dismiss}
|
|
58
|
+
class="ml-1 text-white/60 hover:text-white transition-colors text-lg leading-none cursor-pointer"
|
|
59
|
+
aria-label="Dismiss"
|
|
60
|
+
>
|
|
61
|
+
✕
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
47
64
|
</div>
|
|
48
65
|
)
|
|
49
66
|
}
|
package/src/editor/dom.ts
CHANGED
|
@@ -161,16 +161,19 @@ export function isStyledSpan(element: HTMLElement): boolean {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* Note: This returns plain text only - for styled content, use innerHTML directly.
|
|
164
|
+
* Block-level elements that browsers may create inside contentEditable on Enter.
|
|
165
|
+
* These are treated as line breaks when extracting text.
|
|
167
166
|
*/
|
|
167
|
+
const BLOCK_ELEMENTS = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'blockquote'])
|
|
168
|
+
|
|
168
169
|
function extractTextFromChildNodes(parentNode: HTMLElement): string {
|
|
169
170
|
let text = ''
|
|
170
171
|
|
|
171
172
|
parentNode.childNodes.forEach(node => {
|
|
172
173
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
173
|
-
|
|
174
|
+
// Normalize non-breaking spaces (\u00a0) that browsers insert in
|
|
175
|
+
// contentEditable to regular spaces
|
|
176
|
+
text += (node.nodeValue || '').replace(/\u00a0/g, ' ')
|
|
174
177
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
175
178
|
const element = node as HTMLElement
|
|
176
179
|
const tagName = element.tagName.toLowerCase()
|
|
@@ -186,9 +189,17 @@ function extractTextFromChildNodes(parentNode: HTMLElement): string {
|
|
|
186
189
|
if (directCmsId) {
|
|
187
190
|
// Element has CMS ID - replace with placeholder
|
|
188
191
|
text += `{{cms:${directCmsId}}}`
|
|
192
|
+
} else if (BLOCK_ELEMENTS.has(tagName)) {
|
|
193
|
+
// Block-level elements created by browser on Enter should be
|
|
194
|
+
// treated as line breaks, not collapsed into the text
|
|
195
|
+
const blockText = extractTextFromChildNodes(element)
|
|
196
|
+
if (blockText) {
|
|
197
|
+
// Only add <br> separator if there's already text before this block
|
|
198
|
+
text += (text ? '<br>' : '') + blockText
|
|
199
|
+
}
|
|
189
200
|
} else {
|
|
190
201
|
// For all other elements (including styled spans), just get their text content
|
|
191
|
-
text += element.textContent || ''
|
|
202
|
+
text += (element.textContent || '').replace(/\u00a0/g, ' ')
|
|
192
203
|
}
|
|
193
204
|
}
|
|
194
205
|
})
|
package/src/editor/editor.ts
CHANGED
|
@@ -154,10 +154,14 @@ export async function startEditMode(
|
|
|
154
154
|
makeElementEditable(el)
|
|
155
155
|
|
|
156
156
|
// Suppress browser native contentEditable undo/redo (we handle it ourselves)
|
|
157
|
+
// Also prevent Enter/Shift+Enter from inserting line breaks
|
|
157
158
|
el.addEventListener('beforeinput', (e) => {
|
|
158
159
|
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
159
160
|
e.preventDefault()
|
|
160
161
|
}
|
|
162
|
+
if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
}
|
|
161
165
|
})
|
|
162
166
|
|
|
163
167
|
// Setup color tracking for elements with colorClasses in manifest
|
|
@@ -1122,11 +1126,24 @@ export interface DeploymentPollingOptions {
|
|
|
1122
1126
|
* Start polling for deployment status after a save operation.
|
|
1123
1127
|
* Polls the API every 3 seconds until deployment completes or fails.
|
|
1124
1128
|
* Waits for deployment to appear for up to 30 seconds before giving up.
|
|
1129
|
+
* Skips polling entirely when deployment is not available (e.g. local dev).
|
|
1125
1130
|
*/
|
|
1126
|
-
export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
|
|
1131
|
+
export async function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): Promise<void> {
|
|
1127
1132
|
// Clear any existing timers
|
|
1128
1133
|
stopDeploymentPolling()
|
|
1129
1134
|
|
|
1135
|
+
// Do a preflight check to see if deployment is available
|
|
1136
|
+
try {
|
|
1137
|
+
const preflight = await getDeploymentStatus(config.apiBase)
|
|
1138
|
+
if (preflight.deploymentEnabled === false) {
|
|
1139
|
+
// Deployment not available (e.g. local dev) — skip polling entirely
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
// If we can't even reach the endpoint, skip polling
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1130
1147
|
// Reset wait attempts counter and store the timestamp when we started
|
|
1131
1148
|
deploymentWaitAttempts = 0
|
|
1132
1149
|
deploymentStartTimestamp = new Date().toISOString()
|
package/src/editor/index.tsx
CHANGED
|
@@ -142,10 +142,12 @@ const CmsUI = () => {
|
|
|
142
142
|
if (result.success) {
|
|
143
143
|
signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
|
|
144
144
|
} else if (result.errors) {
|
|
145
|
-
|
|
145
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
146
|
+
signals.showToast(`Save failed: ${details}`, 'error')
|
|
146
147
|
}
|
|
147
148
|
} catch (err) {
|
|
148
|
-
|
|
149
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
150
|
+
signals.showToast(`Save failed: ${message}`, 'error')
|
|
149
151
|
}
|
|
150
152
|
}, [config, updateUI])
|
|
151
153
|
|
package/src/editor/types.ts
CHANGED
|
@@ -418,6 +418,8 @@ export interface DeploymentStatusResponse {
|
|
|
418
418
|
publishedUrl: string
|
|
419
419
|
} | null
|
|
420
420
|
pendingCount: number
|
|
421
|
+
/** When false, deployment is not available (e.g. local dev) and polling should be skipped */
|
|
422
|
+
deploymentEnabled?: boolean
|
|
421
423
|
}
|
|
422
424
|
|
|
423
425
|
export interface DeploymentState {
|