@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/dist/editor.js +23390 -23278
- package/package.json +1 -1
- package/src/build-processor.ts +0 -2
- package/src/collection-scanner.ts +4 -4
- package/src/dev-middleware.ts +25 -18
- 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/field-types.ts +27 -22
- package/src/index.ts +20 -9
- package/src/pages/component-preview.astro +56 -0
- package/src/source-finder/cache.ts +13 -1
- package/src/source-finder/collection-finder.ts +6 -12
- package/src/source-finder/index.ts +0 -1
- package/src/source-finder/search-index.ts +2 -2
- package/src/vite-plugin.ts +9 -0
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
* - `
|
|
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
|
|
476
|
-
const fieldHelpers = schemaBody.matchAll(/(\w+)\s*:\s*
|
|
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]!
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
-
|
|
545
|
-
await initializeSearchIndex()
|
|
551
|
+
const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
|
|
546
552
|
|
|
547
|
-
//
|
|
548
|
-
for (const entry of Object.values(
|
|
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
|
|
551
|
-
if (
|
|
552
|
-
entry.sourcePath =
|
|
553
|
-
entry.sourceLine =
|
|
554
|
-
entry.sourceSnippet =
|
|
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
|
|
558
|
-
if (
|
|
559
|
-
entry.sourcePath =
|
|
560
|
-
entry.sourceLine =
|
|
561
|
-
entry.sourceSnippet =
|
|
562
|
-
if (
|
|
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,
|
|
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
|
-
<
|
|
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/field-types.ts
CHANGED
|
@@ -1,42 +1,47 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Semantic field type
|
|
4
|
+
* Semantic field type schemas for content collections.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
15
|
+
* import { n } from '@nuasite/cms'
|
|
11
16
|
* import { z } from 'astro/zod'
|
|
12
17
|
*
|
|
13
18
|
* const schema = z.object({
|
|
14
|
-
* photo:
|
|
15
|
-
* website:
|
|
16
|
-
* contact:
|
|
17
|
-
* accent:
|
|
18
|
-
* publishedAt:
|
|
19
|
-
* startsAt:
|
|
20
|
-
* opensAt:
|
|
21
|
-
* bio:
|
|
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
|
|
30
|
+
export const n = {
|
|
26
31
|
/** Image picker (opens media library) */
|
|
27
|
-
image:
|
|
32
|
+
image: () => z.string().describe('cms:image'),
|
|
28
33
|
/** URL input */
|
|
29
|
-
url:
|
|
34
|
+
url: () => z.string().describe('cms:url'),
|
|
30
35
|
/** Email input */
|
|
31
|
-
email:
|
|
36
|
+
email: () => z.string().describe('cms:email'),
|
|
32
37
|
/** Color picker */
|
|
33
|
-
color:
|
|
38
|
+
color: () => z.string().describe('cms:color'),
|
|
34
39
|
/** Date picker */
|
|
35
|
-
date:
|
|
40
|
+
date: () => z.string().describe('cms:date'),
|
|
36
41
|
/** Date + time picker */
|
|
37
|
-
datetime:
|
|
42
|
+
datetime: () => z.string().describe('cms:datetime'),
|
|
38
43
|
/** Time picker */
|
|
39
|
-
time:
|
|
44
|
+
time: () => z.string().describe('cms:time'),
|
|
40
45
|
/** Multiline textarea */
|
|
41
|
-
textarea:
|
|
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
|
-
|
|
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
|
)
|
|
@@ -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 {
|
|
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
|
}
|