@nuasite/cms 0.25.0 → 0.27.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.25.0",
17
+ "version": "0.27.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -10,7 +10,6 @@ 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,
14
13
  clearSourceFinderCache,
15
14
  extractOpeningTagWithLine,
16
15
  findCollectionSource,
@@ -777,7 +776,6 @@ export async function processBuildOutput(
777
776
 
778
777
  // Clear caches from previous builds and initialize search index
779
778
  clearSourceFinderCache()
780
- clearCollectionTextIndex()
781
779
 
782
780
  const htmlFiles = await findHtmlFiles(outDir)
783
781
 
@@ -454,12 +454,12 @@ function parseContentConfigReferences(
454
454
  return result
455
455
  }
456
456
 
457
- /** Valid field type names exported by `field` helper from @nuasite/cms */
457
+ /** Valid field type names exported by `n` helper from @nuasite/cms */
458
458
  const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
459
459
 
460
460
  /**
461
461
  * Parse the content config file to extract explicit field type hints:
462
- * - `field.image(...)`, `field.url(...)`, etc. from @nuasite/cms
462
+ * - `n.image()`, `n.url()`, etc. from @nuasite/cms
463
463
  * - `z.enum([...])` for select options
464
464
  *
465
465
  * Returns a map: collectionName → fieldName → { type, options? }
@@ -472,8 +472,8 @@ function parseContentConfigFieldTypes(
472
472
  for (const { collectionName, schemaBody } of schemaBlocks) {
473
473
  const fields = new Map<string, { type: FieldType; options?: string[] }>()
474
474
 
475
- // Detect field.image(...), field.url(...), etc.
476
- const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*field\.(\w+)\s*\(/g)
475
+ // Detect n.image(), n.url(), etc.
476
+ const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*n\.(\w+)/g)
477
477
  for (const m of fieldHelpers) {
478
478
  const fieldName = m[1]!
479
479
  const helperName = m[2]!
@@ -17,10 +17,10 @@ import { processHtml } from './html-processor'
17
17
  import type { ManifestWriter } from './manifest-writer'
18
18
  import type { MediaStorageAdapter } from './media/types'
19
19
  import {
20
+ enhanceManifestWithSourceSnippets,
20
21
  findCollectionSource,
21
22
  findImageSourceLocation,
22
23
  findSourceLocation,
23
- initializeSearchIndex,
24
24
  parseMarkdownContent,
25
25
  reindexDirtyFiles,
26
26
  } from './source-finder'
@@ -302,6 +302,13 @@ export function createDevMiddleware(
302
302
  return res.end(chunk, ...args)
303
303
  }
304
304
 
305
+ // Skip CMS processing for internal preview pages
306
+ if (requestUrl.startsWith('/_nua/preview')) {
307
+ res.write = originalWrite
308
+ res.end = originalEnd
309
+ return (res.end as any)(chunk, ...args)
310
+ }
311
+
305
312
  if (chunk) {
306
313
  chunks!.push(Buffer.from(chunk))
307
314
  }
@@ -424,7 +431,7 @@ async function markHtmlForDev(
424
431
  * Phase 2 (background): Resolve source locations, enhance snippets, populate
425
432
  * component props, and update the manifest. Runs after the HTML response is sent.
426
433
  */
427
- async function enhanceManifestInBackground(
434
+ export async function enhanceManifestInBackground(
428
435
  pagePath: string,
429
436
  entries: Record<string, ManifestEntry>,
430
437
  components: Record<string, ComponentInstance>,
@@ -541,31 +548,31 @@ async function enhanceManifestInBackground(
541
548
  }
542
549
  }
543
550
 
544
- // Ensure the search index is initialized
545
- await initializeSearchIndex()
551
+ const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
546
552
 
547
- // Re-resolve sources with the search index
548
- for (const entry of Object.values(entries)) {
553
+ // Fallback for entries without sourcePath — search index can still find them
554
+ for (const entry of Object.values(enhanced)) {
555
+ if (entry.sourceSnippet || entry.sourcePath) continue
549
556
  if (entry.imageMetadata?.src) {
550
- const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
551
- if (imageSource) {
552
- entry.sourcePath = imageSource.file
553
- entry.sourceLine = imageSource.line
554
- entry.sourceSnippet = imageSource.snippet
557
+ const loc = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
558
+ if (loc) {
559
+ entry.sourcePath = loc.file
560
+ entry.sourceLine = loc.line
561
+ entry.sourceSnippet = loc.snippet
555
562
  }
556
563
  } else if (entry.text && entry.tag) {
557
- const textSource = await findSourceLocation(entry.text, entry.tag)
558
- if (textSource) {
559
- entry.sourcePath = textSource.file
560
- entry.sourceLine = textSource.line
561
- entry.sourceSnippet = textSource.snippet
562
- if (textSource.variableName) entry.variableName = textSource.variableName
564
+ const loc = await findSourceLocation(entry.text, entry.tag)
565
+ if (loc) {
566
+ entry.sourcePath = loc.file
567
+ entry.sourceLine = loc.line
568
+ entry.sourceSnippet = loc.snippet
569
+ if (loc.variableName) entry.variableName = loc.variableName
563
570
  }
564
571
  }
565
572
  }
566
573
 
567
574
  // Update the manifest with fully-resolved entries and component props
568
- manifestWriter.addPage(pagePath, entries, components, collection, seo)
575
+ manifestWriter.addPage(pagePath, enhanced, components, collection, seo)
569
576
  } catch (error) {
570
577
  console.error('[cms] Background enhancement failed:', error)
571
578
  }
@@ -4,10 +4,12 @@ import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
5
  import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
6
  import { createMarkdownPage } from '../markdown-api'
7
+ import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
7
8
  import {
8
9
  config,
9
10
  currentMarkdownPage,
10
11
  isMarkdownPreview,
12
+ manifest,
11
13
  markdownEditorState,
12
14
  pendingCollectionEntries,
13
15
  resetMarkdownEditorState,
@@ -244,6 +246,41 @@ export function MarkdownEditorOverlay() {
244
246
  try {
245
247
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
246
248
  el.innerHTML = view.dom.innerHTML
249
+
250
+ // Replace MDX block cards with rendered component previews
251
+ el.querySelectorAll('.mdx-block-card-wrapper[data-mdx-component]').forEach((wrapper) => {
252
+ const name = wrapper.getAttribute('data-mdx-component')
253
+ if (!name) return
254
+ const def = manifest.value?.componentDefinitions?.[name]
255
+ if (!def?.file) return
256
+
257
+ const propsJson = wrapper.getAttribute('data-mdx-props') || '{}'
258
+ const childrenText = wrapper.getAttribute('data-mdx-children') || ''
259
+ let props: Record<string, string> = {}
260
+ try {
261
+ props = JSON.parse(propsJson)
262
+ } catch {}
263
+
264
+ // Filter out expression props
265
+ const staticProps: Record<string, string> = {}
266
+ for (const [k, v] of Object.entries(props)) {
267
+ if (!v.startsWith(MDX_EXPR_PREFIX)) staticProps[k] = v
268
+ }
269
+
270
+ const params = new URLSearchParams({ file: def.file, props: JSON.stringify(staticProps) })
271
+ if (childrenText) params.set('children', childrenText)
272
+
273
+ const iframe = document.createElement('iframe')
274
+ iframe.src = `/_nua/preview?${params}`
275
+ iframe.style.cssText = 'width:100%;border:0;display:block;min-height:60px'
276
+ iframe.onload = () => {
277
+ try {
278
+ const h = iframe.contentDocument?.body?.scrollHeight
279
+ if (h) iframe.style.height = `${h + 16}px`
280
+ } catch {}
281
+ }
282
+ wrapper.replaceWith(iframe)
283
+ })
247
284
  } catch (error) {
248
285
  console.error('Failed to get editor HTML for preview:', error)
249
286
  originalHTMLRef.current = null
@@ -13,9 +13,12 @@ import {
13
13
  import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
14
14
  import { callCommand, insert, replaceAll } from '@milkdown/utils'
15
15
  import type { ComponentChildren } from 'preact'
16
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
17
+ import { getComponentDefinition } from '../manifest'
17
18
  import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
18
19
  import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
20
+ import { manifest, openMediaLibraryWithCallback } from '../signals'
21
+ import type { ComponentProp } from '../types'
19
22
 
20
23
  const MDX_COMPONENT_ICON_PATH =
21
24
  'M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5'
@@ -301,6 +304,101 @@ function InlineInput({ value, onChange, placeholder }: { value: string; onChange
301
304
  )
302
305
  }
303
306
 
307
+ const INLINE_INPUT_TYPES: Record<string, string> = {
308
+ number: 'number',
309
+ url: 'url',
310
+ date: 'date',
311
+ datetime: 'datetime-local',
312
+ time: 'time',
313
+ email: 'email',
314
+ }
315
+ const inputClass =
316
+ 'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
317
+
318
+ function InlinePropField(
319
+ { name, value, propDef, onChange }: { name: string; value: string; propDef?: ComponentProp; onChange: (v: string) => void },
320
+ ) {
321
+ const typeLower = propDef?.type.toLowerCase() ?? ''
322
+
323
+ if (typeLower === 'boolean') {
324
+ return (
325
+ <div class="flex items-center gap-2">
326
+ <label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
327
+ <label class="flex items-center gap-2 cursor-pointer py-1">
328
+ <input
329
+ type="checkbox"
330
+ checked={value === 'true'}
331
+ onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
332
+ class="accent-cms-primary w-4 h-4 rounded"
333
+ />
334
+ <span class="text-[12px] text-white/60">{value === 'true' ? 'Yes' : 'No'}</span>
335
+ </label>
336
+ </div>
337
+ )
338
+ }
339
+
340
+ if (typeLower === 'image') {
341
+ return (
342
+ <div class="flex items-center gap-2">
343
+ <label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
344
+ <div class="flex gap-1.5 flex-1">
345
+ <InlineInput value={value} onChange={onChange} placeholder="Select an image..." />
346
+ <button
347
+ type="button"
348
+ onClick={() => openMediaLibraryWithCallback((url: string) => onChange(url))}
349
+ class="px-2 py-1.5 bg-white/5 border border-white/10 text-white/50 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors shrink-0"
350
+ title="Browse media"
351
+ >
352
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
353
+ <path
354
+ stroke-linecap="round"
355
+ stroke-linejoin="round"
356
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
357
+ />
358
+ </svg>
359
+ </button>
360
+ </div>
361
+ </div>
362
+ )
363
+ }
364
+
365
+ if (typeLower === 'color') {
366
+ return (
367
+ <div class="flex items-center gap-2">
368
+ <label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
369
+ <div class="flex gap-1.5 flex-1 items-center">
370
+ <input
371
+ type="color"
372
+ value={value || '#000000'}
373
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
374
+ class="w-7 h-7 rounded-cms-sm border border-white/10 bg-transparent cursor-pointer shrink-0"
375
+ />
376
+ <InlineInput value={value} onChange={onChange} placeholder="#000000" />
377
+ </div>
378
+ </div>
379
+ )
380
+ }
381
+
382
+ const htmlType = INLINE_INPUT_TYPES[typeLower]
383
+
384
+ return (
385
+ <div class="flex items-center gap-2">
386
+ <label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
387
+ {htmlType
388
+ ? (
389
+ <input
390
+ type={htmlType}
391
+ value={value}
392
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
393
+ placeholder={`Enter ${name}...`}
394
+ class={inputClass}
395
+ />
396
+ )
397
+ : <InlineInput value={value} onChange={onChange} placeholder={`Enter ${name}...`} />}
398
+ </div>
399
+ )
400
+ }
401
+
304
402
  // ============================================================================
305
403
  // Block Card
306
404
  // ============================================================================
@@ -311,6 +409,14 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
311
409
  const expressionProps = propEntries.filter(([_, v]) => v.startsWith(MDX_EXPR_PREFIX))
312
410
 
313
411
  const hasSlotContent = onSlotContentChange != null
412
+ const definition = getComponentDefinition(manifest.value, componentName)
413
+ const propTypes = useMemo(() => {
414
+ const map = new Map<string, ComponentProp>()
415
+ if (definition?.props) {
416
+ for (const p of definition.props) map.set(p.name, p)
417
+ }
418
+ return map
419
+ }, [definition])
314
420
 
315
421
  const handlePropChange = (name: string, newValue: string) => {
316
422
  if (onPropsChange) {
@@ -330,21 +436,23 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
330
436
  <span class="text-[13px] font-semibold text-white">{componentName}</span>
331
437
  {hasExpressions && <span class="text-[10px] px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded font-mono">expr</span>}
332
438
  </div>
333
- <button
334
- type="button"
335
- data-mdx-action="remove"
336
- onClick={onRemove}
337
- class="p-1.5 rounded-cms-sm text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
338
- title="Remove block"
339
- >
340
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
341
- <path
342
- stroke-linecap="round"
343
- stroke-linejoin="round"
344
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
345
- />
346
- </svg>
347
- </button>
439
+ <div class="flex items-center gap-1">
440
+ <button
441
+ type="button"
442
+ data-mdx-action="remove"
443
+ onClick={onRemove}
444
+ class="p-1.5 rounded-cms-sm text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
445
+ title="Remove block"
446
+ >
447
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
448
+ <path
449
+ stroke-linecap="round"
450
+ stroke-linejoin="round"
451
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
452
+ />
453
+ </svg>
454
+ </button>
455
+ </div>
348
456
  </div>
349
457
 
350
458
  {/* Slot content editor */}
@@ -361,14 +469,13 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
361
469
  {onPropsChange && editableProps.length > 0 && (
362
470
  <div class="px-4 py-3 space-y-2" data-mdx-action="props">
363
471
  {editableProps.map(([name, value]) => (
364
- <div key={name} class="flex items-center gap-2">
365
- <label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
366
- <InlineInput
367
- value={value}
368
- onChange={(v) => handlePropChange(name, v)}
369
- placeholder={`Enter ${name}...`}
370
- />
371
- </div>
472
+ <InlinePropField
473
+ key={name}
474
+ name={name}
475
+ value={value}
476
+ propDef={propTypes.get(name)}
477
+ onChange={(v) => handlePropChange(name, v)}
478
+ />
372
479
  ))}
373
480
  </div>
374
481
  )}
@@ -240,6 +240,11 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
240
240
  const renderCard = (node: PmNode) => {
241
241
  const componentName = node.attrs.componentName as string
242
242
  const propsJson = node.attrs.props as string
243
+
244
+ // Store attrs on the wrapper so preview mode can read them from copied DOM
245
+ container.setAttribute('data-mdx-component', componentName)
246
+ container.setAttribute('data-mdx-props', propsJson)
247
+ container.setAttribute('data-mdx-children', (node.attrs.children as string) || '')
243
248
  const props: Record<string, string> = JSON.parse(propsJson)
244
249
  const hasExpressions = node.attrs.hasExpressions as boolean
245
250
  const children = (node.attrs.children as string) || ''
@@ -1,42 +1,47 @@
1
+ import { z } from 'astro/zod'
2
+
1
3
  /**
2
- * Semantic field type wrappers for Zod schemas in content collections.
4
+ * Semantic field type schemas for content collections.
3
5
  *
4
- * These are identity functions they return exactly what's passed in.
5
- * The CMS collection scanner detects them by name in the source and
6
- * renders the appropriate editor input.
6
+ * Each method returns a `z.string()` schema that Astro resolves
7
+ * statically (concrete return type, no generics). The CMS collection
8
+ * scanner detects them by name in the source and renders the
9
+ * appropriate editor input.
10
+ *
11
+ * Chain Zod methods as usual (`.optional()`, `.default()`, etc.).
7
12
  *
8
13
  * @example
9
14
  * ```ts
10
- * import { field } from '@nuasite/cms'
15
+ * import { n } from '@nuasite/cms'
11
16
  * import { z } from 'astro/zod'
12
17
  *
13
18
  * const schema = z.object({
14
- * photo: field.image(z.string()),
15
- * website: field.url(z.string()),
16
- * contact: field.email(z.string()),
17
- * accent: field.color(z.string()),
18
- * publishedAt: field.date(z.string()),
19
- * startsAt: field.datetime(z.string()),
20
- * opensAt: field.time(z.string()),
21
- * bio: field.textarea(z.string()),
19
+ * photo: n.image(),
20
+ * website: n.url().optional(),
21
+ * contact: n.email(),
22
+ * accent: n.color(),
23
+ * publishedAt: n.date(),
24
+ * startsAt: n.datetime(),
25
+ * opensAt: n.time(),
26
+ * bio: n.textarea(),
22
27
  * })
23
28
  * ```
24
29
  */
25
- export const field = {
30
+ export const n = {
26
31
  /** Image picker (opens media library) */
27
- image: <T>(schema: T): T => schema,
32
+ image: () => z.string().describe('cms:image'),
28
33
  /** URL input */
29
- url: <T>(schema: T): T => schema,
34
+ url: () => z.string().describe('cms:url'),
30
35
  /** Email input */
31
- email: <T>(schema: T): T => schema,
36
+ email: () => z.string().describe('cms:email'),
32
37
  /** Color picker */
33
- color: <T>(schema: T): T => schema,
38
+ color: () => z.string().describe('cms:color'),
34
39
  /** Date picker */
35
- date: <T>(schema: T): T => schema,
40
+ date: () => z.string().describe('cms:date'),
36
41
  /** Date + time picker */
37
- datetime: <T>(schema: T): T => schema,
42
+ datetime: () => z.string().describe('cms:datetime'),
38
43
  /** Time picker */
39
- time: <T>(schema: T): T => schema,
44
+ time: () => z.string().describe('cms:time'),
40
45
  /** Multiline textarea */
41
- textarea: <T>(schema: T): T => schema,
46
+ textarea: () => z.string().describe('cms:textarea'),
42
47
  }
package/src/index.ts CHANGED
@@ -119,10 +119,17 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
119
119
  return {
120
120
  name: '@nuasite/cms',
121
121
  hooks: {
122
- 'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
122
+ 'astro:config:setup': async ({ updateConfig, command, injectScript, injectRoute, logger }) => {
123
123
  // CMS is only needed during dev — skip all setup during build
124
124
  if (command !== 'dev') return
125
125
 
126
+ // Inject dev-only component preview route (prerender:false → SSR with query param access)
127
+ injectRoute({
128
+ pattern: '/_nua/preview',
129
+ entrypoint: new URL('./pages/component-preview.astro', import.meta.url).pathname,
130
+ prerender: false,
131
+ })
132
+
126
133
  // --- CMS Marker setup ---
127
134
  idCounter.value = 0
128
135
  manifestWriter.reset()
@@ -200,13 +207,17 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
200
207
  injectScript(
201
208
  'page',
202
209
  `
203
- ${configScript}
204
- if (!document.querySelector('script[data-nuasite-cms]')) {
205
- const s = document.createElement('script');
206
- s.type = 'module';
207
- s.src = ${JSON.stringify(editorSrc)};
208
- s.dataset.nuasiteCms = '';
209
- document.head.appendChild(s);
210
+ if (window.location.pathname.startsWith('/_nua/')) {
211
+ // Skip CMS editor on internal preview pages
212
+ } else {
213
+ ${configScript}
214
+ if (!document.querySelector('script[data-nuasite-cms]')) {
215
+ const s = document.createElement('script');
216
+ s.type = 'module';
217
+ s.src = ${JSON.stringify(editorSrc)};
218
+ s.dataset.nuasiteCms = '';
219
+ document.head.appendChild(s);
220
+ }
210
221
  }
211
222
  `,
212
223
  )
@@ -346,7 +357,7 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
346
357
  logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
347
358
  }
348
359
 
349
- export { field } from './field-types'
360
+ export { n } from './field-types'
350
361
  export { createContemberStorageAdapter as contemberMedia } from './media/contember'
351
362
  export { createLocalStorageAdapter as localMedia } from './media/local'
352
363
  export { createS3StorageAdapter as s3Media } from './media/s3'
@@ -0,0 +1,56 @@
1
+ ---
2
+ // @ts-ignore — virtual module provided by CMS vite plugin
3
+ import { components } from 'virtual:cms-component-preview'
4
+
5
+ const componentFile = Astro.url.searchParams.get('file')
6
+ const childrenMarkdown = Astro.url.searchParams.get('children')
7
+
8
+ let props: Record<string, unknown> = {}
9
+ const propsParam = Astro.url.searchParams.get('props')
10
+ if (propsParam) {
11
+ try {
12
+ props = JSON.parse(propsParam)
13
+ } catch {}
14
+ }
15
+
16
+ const loader = componentFile ? components[componentFile] : undefined
17
+ const Component = loader ? (await loader()).default : null
18
+
19
+ // Render markdown children to HTML
20
+ let childrenHtml = ''
21
+ if (childrenMarkdown) {
22
+ try {
23
+ const { unified } = await import('unified')
24
+ const { default: remarkParse } = await import('remark-parse')
25
+ const { default: remarkRehype } = await import('remark-rehype')
26
+ const { default: rehypeStringify } = await import('rehype-stringify')
27
+ const result = await unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).process(childrenMarkdown)
28
+ childrenHtml = String(result)
29
+ } catch {
30
+ childrenHtml = childrenMarkdown
31
+ }
32
+ }
33
+
34
+ // Import the project's global CSS so components render with correct styles.
35
+ // Glob matches common CSS entry points — Vite resolves relative to project root.
36
+ const globalStyles = import.meta.glob(['/src/styles/**/*.css', '/src/styles/**/*.scss'], { eager: true })
37
+ ---
38
+
39
+ <html>
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
43
+ <style>
44
+ html, body { margin: 0; padding: 0; background: white; }
45
+ body { padding: 8px; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ {Component
50
+ ? childrenHtml
51
+ ? <Component {...props}><Fragment set:html={childrenHtml} /></Component>
52
+ : <Component {...props} />
53
+ : <p style="color:#888;font-size:13px;margin:0">No preview available</p>
54
+ }
55
+ </body>
56
+ </html>
@@ -1,4 +1,4 @@
1
- import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry } from './types'
1
+ import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
2
2
 
3
3
  // ============================================================================
4
4
  // File Parsing Cache - Avoid re-parsing the same files
@@ -18,6 +18,9 @@ const textSearchIndex: SearchIndexEntry[] = []
18
18
  const imageSearchIndex: ImageIndexEntry[] = []
19
19
  let searchIndexInitialized = false
20
20
 
21
+ /** Pre-built reverse index: normalizedText → SourceLocation[] (collection data files) */
22
+ let collectionTextIndex: Map<string, SourceLocation[]> | null = null
23
+
21
24
  /** Files that changed since last indexing — tracked by Vite watcher */
22
25
  const dirtyFiles = new Set<string>()
23
26
 
@@ -61,6 +64,14 @@ export function addToImageSearchIndex(entry: ImageIndexEntry): void {
61
64
  imageSearchIndex.push(entry)
62
65
  }
63
66
 
67
+ export function getCollectionTextIndex(): Map<string, SourceLocation[]> | null {
68
+ return collectionTextIndex
69
+ }
70
+
71
+ export function setCollectionTextIndex(index: Map<string, SourceLocation[]> | null): void {
72
+ collectionTextIndex = index
73
+ }
74
+
64
75
  // ============================================================================
65
76
  // Dirty File Tracking (incremental re-indexing)
66
77
  // ============================================================================
@@ -120,4 +131,5 @@ export function clearSourceFinderCache(): void {
120
131
  textSearchIndex.length = 0
121
132
  imageSearchIndex.length = 0
122
133
  searchIndexInitialized = false
134
+ collectionTextIndex = null
123
135
  }