@nuasite/cms 0.26.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.26.0",
17
+ "version": "0.27.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -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
  }
@@ -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) || ''
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
  )
@@ -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>
@@ -28,6 +28,9 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
28
28
  if (id === '/@cms/components' || id === 'virtual:cms-components') {
29
29
  return '\0virtual:cms-components'
30
30
  }
31
+ if (id === 'virtual:cms-component-preview') {
32
+ return '\0virtual:cms-component-preview'
33
+ }
31
34
  },
32
35
  load(id) {
33
36
  if (id === '\0virtual:cms-manifest') {
@@ -36,6 +39,12 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
36
39
  if (id === '\0virtual:cms-components') {
37
40
  return `export default ${JSON.stringify(componentDefinitions)};`
38
41
  }
42
+ if (id === '\0virtual:cms-component-preview') {
43
+ const entries = Object.values(componentDefinitions).map(
44
+ (def) => ` ${JSON.stringify(def.file)}: () => import(${JSON.stringify('/' + def.file)})`,
45
+ )
46
+ return `export const components = {\n${entries.join(',\n')}\n};`
47
+ }
39
48
  },
40
49
  }
41
50