@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/dist/editor.js +23678 -23566
- package/package.json +1 -1
- package/src/dev-middleware.ts +7 -0
- package/src/editor/components/markdown-editor-overlay.tsx +37 -0
- package/src/editor/components/mdx-block-view.tsx +131 -24
- package/src/editor/milkdown-mdx-plugin.tsx +5 -0
- package/src/index.ts +19 -8
- package/src/pages/component-preview.astro +56 -0
- package/src/vite-plugin.ts +9 -0
package/package.json
CHANGED
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
-
<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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>
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
|