@nuasite/cms 0.19.0 → 0.20.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.19.0",
17
+ "version": "0.20.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@tailwindcss/vite": "^4.2.2",
46
46
  "@types/bun": "1.3.11",
47
47
  "clsx": "^2.1.1",
48
- "marked": "^17.0.6",
48
+ "marked": "^18.0.0",
49
49
  "preact": "^10.29.1",
50
50
  "prosemirror-commands": "^1.7.1",
51
51
  "prosemirror-inputrules": "^1.5.1",
@@ -59,7 +59,7 @@
59
59
  "tailwind-merge": "^3.5.0"
60
60
  },
61
61
  "peerDependencies": {
62
- "astro": "6.1.3",
62
+ "astro": "6.1.4",
63
63
  "typescript": "^6.0.2",
64
64
  "vite": "^7.0.0",
65
65
  "@aws-sdk/client-s3": "^3.0.0"
@@ -10,6 +10,7 @@ import { extractComponentName, processHtml } from './html-processor'
10
10
  import type { ManifestWriter } from './manifest-writer'
11
11
  import { generateComponentPreviews } from './preview-generator'
12
12
  import {
13
+ clearCollectionTextIndex,
13
14
  clearSourceFinderCache,
14
15
  extractOpeningTagWithLine,
15
16
  findCollectionSource,
@@ -23,6 +24,7 @@ import {
23
24
  } from './source-finder'
24
25
  import type { ComponentInstance } from './types'
25
26
  import type { CmsMarkerOptions, CollectionEntry } from './types'
27
+ import { firstNonEmptyLine } from './utils'
26
28
 
27
29
  // Concurrency limit for parallel processing
28
30
  const MAX_CONCURRENT = 10
@@ -323,10 +325,7 @@ async function processFile(
323
325
  }
324
326
 
325
327
  // Get the first non-empty line of the markdown body for wrapper detection
326
- const bodyFirstLine = mdContent?.body
327
- ?.split('\n')
328
- .find((line) => line.trim().length > 0)
329
- ?.trim()
328
+ const bodyFirstLine = firstNonEmptyLine(mdContent?.body)
330
329
 
331
330
  // Create ID generator - use atomic increment
332
331
  const pageIdStart = idCounter.value
@@ -780,6 +779,7 @@ export async function processBuildOutput(
780
779
 
781
780
  // Clear caches from previous builds and initialize search index
782
781
  clearSourceFinderCache()
782
+ clearCollectionTextIndex()
783
783
 
784
784
  const htmlFiles = await findHtmlFiles(outDir)
785
785
 
@@ -1,4 +1,3 @@
1
- import { parse } from 'node-html-parser'
2
1
  import fs from 'node:fs/promises'
3
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
3
  import path from 'node:path'
@@ -17,15 +16,23 @@ import { processHtml } from './html-processor'
17
16
  import type { ManifestWriter } from './manifest-writer'
18
17
  import type { MediaStorageAdapter } from './media/types'
19
18
  import {
20
- clearSourceFinderCache,
21
19
  findCollectionSource,
22
20
  findImageSourceLocation,
23
21
  findSourceLocation,
24
22
  initializeSearchIndex,
25
23
  parseMarkdownContent,
24
+ reindexDirtyFiles,
26
25
  } from './source-finder'
27
- import type { CmsMarkerOptions, CollectionEntry, ComponentDefinition } from './types'
28
- import { normalizePagePath } from './utils'
26
+ import type {
27
+ CmsMarkerOptions,
28
+ CollectionDefinition,
29
+ CollectionEntry,
30
+ ComponentDefinition,
31
+ ComponentInstance,
32
+ ManifestEntry,
33
+ PageSeoData,
34
+ } from './types'
35
+ import { firstNonEmptyLine, normalizePagePath } from './utils'
29
36
 
30
37
  /** Minimal ViteDevServer interface to avoid version conflicts between Astro's bundled Vite and root Vite */
31
38
  interface ViteDevServerLike {
@@ -258,18 +265,23 @@ export function createDevMiddleware(
258
265
  const html = Buffer.concat(chunks!).toString('utf8')
259
266
  const pagePath = normalizePagePath(requestUrl)
260
267
 
261
- // Process HTML asynchronously
262
- processHtmlForDev(html, pagePath, config, idCounter, manifestWriter)
263
- .then(({ html: transformed, entries, components, collection, seo }) => {
268
+ // Phase 1 (fast): mark HTML with CMS IDs and build basic entries
269
+ markHtmlForDev(html, pagePath, config, idCounter, manifestWriter)
270
+ .then(({ html: transformed, entries, components, collection, seo, collectionDefinitions: colDefs }) => {
271
+ // Store basic manifest immediately so editor toolbar has data
264
272
  manifestWriter.addPage(pagePath, entries, components, collection, seo)
265
273
 
274
+ // Send the marked HTML to the browser without waiting for source resolution
266
275
  res.write = originalWrite
267
276
  res.end = originalEnd
268
277
  if (!res.headersSent) {
269
278
  res.removeHeader('content-length')
270
279
  }
280
+ res.end(transformed, ...args)
271
281
 
272
- return res.end(transformed, ...args)
282
+ // Phase 2 (background): resolve source locations and enhance manifest
283
+ // This runs after the page is already visible to the user
284
+ enhanceManifestInBackground(pagePath, entries, components, collection, seo, colDefs, config, manifestWriter)
273
285
  })
274
286
  .catch((error) => {
275
287
  console.error('[cms] Error transforming HTML:', error)
@@ -289,35 +301,35 @@ export function createDevMiddleware(
289
301
  })
290
302
  }
291
303
 
292
- async function processHtmlForDev(
304
+ /**
305
+ * Phase 1 (fast): Mark HTML with CMS IDs and build basic manifest entries.
306
+ * Returns quickly so the page can be sent to the browser without delay.
307
+ * Source resolution and snippet enhancement are deferred to Phase 2.
308
+ */
309
+ async function markHtmlForDev(
293
310
  html: string,
294
311
  pagePath: string,
295
312
  config: Required<CmsMarkerOptions>,
296
313
  idCounter: { value: number },
297
314
  manifestWriter: ManifestWriter,
298
315
  ) {
299
- // Clear cached parsed files so variable definitions reflect the latest source
300
- clearSourceFinderCache()
316
+ // Re-index only files that changed since last page load (tracked by Vite watcher).
317
+ await reindexDirtyFiles()
301
318
 
302
319
  // In dev mode, reset counter per page for consistent IDs during HMR
303
320
  let pageCounter = 0
304
321
  const idGenerator = () => `cms-${pageCounter++}`
305
322
 
306
- // Check if this is a collection page (e.g., /services/example -> services collection, example slug)
323
+ // Check if this is a collection page
307
324
  const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
308
325
  const isCollectionPage = !!collectionInfo
309
326
 
310
- // Parse markdown content if this is a collection page
311
327
  let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
312
328
  if (collectionInfo) {
313
329
  mdContent = await parseMarkdownContent(collectionInfo)
314
330
  }
315
331
 
316
- // Get the first non-empty line of the markdown body for wrapper detection
317
- const bodyFirstLine = mdContent?.body
318
- ?.split('\n')
319
- .find((line) => line.trim().length > 0)
320
- ?.trim()
332
+ const bodyFirstLine = firstNonEmptyLine(mdContent?.body)
321
333
 
322
334
  const result = await processHtml(
323
335
  html,
@@ -330,214 +342,188 @@ async function processHtmlForDev(
330
342
  generateManifest: config.generateManifest,
331
343
  markComponents: config.markComponents,
332
344
  componentDirs: config.componentDirs,
333
- // Skip marking markdown-rendered content on collection pages
334
- // The markdown body is treated as a single editable unit
335
345
  skipMarkdownContent: isCollectionPage,
336
- // Pass collection info for wrapper element marking
337
346
  collectionInfo: collectionInfo
338
347
  ? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine, bodyText: mdContent?.body, contentPath: collectionInfo.file }
339
348
  : undefined,
340
- // Pass SEO options
341
349
  seo: config.seo,
342
- // Pass collection definitions for resolving frontmatter text on listing pages
343
350
  collectionDefinitions: manifestWriter.getCollectionDefinitions(),
344
351
  },
345
352
  idGenerator,
346
353
  )
347
354
 
348
- // Populate component props from source invocations
349
- const projectRoot = getProjectRoot()
350
- const fileCache = new Map<string, string[] | null>()
351
- const readLines = async (filePath: string): Promise<string[] | null> => {
352
- if (fileCache.has(filePath)) return fileCache.get(filePath)!
353
- try {
354
- const content = await fs.readFile(filePath, 'utf-8')
355
- const lines = content.split('\n')
356
- fileCache.set(filePath, lines)
357
- return lines
358
- } catch {
359
- fileCache.set(filePath, null)
360
- return null
355
+ // Build collection entry if this is a collection page
356
+ let collectionEntry: CollectionEntry | undefined
357
+ if (collectionInfo && mdContent) {
358
+ collectionEntry = {
359
+ collectionName: mdContent.collectionName,
360
+ collectionSlug: mdContent.collectionSlug,
361
+ sourcePath: mdContent.file,
362
+ frontmatter: mdContent.frontmatter,
363
+ body: mdContent.body,
364
+ bodyStartLine: mdContent.bodyStartLine,
365
+ wrapperId: result.collectionWrapperId,
361
366
  }
362
367
  }
363
368
 
364
- for (const comp of Object.values(result.components)) {
365
- // Skip inline array components — they have no <Tag> in source;
366
- // their props are resolved in the array-group pass below
367
- if (comp.componentName.startsWith('__array:')) continue
368
-
369
- let found = false
369
+ return {
370
+ html: result.html,
371
+ entries: result.entries,
372
+ components: result.components,
373
+ collection: collectionEntry,
374
+ seo: result.seo,
375
+ collectionDefinitions: result.collectionDefinitions,
376
+ }
377
+ }
370
378
 
371
- // Try invocationSourcePath first (may point to a layout, not the page)
372
- if (comp.invocationSourcePath) {
373
- const filePath = normalizeFilePath(comp.invocationSourcePath)
374
- const lines = await readLines(path.resolve(projectRoot, filePath))
375
- if (lines) {
376
- const invLine = findComponentInvocationLine(lines, comp.componentName, comp.invocationIndex ?? 0)
377
- if (invLine >= 0) {
378
- comp.props = extractPropsFromSource(lines, invLine, comp.componentName)
379
- found = true
380
- }
379
+ /**
380
+ * Phase 2 (background): Resolve source locations, enhance snippets, populate
381
+ * component props, and update the manifest. Runs after the HTML response is sent.
382
+ */
383
+ async function enhanceManifestInBackground(
384
+ pagePath: string,
385
+ entries: Record<string, ManifestEntry>,
386
+ components: Record<string, ComponentInstance>,
387
+ collection: CollectionEntry | undefined,
388
+ seo: PageSeoData | undefined,
389
+ collectionDefinitions: Record<string, CollectionDefinition> | undefined,
390
+ config: Required<CmsMarkerOptions>,
391
+ manifestWriter: ManifestWriter,
392
+ ): Promise<void> {
393
+ try {
394
+ // Populate component props from source invocations
395
+ const projectRoot = getProjectRoot()
396
+ const fileCache = new Map<string, string[] | null>()
397
+ const readLines = async (filePath: string): Promise<string[] | null> => {
398
+ if (fileCache.has(filePath)) return fileCache.get(filePath)!
399
+ try {
400
+ const content = await fs.readFile(filePath, 'utf-8')
401
+ const lines = content.split('\n')
402
+ fileCache.set(filePath, lines)
403
+ return lines
404
+ } catch {
405
+ fileCache.set(filePath, null)
406
+ return null
381
407
  }
382
408
  }
383
409
 
384
- // Fallback: search page source file candidates
385
- if (!found) {
386
- for (const candidate of getPageFileCandidates(pagePath)) {
387
- const lines = await readLines(path.resolve(projectRoot, candidate))
410
+ for (const comp of Object.values(components)) {
411
+ if (comp.componentName.startsWith('__array:')) continue
412
+
413
+ let found = false
414
+
415
+ if (comp.invocationSourcePath) {
416
+ const filePath = normalizeFilePath(comp.invocationSourcePath)
417
+ const lines = await readLines(path.resolve(projectRoot, filePath))
388
418
  if (lines) {
389
419
  const invLine = findComponentInvocationLine(lines, comp.componentName, comp.invocationIndex ?? 0)
390
420
  if (invLine >= 0) {
391
421
  comp.props = extractPropsFromSource(lines, invLine, comp.componentName)
392
- break
422
+ found = true
393
423
  }
394
424
  }
395
425
  }
396
- }
397
- }
398
426
 
399
- // Resolve spread props for array-rendered components.
400
- // Group components by (name, invocationSourcePath) to detect array patterns.
401
- const componentGroups = new Map<string, typeof result.components[string][]>()
402
- for (const comp of Object.values(result.components)) {
403
- const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
404
- if (!componentGroups.has(key)) componentGroups.set(key, [])
405
- componentGroups.get(key)!.push(comp)
406
- }
407
-
408
- for (const group of componentGroups.values()) {
409
- if (group.length < 1) continue
410
- // Only process groups where at least one component has empty props (spread case)
411
- if (!group.some(c => Object.keys(c.props).length === 0)) continue
412
-
413
- const firstComp = group[0]!
414
- const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
415
- const lines = await readLines(path.resolve(projectRoot, filePath))
416
- if (!lines) continue
417
-
418
- // For inline array components (__array:varName or __array:varName#N), find the .map() line
419
- // directly instead of searching for a component tag that won't exist
420
- let pattern: ReturnType<typeof detectArrayPattern>
421
- const parsed = parseInlineArrayName(firstComp.componentName)
422
- if (parsed) {
423
- const { arrayVarName, mapOccurrence } = parsed
424
- const fmEndCheck = findFrontmatterEnd(lines)
425
- const mapRegex = new RegExp(buildMapPattern(arrayVarName))
426
- let mapLine = -1
427
- let seen = 0
428
- for (let i = fmEndCheck; i < lines.length; i++) {
429
- if (mapRegex.test(lines[i]!)) {
430
- if (seen === mapOccurrence) {
431
- mapLine = i
432
- break
427
+ if (!found) {
428
+ for (const candidate of getPageFileCandidates(pagePath)) {
429
+ const lines = await readLines(path.resolve(projectRoot, candidate))
430
+ if (lines) {
431
+ const invLine = findComponentInvocationLine(lines, comp.componentName, comp.invocationIndex ?? 0)
432
+ if (invLine >= 0) {
433
+ comp.props = extractPropsFromSource(lines, invLine, comp.componentName)
434
+ break
435
+ }
433
436
  }
434
- seen++
435
437
  }
436
438
  }
437
- if (mapLine < 0) continue
438
- pattern = { arrayVarName, mapLineIndex: mapLine }
439
- } else {
440
- // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
441
- const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
442
- if (invLine < 0) continue
443
- pattern = detectArrayPattern(lines, invLine)
444
439
  }
445
- if (!pattern) continue
446
-
447
- const fmEnd = findFrontmatterEnd(lines)
448
- if (fmEnd === 0) continue
449
440
 
450
- const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
441
+ // Resolve spread props for array-rendered components
442
+ const componentGroups = new Map<string, typeof components[string][]>()
443
+ for (const comp of Object.values(components)) {
444
+ const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
445
+ if (!componentGroups.has(key)) componentGroups.set(key, [])
446
+ componentGroups.get(key)!.push(comp)
447
+ }
451
448
 
452
- // Sort group by invocationIndex to match array element order
453
- const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
454
- for (let i = 0; i < sorted.length; i++) {
455
- const comp = sorted[i]!
456
- if (Object.keys(comp.props).length > 0) continue
449
+ for (const group of componentGroups.values()) {
450
+ if (group.length < 1) continue
451
+ if (!group.some(c => Object.keys(c.props).length === 0)) continue
457
452
 
458
- const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
459
- if (arrayProps) {
460
- comp.props = arrayProps
453
+ const firstComp = group[0]!
454
+ const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
455
+ const lines = await readLines(path.resolve(projectRoot, filePath))
456
+ if (!lines) continue
457
+
458
+ const fmEnd = findFrontmatterEnd(lines)
459
+
460
+ let pattern: ReturnType<typeof detectArrayPattern>
461
+ const parsed = parseInlineArrayName(firstComp.componentName)
462
+ if (parsed) {
463
+ const { arrayVarName, mapOccurrence } = parsed
464
+ const mapRegex = new RegExp(buildMapPattern(arrayVarName))
465
+ let mapLine = -1
466
+ let seen = 0
467
+ for (let i = fmEnd; i < lines.length; i++) {
468
+ if (mapRegex.test(lines[i]!)) {
469
+ if (seen === mapOccurrence) {
470
+ mapLine = i
471
+ break
472
+ }
473
+ seen++
474
+ }
475
+ }
476
+ if (mapLine < 0) continue
477
+ pattern = { arrayVarName, mapLineIndex: mapLine }
478
+ } else {
479
+ const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
480
+ if (invLine < 0) continue
481
+ pattern = detectArrayPattern(lines, invLine)
461
482
  }
462
- }
463
- }
483
+ if (!pattern) continue
484
+ if (fmEnd === 0) continue
464
485
 
465
- // Build collection entry if this is a collection page
466
- let collectionEntry: CollectionEntry | undefined
467
- if (collectionInfo && mdContent) {
468
- collectionEntry = {
469
- collectionName: mdContent.collectionName,
470
- collectionSlug: mdContent.collectionSlug,
471
- sourcePath: mdContent.file,
472
- frontmatter: mdContent.frontmatter,
473
- body: mdContent.body,
474
- bodyStartLine: mdContent.bodyStartLine,
475
- wrapperId: result.collectionWrapperId,
476
- }
477
- }
486
+ const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
478
487
 
479
- // Ensure the search index is initialized for image source lookups
480
- // (idempotent - only scans files on first call)
481
- await initializeSearchIndex()
482
-
483
- // Re-resolve sources with the fully-built search index (the earlier enhancement
484
- // step runs before the index is ready, so its results may be stale).
485
- for (const entry of Object.values(result.entries)) {
486
- if (entry.imageMetadata?.src) {
487
- const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
488
- if (imageSource) {
489
- entry.sourcePath = imageSource.file
490
- entry.sourceLine = imageSource.line
491
- entry.sourceSnippet = imageSource.snippet
492
- }
493
- } else if (entry.text && entry.tag) {
494
- const textSource = await findSourceLocation(entry.text, entry.tag)
495
- if (textSource) {
496
- entry.sourcePath = textSource.file
497
- entry.sourceLine = textSource.line
498
- entry.sourceSnippet = textSource.snippet
499
- if (textSource.variableName) entry.variableName = textSource.variableName
500
- }
501
- }
502
- }
488
+ const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
489
+ for (let i = 0; i < sorted.length; i++) {
490
+ const comp = sorted[i]!
491
+ if (Object.keys(comp.props).length > 0) continue
503
492
 
504
- // Filter out entries without sourcePath - these can't be edited
505
- const idsToRemove: string[] = []
506
- for (const [id, entry] of Object.entries(result.entries)) {
507
- // Keep collection wrapper entries even without sourcePath (they use contentPath)
508
- if (entry.collectionName) continue
509
- // Remove entries that don't have a resolved sourcePath
510
- if (!entry.sourcePath) {
511
- idsToRemove.push(id)
512
- delete result.entries[id]
493
+ const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
494
+ if (arrayProps) {
495
+ comp.props = arrayProps
496
+ }
497
+ }
513
498
  }
514
- }
515
499
 
516
- // Remove CMS ID attributes from HTML for entries that were filtered out
517
- let finalHtml = result.html
518
- if (idsToRemove.length > 0) {
519
- const root = parse(result.html, {
520
- lowerCaseTagName: false,
521
- comment: true,
522
- })
523
- for (const id of idsToRemove) {
524
- const element = root.querySelector(`[${config.attributeName}="${id}"]`)
525
- if (element) {
526
- element.removeAttribute(config.attributeName)
527
- // Also remove related CMS attributes
528
- element.removeAttribute('data-cms-img')
529
- element.removeAttribute('data-cms-markdown')
500
+ // Ensure the search index is initialized
501
+ await initializeSearchIndex()
502
+
503
+ // Re-resolve sources with the search index
504
+ for (const entry of Object.values(entries)) {
505
+ if (entry.imageMetadata?.src) {
506
+ const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
507
+ if (imageSource) {
508
+ entry.sourcePath = imageSource.file
509
+ entry.sourceLine = imageSource.line
510
+ entry.sourceSnippet = imageSource.snippet
511
+ }
512
+ } else if (entry.text && entry.tag) {
513
+ const textSource = await findSourceLocation(entry.text, entry.tag)
514
+ if (textSource) {
515
+ entry.sourcePath = textSource.file
516
+ entry.sourceLine = textSource.line
517
+ entry.sourceSnippet = textSource.snippet
518
+ if (textSource.variableName) entry.variableName = textSource.variableName
519
+ }
530
520
  }
531
521
  }
532
- finalHtml = root.toString()
533
- }
534
522
 
535
- return {
536
- html: finalHtml,
537
- entries: result.entries,
538
- components: result.components,
539
- collection: collectionEntry,
540
- seo: result.seo,
523
+ // Update the manifest with fully-resolved entries and component props
524
+ manifestWriter.addPage(pagePath, entries, components, collection, seo)
525
+ } catch (error) {
526
+ console.error('[cms] Background enhancement failed:', error)
541
527
  }
542
528
  }
543
529
 
@@ -83,18 +83,18 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
83
83
  const hasImage = !!value && value.length > 0
84
84
 
85
85
  return (
86
- <div class="space-y-1.5">
86
+ <div class="space-y-1.5 min-w-0">
87
87
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
88
88
  {hasImage && (
89
89
  <div
90
- class="relative w-full h-24 rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
90
+ class="relative w-full rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
91
91
  onClick={onBrowse}
92
92
  data-cms-ui
93
93
  >
94
94
  <img
95
95
  src={value}
96
96
  alt={label}
97
- class="w-full h-full object-cover"
97
+ class="w-full h-auto max-h-48"
98
98
  onError={(e) => {
99
99
  ;(e.target as HTMLImageElement).style.display = 'none'
100
100
  }}
@@ -104,14 +104,14 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
104
104
  </div>
105
105
  </div>
106
106
  )}
107
- <div class="flex gap-2">
107
+ <div class="flex gap-2 min-w-0">
108
108
  <input
109
109
  type="text"
110
110
  value={value ?? ''}
111
111
  placeholder={placeholder}
112
112
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
113
113
  class={cn(
114
- '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',
114
+ 'flex-1 min-w-0 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',
115
115
  isDirty
116
116
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
117
117
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -121,7 +121,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
121
121
  <button
122
122
  type="button"
123
123
  onClick={onBrowse}
124
- 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"
124
+ class="shrink-0 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"
125
125
  data-cms-ui
126
126
  >
127
127
  Browse