@nuasite/cms 0.12.4 → 0.13.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/dist/editor.js +4993 -4912
- package/package.json +1 -1
- package/src/dev-middleware.ts +66 -9
- package/src/editor/components/collections-browser.tsx +112 -25
- package/src/editor/markdown-api.ts +27 -0
- package/src/handlers/array-ops.ts +139 -21
- package/src/handlers/markdown-ops.ts +29 -0
- package/src/handlers/source-writer.ts +1 -1
- package/src/html-processor.ts +60 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin-array-transform.ts +119 -0
- package/src/vite-plugin.ts +27 -2
package/src/html-processor.ts
CHANGED
|
@@ -339,6 +339,66 @@ export async function processHtml(
|
|
|
339
339
|
})
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
// Inline array detection pass: detect elements with data-cms-array-source
|
|
343
|
+
// (injected by vite-plugin-array-transform) and create virtual ComponentInstance entries
|
|
344
|
+
if (markComponents) {
|
|
345
|
+
root.querySelectorAll('[data-cms-array-source]').forEach((node) => {
|
|
346
|
+
const arrayVarName = node.getAttribute('data-cms-array-source')
|
|
347
|
+
if (!arrayVarName) return
|
|
348
|
+
|
|
349
|
+
// Walk ancestors to find invocationSourcePath and source line
|
|
350
|
+
let invocationSourcePath: string | undefined
|
|
351
|
+
let sourceLine = 0
|
|
352
|
+
let ancestor = node.parentNode as HTMLNode | null
|
|
353
|
+
while (ancestor) {
|
|
354
|
+
const ancestorSource = ancestor.getAttribute?.('data-astro-source-file')
|
|
355
|
+
if (ancestorSource) {
|
|
356
|
+
invocationSourcePath = ancestorSource
|
|
357
|
+
// Try to get source line from ancestor
|
|
358
|
+
const locAttr = ancestor.getAttribute?.('data-astro-source-loc')
|
|
359
|
+
|| ancestor.getAttribute?.('data-astro-source-line')
|
|
360
|
+
if (locAttr) {
|
|
361
|
+
sourceLine = parseInt(locAttr.split(':')[0] ?? '0', 10)
|
|
362
|
+
}
|
|
363
|
+
break
|
|
364
|
+
}
|
|
365
|
+
ancestor = ancestor.parentNode as HTMLNode | null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const componentName = `__array:${arrayVarName}`
|
|
369
|
+
|
|
370
|
+
// Track invocation index using existing componentCountPerParent map
|
|
371
|
+
let invocationIndex: number | undefined
|
|
372
|
+
if (invocationSourcePath) {
|
|
373
|
+
if (!componentCountPerParent.has(invocationSourcePath)) {
|
|
374
|
+
componentCountPerParent.set(invocationSourcePath, new Map())
|
|
375
|
+
}
|
|
376
|
+
const counters = componentCountPerParent.get(invocationSourcePath)!
|
|
377
|
+
const current = counters.get(componentName) ?? 0
|
|
378
|
+
counters.set(componentName, current + 1)
|
|
379
|
+
invocationIndex = current
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const id = getNextId()
|
|
383
|
+
node.setAttribute('data-cms-component-id', id)
|
|
384
|
+
|
|
385
|
+
components[id] = {
|
|
386
|
+
id,
|
|
387
|
+
componentName,
|
|
388
|
+
file: fileId,
|
|
389
|
+
sourcePath: invocationSourcePath ?? '',
|
|
390
|
+
sourceLine,
|
|
391
|
+
props: {},
|
|
392
|
+
invocationSourcePath,
|
|
393
|
+
invocationIndex,
|
|
394
|
+
isInlineArray: true,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Remove the marker attribute from output HTML
|
|
398
|
+
node.removeAttribute('data-cms-array-source')
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
342
402
|
// Second pass: mark span elements with text-only styling classes as styled spans
|
|
343
403
|
// This allows the CMS editor to recognize pre-existing styled text
|
|
344
404
|
if (markStyledSpans) {
|
package/src/types.ts
CHANGED
|
@@ -195,6 +195,8 @@ export interface ComponentInstance {
|
|
|
195
195
|
invocationSourcePath?: string
|
|
196
196
|
/** 0-based index among same-name component invocations in the parent file */
|
|
197
197
|
invocationIndex?: number
|
|
198
|
+
/** Whether this component represents an inline HTML element inside a .map() array */
|
|
199
|
+
isInlineArray?: boolean
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
/** Represents a content collection entry (markdown file) */
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
import { buildMapPattern } from './handlers/array-ops'
|
|
3
|
+
import { findFrontmatterEnd } from './handlers/component-ops'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vite transform plugin that injects `data-cms-array-source` markers on root HTML
|
|
7
|
+
* elements inside `.map()` callbacks in Astro template sections.
|
|
8
|
+
*
|
|
9
|
+
* This enables the CMS to detect inline array-rendered HTML elements (not just
|
|
10
|
+
* named Astro components) and provide add/remove array item operations.
|
|
11
|
+
*
|
|
12
|
+
* Only targets lowercase tags (inline HTML). Uppercase tags (components) are
|
|
13
|
+
* already supported via `data-astro-source-file` tracking.
|
|
14
|
+
*/
|
|
15
|
+
export function createArrayTransformPlugin(): Plugin {
|
|
16
|
+
return {
|
|
17
|
+
name: 'cms-array-transform',
|
|
18
|
+
enforce: 'pre',
|
|
19
|
+
transform(code, id) {
|
|
20
|
+
if (!id.endsWith('.astro')) return null
|
|
21
|
+
|
|
22
|
+
// Find template section (after closing ---)
|
|
23
|
+
const templateStart = findTemplateStart(code)
|
|
24
|
+
if (templateStart < 0) return null
|
|
25
|
+
|
|
26
|
+
const template = code.slice(templateStart)
|
|
27
|
+
const transformed = injectArraySourceMarkers(template)
|
|
28
|
+
if (transformed === template) return null
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
code: code.slice(0, templateStart) + transformed,
|
|
32
|
+
map: null,
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find the start of the template section in an Astro file.
|
|
40
|
+
* The template starts after the closing `---` of the frontmatter block.
|
|
41
|
+
*/
|
|
42
|
+
export function findTemplateStart(code: string): number {
|
|
43
|
+
const lines = code.split('\n')
|
|
44
|
+
const fmEndLine = findFrontmatterEnd(lines)
|
|
45
|
+
if (fmEndLine === 0) return 0 // No frontmatter, whole file is template
|
|
46
|
+
|
|
47
|
+
// Convert line index back to character offset
|
|
48
|
+
let offset = 0
|
|
49
|
+
for (let i = 0; i < fmEndLine; i++) {
|
|
50
|
+
offset += lines[i]!.length + 1 // +1 for the newline
|
|
51
|
+
}
|
|
52
|
+
return offset
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scan the template section for `.map(` patterns that render inline HTML elements,
|
|
57
|
+
* and inject `data-cms-array-source="varName"` on the root element.
|
|
58
|
+
*/
|
|
59
|
+
export function injectArraySourceMarkers(template: string): string {
|
|
60
|
+
// Match patterns like: {varName.map((item) => ( or {varName.map(item =>
|
|
61
|
+
// We process each match individually
|
|
62
|
+
const mapPattern = new RegExp(buildMapPattern(), 'g')
|
|
63
|
+
let result = template
|
|
64
|
+
let offset = 0
|
|
65
|
+
// Track how many .map() calls we've seen per variable name
|
|
66
|
+
const varMapCounts = new Map<string, number>()
|
|
67
|
+
|
|
68
|
+
for (const match of template.matchAll(mapPattern)) {
|
|
69
|
+
const arrayVarName = match[1]!
|
|
70
|
+
const matchEnd = match.index! + match[0].length
|
|
71
|
+
|
|
72
|
+
// Scan forward from the match to find the arrow `=>` and then the first `<tag`
|
|
73
|
+
const afterMatch = template.slice(matchEnd)
|
|
74
|
+
const arrowIndex = afterMatch.indexOf('=>')
|
|
75
|
+
if (arrowIndex < 0) continue
|
|
76
|
+
|
|
77
|
+
const afterArrow = afterMatch.slice(arrowIndex + 2)
|
|
78
|
+
// Find the first opening tag: `<tagName` where tagName starts with a letter.
|
|
79
|
+
// Supports multiple patterns:
|
|
80
|
+
// => <tag (direct return)
|
|
81
|
+
// => (<tag (parenthesized)
|
|
82
|
+
// => { return <tag (block body)
|
|
83
|
+
// => { return (<tag (block body, parenthesized)
|
|
84
|
+
// => expr && <tag (logical AND conditional)
|
|
85
|
+
// => cond ? <tag (ternary conditional)
|
|
86
|
+
const tagMatch = afterArrow.match(/^[\s(]*<([a-zA-Z][\w.-]*)/)
|
|
87
|
+
?? afterArrow.match(/^[\s]*\{[\s]*return[\s(]*<([a-zA-Z][\w.-]*)/)
|
|
88
|
+
?? afterArrow.match(/^[\s(]*[^<]*?&&\s*[\s(]*<([a-zA-Z][\w.-]*)/)
|
|
89
|
+
?? afterArrow.match(/^[\s(]*[^<]*?\?\s*[\s(]*<([a-zA-Z][\w.-]*)/)
|
|
90
|
+
if (!tagMatch) continue
|
|
91
|
+
|
|
92
|
+
const tagName = tagMatch[1]!
|
|
93
|
+
// Skip uppercase tags (Astro components) — they are already supported
|
|
94
|
+
if (tagName[0] === tagName[0]!.toUpperCase() && tagName[0] !== tagName[0]!.toLowerCase()) continue
|
|
95
|
+
|
|
96
|
+
// Find the exact position of this `<tagName` in the original template
|
|
97
|
+
const tagStartInAfterArrow = afterArrow.indexOf(tagMatch[0])
|
|
98
|
+
const absoluteTagPos = matchEnd + arrowIndex + 2 + tagStartInAfterArrow
|
|
99
|
+
// Position right after `<tagName`
|
|
100
|
+
const insertPos = absoluteTagPos + tagMatch[0].length
|
|
101
|
+
|
|
102
|
+
// Check if the attribute is already injected (search to closing >)
|
|
103
|
+
const closingBracket = template.indexOf('>', insertPos)
|
|
104
|
+
const searchEnd = closingBracket >= 0 ? closingBracket : template.length
|
|
105
|
+
const alreadyHasAttr = template.slice(insertPos, searchEnd).includes('data-cms-array-source')
|
|
106
|
+
if (alreadyHasAttr) continue
|
|
107
|
+
|
|
108
|
+
// Track occurrence index per variable name (for multiple .map() of same array)
|
|
109
|
+
const mapIndex = varMapCounts.get(arrayVarName) ?? 0
|
|
110
|
+
varMapCounts.set(arrayVarName, mapIndex + 1)
|
|
111
|
+
const attrValue = mapIndex === 0 ? arrayVarName : `${arrayVarName}#${mapIndex}`
|
|
112
|
+
|
|
113
|
+
const injection = ` data-cms-array-source="${attrValue}"`
|
|
114
|
+
result = result.slice(0, insertPos + offset) + injection + result.slice(insertPos + offset)
|
|
115
|
+
offset += injection.length
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
}
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from 'vite'
|
|
2
|
+
import { expectedDeletions } from './dev-middleware'
|
|
2
3
|
import type { ManifestWriter } from './manifest-writer'
|
|
3
4
|
import type { CmsMarkerOptions, ComponentDefinition } from './types'
|
|
5
|
+
import { createArrayTransformPlugin } from './vite-plugin-array-transform'
|
|
4
6
|
|
|
5
7
|
export interface VitePluginContext {
|
|
6
8
|
manifestWriter: ManifestWriter
|
|
@@ -11,7 +13,7 @@ export interface VitePluginContext {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function createVitePlugin(context: VitePluginContext): Plugin[] {
|
|
14
|
-
const { manifestWriter, componentDefinitions } = context
|
|
16
|
+
const { manifestWriter, componentDefinitions, command } = context
|
|
15
17
|
|
|
16
18
|
const virtualManifestPlugin: Plugin = {
|
|
17
19
|
name: 'cms-marker-virtual-manifest',
|
|
@@ -33,10 +35,33 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
|
|
|
33
35
|
},
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// Intercept Vite's file watcher to suppress full page reloads when the CMS
|
|
39
|
+
// deletes a content collection entry. Without this, Vite/Astro detects the
|
|
40
|
+
// unlink and forces a reload, undoing the optimistic UI update.
|
|
41
|
+
const watcherPlugin: Plugin = {
|
|
42
|
+
name: 'cms-suppress-delete-reload',
|
|
43
|
+
configureServer(server) {
|
|
44
|
+
if (command !== 'dev') return
|
|
45
|
+
|
|
46
|
+
// Monkey-patch the watcher to intercept unlink events before Vite/Astro
|
|
47
|
+
// processes them. We use prependListener so our handler runs first.
|
|
48
|
+
const watcher = server.watcher
|
|
49
|
+
const origEmit = watcher.emit.bind(watcher)
|
|
50
|
+
watcher.emit = function (event: string, filePath: string, ...args: any[]) {
|
|
51
|
+
if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
|
|
52
|
+
expectedDeletions.delete(filePath)
|
|
53
|
+
// Swallow the event — don't let Vite/Astro see it
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
return origEmit(event, filePath, ...args)
|
|
57
|
+
} as typeof watcher.emit
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
// Note: We cannot use transformIndexHtml for static Astro builds because
|
|
37
62
|
// Astro generates HTML files directly without going through Vite's HTML pipeline.
|
|
38
63
|
// HTML processing is done in build-processor.ts after pages are generated.
|
|
39
64
|
// Source location attributes are provided natively by Astro's compiler
|
|
40
65
|
// (data-astro-source-file, data-astro-source-loc) in dev mode.
|
|
41
|
-
return [virtualManifestPlugin]
|
|
66
|
+
return [virtualManifestPlugin, watcherPlugin, createArrayTransformPlugin()]
|
|
42
67
|
}
|