@nuasite/cms 0.18.1 → 0.19.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 +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getProjectRoot } from '../config'
|
|
4
|
+
import type { AddRedirectRequest, DeleteRedirectRequest, RedirectOperationResponse, RedirectRule, UpdateRedirectRequest } from '../types'
|
|
5
|
+
import { acquireFileLock, isNodeError } from '../utils'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_STATUS_CODE = 307
|
|
8
|
+
const REDIRECTS_FILE = 'src/_redirects'
|
|
9
|
+
|
|
10
|
+
function getRedirectsFilePath(): string {
|
|
11
|
+
return path.join(getProjectRoot(), REDIRECTS_FILE)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleGetRedirects(): Promise<{ rules: RedirectRule[] }> {
|
|
15
|
+
const lines = await readRedirectsFile(getRedirectsFilePath())
|
|
16
|
+
return { rules: parseRedirectLines(lines) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function handleAddRedirect(request: AddRedirectRequest): Promise<RedirectOperationResponse> {
|
|
20
|
+
const { source, destination, statusCode = DEFAULT_STATUS_CODE } = request
|
|
21
|
+
|
|
22
|
+
if (!source || !destination) {
|
|
23
|
+
return { success: false, error: 'Source and destination are required' }
|
|
24
|
+
}
|
|
25
|
+
if (!source.startsWith('/')) {
|
|
26
|
+
return { success: false, error: 'Source must start with /' }
|
|
27
|
+
}
|
|
28
|
+
if (!destination.startsWith('/') && !destination.startsWith('http')) {
|
|
29
|
+
return { success: false, error: 'Destination must start with / or http' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const filePath = getRedirectsFilePath()
|
|
33
|
+
const release = await acquireFileLock(filePath)
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const lines = await readRedirectsFile(filePath)
|
|
37
|
+
const existing = parseRedirectLines(lines)
|
|
38
|
+
|
|
39
|
+
if (existing.some(r => r.source === source)) {
|
|
40
|
+
return { success: false, error: `Redirect already exists for ${source}` }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
lines.push(formatRedirectLine(source, destination, statusCode))
|
|
44
|
+
await writeRedirectsFile(filePath, lines)
|
|
45
|
+
return { success: true }
|
|
46
|
+
} finally {
|
|
47
|
+
release()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function handleUpdateRedirect(request: UpdateRedirectRequest): Promise<RedirectOperationResponse> {
|
|
52
|
+
const { lineIndex, source, destination, statusCode = DEFAULT_STATUS_CODE } = request
|
|
53
|
+
|
|
54
|
+
if (!source || !destination) {
|
|
55
|
+
return { success: false, error: 'Source and destination are required' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const filePath = getRedirectsFilePath()
|
|
59
|
+
const release = await acquireFileLock(filePath)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const lines = await readRedirectsFile(filePath)
|
|
63
|
+
|
|
64
|
+
// Guard against stale line index
|
|
65
|
+
const currentLine = lines[lineIndex]?.trim()
|
|
66
|
+
if (!currentLine || currentLine.startsWith('#')) {
|
|
67
|
+
return { success: false, error: 'Line at index is no longer a redirect rule — please refresh and try again' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lines[lineIndex] = formatRedirectLine(source, destination, statusCode)
|
|
71
|
+
await writeRedirectsFile(filePath, lines)
|
|
72
|
+
return { success: true }
|
|
73
|
+
} finally {
|
|
74
|
+
release()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleDeleteRedirect(request: DeleteRedirectRequest): Promise<RedirectOperationResponse> {
|
|
79
|
+
const { lineIndex } = request
|
|
80
|
+
|
|
81
|
+
const filePath = getRedirectsFilePath()
|
|
82
|
+
const release = await acquireFileLock(filePath)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const lines = await readRedirectsFile(filePath)
|
|
86
|
+
|
|
87
|
+
if (lineIndex < 0 || lineIndex >= lines.length) {
|
|
88
|
+
return { success: false, error: `Invalid line index: ${lineIndex}` }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const line = lines[lineIndex]!.trim()
|
|
92
|
+
if (!line || line.startsWith('#')) {
|
|
93
|
+
return { success: false, error: 'Line is not a redirect rule' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lines.splice(lineIndex, 1)
|
|
97
|
+
await writeRedirectsFile(filePath, lines)
|
|
98
|
+
return { success: true }
|
|
99
|
+
} finally {
|
|
100
|
+
release()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Internal helpers ---
|
|
105
|
+
|
|
106
|
+
function formatRedirectLine(source: string, destination: string, statusCode: number): string {
|
|
107
|
+
return statusCode === DEFAULT_STATUS_CODE
|
|
108
|
+
? `${source} ${destination}`
|
|
109
|
+
: `${source} ${destination} ${statusCode}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readRedirectsFile(filePath: string): Promise<string[]> {
|
|
113
|
+
try {
|
|
114
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
115
|
+
return content.split('\n')
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (isNodeError(error, 'ENOENT')) return []
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function writeRedirectsFile(filePath: string, lines: string[]): Promise<void> {
|
|
123
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
124
|
+
|
|
125
|
+
const trimmed = lines.slice()
|
|
126
|
+
while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') {
|
|
127
|
+
trimmed.pop()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (trimmed.length === 0) {
|
|
131
|
+
await fs.writeFile(filePath, '', 'utf-8')
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await fs.writeFile(filePath, trimmed.join('\n') + '\n', 'utf-8')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseRedirectLines(lines: string[]): RedirectRule[] {
|
|
139
|
+
const rules: RedirectRule[] = []
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
const line = lines[i]!.trim()
|
|
143
|
+
if (!line || line.startsWith('#')) continue
|
|
144
|
+
|
|
145
|
+
const parts = line.split(/\s+/)
|
|
146
|
+
if (parts.length < 2) continue
|
|
147
|
+
|
|
148
|
+
const source = parts[0]!
|
|
149
|
+
if (!source.startsWith('/')) continue
|
|
150
|
+
|
|
151
|
+
const destination = parts[1]!
|
|
152
|
+
const statusCode = parts[2] ? parseInt(parts[2], 10) : DEFAULT_STATUS_CODE
|
|
153
|
+
|
|
154
|
+
rules.push({
|
|
155
|
+
source,
|
|
156
|
+
destination,
|
|
157
|
+
statusCode: Number.isNaN(statusCode) ? DEFAULT_STATUS_CODE : statusCode,
|
|
158
|
+
lineIndex: i,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return rules
|
|
163
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { NodeType, parse as parseHtml } from 'node-html-parser'
|
|
2
2
|
import fs from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
5
|
import { getProjectRoot } from '../config'
|
|
5
6
|
import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
|
|
6
7
|
import type { ManifestWriter } from '../manifest-writer'
|
|
8
|
+
import { extractAstroImageOriginalUrl } from '../source-finder/snippet-utils'
|
|
7
9
|
import type { CmsManifest, ManifestEntry } from '../types'
|
|
8
10
|
import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
|
|
9
11
|
|
|
@@ -59,7 +61,6 @@ export async function handleUpdate(
|
|
|
59
61
|
fileChanges,
|
|
60
62
|
manifest,
|
|
61
63
|
)
|
|
62
|
-
|
|
63
64
|
if (failedChanges.length > 0) {
|
|
64
65
|
errors.push(...failedChanges)
|
|
65
66
|
}
|
|
@@ -185,6 +186,32 @@ export function applyImageChange(
|
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// Extract original path from Astro Image optimization URLs (/_image?href=...)
|
|
190
|
+
const decodedHref = extractAstroImageOriginalUrl(originalSrc)
|
|
191
|
+
if (decodedHref && !srcCandidates.includes(decodedHref)) {
|
|
192
|
+
srcCandidates.push(decodedHref)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Extract the authored value from YAML/JSON source snippets.
|
|
196
|
+
// Astro optimizes images from content collections (e.g. ./images/photo.jpg → /assets/hash.webp),
|
|
197
|
+
// so the rendered URL won't match the value in the data file. Parse the snippet to recover it.
|
|
198
|
+
if (change.sourceSnippet) {
|
|
199
|
+
const yamlKeyMatch = change.sourceSnippet.match(/^\s*([\w][\w-]*):\s*/)
|
|
200
|
+
if (yamlKeyMatch?.[1]) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = parseYaml(change.sourceSnippet)
|
|
203
|
+
if (parsed && typeof parsed === 'object') {
|
|
204
|
+
const value = (parsed as Record<string, unknown>)[yamlKeyMatch[1]]
|
|
205
|
+
if (typeof value === 'string' && !srcCandidates.includes(value)) {
|
|
206
|
+
srcCandidates.push(value)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Not valid YAML, ignore
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
let newContent = content
|
|
189
216
|
let replacedIndex = -1
|
|
190
217
|
for (const srcToFind of srcCandidates) {
|
|
@@ -209,6 +236,46 @@ export function applyImageChange(
|
|
|
209
236
|
}
|
|
210
237
|
}
|
|
211
238
|
|
|
239
|
+
// Fallback: try YAML key-value replacement for collection frontmatter fields
|
|
240
|
+
// Try all srcCandidates since the rendered URL may differ from the authored YAML value
|
|
241
|
+
if (replacedIndex < 0 && change.sourceSnippet) {
|
|
242
|
+
for (const srcToFind of srcCandidates) {
|
|
243
|
+
const yamlResult = tryYamlValueReplacement(change.sourceSnippet, srcToFind, newSrc)
|
|
244
|
+
if (yamlResult !== null) {
|
|
245
|
+
// Search near the source line to avoid matching a duplicate snippet elsewhere
|
|
246
|
+
let searchStart = 0
|
|
247
|
+
if (change.sourceLine > 1) {
|
|
248
|
+
let linesFound = 0
|
|
249
|
+
for (let j = 0; j < newContent.length; j++) {
|
|
250
|
+
if (newContent[j] === '\n' && ++linesFound >= change.sourceLine - 1) {
|
|
251
|
+
searchStart = j + 1
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const snippetIdx = newContent.indexOf(change.sourceSnippet, searchStart)
|
|
257
|
+
if (snippetIdx >= 0) {
|
|
258
|
+
replacedIndex = snippetIdx
|
|
259
|
+
newContent = newContent.slice(0, snippetIdx) + yamlResult + newContent.slice(snippetIdx + change.sourceSnippet.length)
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Fallback: direct quoted-value replacement for data files (JSON, YAML, MD frontmatter)
|
|
267
|
+
// The source file may be a collection data file where the image is a plain string value
|
|
268
|
+
if (replacedIndex < 0 && change.sourceSnippet) {
|
|
269
|
+
for (const srcToFind of srcCandidates) {
|
|
270
|
+
const result = tryDataFileValueReplacement(newContent, change.sourceSnippet, srcToFind, newSrc, change.sourceLine)
|
|
271
|
+
if (result) {
|
|
272
|
+
replacedIndex = result.index
|
|
273
|
+
newContent = result.content
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
212
279
|
// Fallback: if literal src not found, try to find an expression-based src attribute
|
|
213
280
|
// near the source line (handles src={variable}, src={obj.prop}, etc.)
|
|
214
281
|
if (replacedIndex < 0 && change.sourceLine > 0) {
|
|
@@ -543,6 +610,13 @@ export function applyTextChange(
|
|
|
543
610
|
const updatedSnippet = sourceSnippet.replace(resolvedOriginal, resolvedNewText)
|
|
544
611
|
|
|
545
612
|
if (updatedSnippet === sourceSnippet) {
|
|
613
|
+
// Try YAML key-value replacement for multi-line frontmatter values
|
|
614
|
+
// (e.g., "title: long text\n that wraps")
|
|
615
|
+
const yamlResult = tryYamlValueReplacement(sourceSnippet, resolvedOriginal, resolvedNewText)
|
|
616
|
+
if (yamlResult !== null) {
|
|
617
|
+
return { success: true, content: content.replace(sourceSnippet, yamlResult) }
|
|
618
|
+
}
|
|
619
|
+
|
|
546
620
|
// Try AST-based <br> normalization (browser normalizes <br class="..." /> to <br>
|
|
547
621
|
// and collapses surrounding whitespace/indentation)
|
|
548
622
|
const brResult = tryBrNormalizedChange(sourceSnippet, resolvedOriginal, resolvedNewText)
|
|
@@ -774,6 +848,88 @@ function getVisibleText(html: string): string {
|
|
|
774
848
|
return text.trim()
|
|
775
849
|
}
|
|
776
850
|
|
|
851
|
+
/**
|
|
852
|
+
* Try to replace a YAML value in a frontmatter snippet.
|
|
853
|
+
* Uses the YAML parser to resolve the value (handles all scalar styles:
|
|
854
|
+
* plain wrapping, single/double quoted, block literal `|`, folded `>`).
|
|
855
|
+
* Returns the updated snippet, or null if this approach doesn't apply.
|
|
856
|
+
*/
|
|
857
|
+
function tryYamlValueReplacement(
|
|
858
|
+
sourceSnippet: string,
|
|
859
|
+
resolvedOriginal: string,
|
|
860
|
+
resolvedNewText: string,
|
|
861
|
+
): string | null {
|
|
862
|
+
// Must look like a YAML key: value pair
|
|
863
|
+
const keyMatch = sourceSnippet.match(/^(\s*([\w][\w-]*):\s*)/)
|
|
864
|
+
if (!keyMatch) return null
|
|
865
|
+
|
|
866
|
+
// Use the YAML parser to resolve the value — handles all scalar styles
|
|
867
|
+
try {
|
|
868
|
+
const parsed = parseYaml(sourceSnippet)
|
|
869
|
+
if (parsed == null || typeof parsed !== 'object') return null
|
|
870
|
+
const value = (parsed as Record<string, unknown>)[keyMatch[2]!]
|
|
871
|
+
if (typeof value !== 'string' && typeof value !== 'number') return null
|
|
872
|
+
if (String(value) !== resolvedOriginal) return null
|
|
873
|
+
} catch {
|
|
874
|
+
return null
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Use the YAML library to safely serialize the new value,
|
|
878
|
+
// handling characters that would break plain scalars (: # [ ] { } , etc.)
|
|
879
|
+
const serialized = stringifyYaml(resolvedNewText, { lineWidth: 0 }).trimEnd()
|
|
880
|
+
return `${keyMatch[1]}${serialized}`
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Replace an image value in a data file (JSON, YAML, MD frontmatter).
|
|
885
|
+
* Matches the original value as a quoted string within the source snippet context.
|
|
886
|
+
*/
|
|
887
|
+
function tryDataFileValueReplacement(
|
|
888
|
+
content: string,
|
|
889
|
+
sourceSnippet: string,
|
|
890
|
+
originalValue: string,
|
|
891
|
+
newValue: string,
|
|
892
|
+
sourceLine: number,
|
|
893
|
+
): { content: string; index: number } | null {
|
|
894
|
+
// Check if snippet contains the original value as a quoted string (JSON or YAML)
|
|
895
|
+
const doubleQuoted = `"${originalValue}"`
|
|
896
|
+
const singleQuoted = `'${originalValue}'`
|
|
897
|
+
|
|
898
|
+
let quotedOriginal: string
|
|
899
|
+
let quotedNew: string
|
|
900
|
+
if (sourceSnippet.includes(doubleQuoted)) {
|
|
901
|
+
quotedOriginal = doubleQuoted
|
|
902
|
+
quotedNew = `"${newValue}"`
|
|
903
|
+
} else if (sourceSnippet.includes(singleQuoted)) {
|
|
904
|
+
quotedOriginal = singleQuoted
|
|
905
|
+
quotedNew = `'${newValue}'`
|
|
906
|
+
} else {
|
|
907
|
+
return null
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const updatedSnippet = sourceSnippet.replace(quotedOriginal, quotedNew)
|
|
911
|
+
if (updatedSnippet === sourceSnippet) return null
|
|
912
|
+
|
|
913
|
+
// Find the snippet in content near the source line
|
|
914
|
+
let searchStart = 0
|
|
915
|
+
if (sourceLine > 1) {
|
|
916
|
+
let linesFound = 0
|
|
917
|
+
for (let j = 0; j < content.length; j++) {
|
|
918
|
+
if (content[j] === '\n' && ++linesFound >= sourceLine - 1) {
|
|
919
|
+
searchStart = j + 1
|
|
920
|
+
break
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const snippetIdx = content.indexOf(sourceSnippet, searchStart)
|
|
925
|
+
if (snippetIdx < 0) return null
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
content: content.slice(0, snippetIdx) + updatedSnippet + content.slice(snippetIdx + sourceSnippet.length),
|
|
929
|
+
index: snippetIdx,
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
777
933
|
/**
|
|
778
934
|
* Try to apply a text change when the mismatch is due to <br> normalization.
|
|
779
935
|
* The browser normalizes <br class="..." /> to plain <br> and collapses surrounding whitespace.
|
package/src/html-processor.ts
CHANGED
|
@@ -2,7 +2,16 @@ import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
|
|
|
2
2
|
import { processSeoFromHtml } from './seo-processor'
|
|
3
3
|
import { enhanceManifestWithSourceSnippets } from './source-finder'
|
|
4
4
|
import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
Attribute,
|
|
7
|
+
BackgroundImageMetadata,
|
|
8
|
+
CollectionDefinition,
|
|
9
|
+
ComponentInstance,
|
|
10
|
+
ImageMetadata,
|
|
11
|
+
ManifestEntry,
|
|
12
|
+
PageSeoData,
|
|
13
|
+
SeoOptions,
|
|
14
|
+
} from './types'
|
|
6
15
|
import { generateStableId } from './utils'
|
|
7
16
|
|
|
8
17
|
/** Type for parsed HTML element nodes from node-html-parser */
|
|
@@ -69,6 +78,8 @@ export interface ProcessHtmlOptions {
|
|
|
69
78
|
}
|
|
70
79
|
/** SEO tracking options */
|
|
71
80
|
seo?: SeoOptions
|
|
81
|
+
/** Collection definitions for resolving frontmatter text on listing pages */
|
|
82
|
+
collectionDefinitions?: Record<string, CollectionDefinition>
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
export interface ProcessHtmlResult {
|
|
@@ -223,6 +234,7 @@ export async function processHtml(
|
|
|
223
234
|
skipInlineStyleTags = true,
|
|
224
235
|
collectionInfo,
|
|
225
236
|
seo: seoOptions,
|
|
237
|
+
collectionDefinitions,
|
|
226
238
|
} = options
|
|
227
239
|
|
|
228
240
|
const root = parse(html, {
|
|
@@ -1013,7 +1025,7 @@ export async function processHtml(
|
|
|
1013
1025
|
|
|
1014
1026
|
// Enhance manifest entries with actual source snippets from source files
|
|
1015
1027
|
// This allows the CMS to match and replace dynamic content in source files
|
|
1016
|
-
const enhancedEntries = await enhanceManifestWithSourceSnippets(entries)
|
|
1028
|
+
const enhancedEntries = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
|
|
1017
1029
|
|
|
1018
1030
|
// Get the current HTML for SEO processing
|
|
1019
1031
|
let finalHtml = root.toString()
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AstroIntegration } from 'astro'
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
3
4
|
import { dirname, join } from 'node:path'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
6
|
|
|
@@ -12,7 +13,7 @@ import { getErrorCollector, resetErrorCollector } from './error-collector'
|
|
|
12
13
|
import { ManifestWriter } from './manifest-writer'
|
|
13
14
|
import { createLocalStorageAdapter } from './media/local'
|
|
14
15
|
import type { MediaStorageAdapter } from './media/types'
|
|
15
|
-
import type { CmsMarkerOptions, ComponentDefinition } from './types'
|
|
16
|
+
import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
|
|
16
17
|
import { createVitePlugin } from './vite-plugin'
|
|
17
18
|
|
|
18
19
|
export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
@@ -30,6 +31,7 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
30
31
|
debug?: boolean
|
|
31
32
|
theme?: Record<string, string>
|
|
32
33
|
themePreset?: string
|
|
34
|
+
features?: CmsFeatures
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* Proxy /_nua/cms requests to this target URL during dev.
|
|
@@ -41,6 +43,19 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
41
43
|
* Defaults to local filesystem (public/uploads) when no proxy is configured.
|
|
42
44
|
*/
|
|
43
45
|
media?: MediaStorageAdapter
|
|
46
|
+
/**
|
|
47
|
+
* Directories containing components available in the MDX component picker.
|
|
48
|
+
* Only components within these directories (relative to project root) will appear.
|
|
49
|
+
* Example: ['src/components/mdx'] or ['src/components/mdx', 'src/components/blocks']
|
|
50
|
+
*/
|
|
51
|
+
mdxComponentDirs?: string[]
|
|
52
|
+
/**
|
|
53
|
+
* Per-collection field overrides for position and grouping.
|
|
54
|
+
* Highest priority — overrides scanner defaults and frontmatter comment directives.
|
|
55
|
+
*/
|
|
56
|
+
collections?: Record<string, {
|
|
57
|
+
fields?: Record<string, { position?: 'sidebar' | 'header'; group?: string }>
|
|
58
|
+
}>
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
|
|
@@ -62,6 +77,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
62
77
|
markComponents = true,
|
|
63
78
|
componentDirs = ['src/components'],
|
|
64
79
|
contentDir = 'src/content',
|
|
80
|
+
mdxComponentDirs,
|
|
65
81
|
seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
|
|
66
82
|
} = options
|
|
67
83
|
|
|
@@ -96,6 +112,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
96
112
|
name: '@nuasite/cms',
|
|
97
113
|
hooks: {
|
|
98
114
|
'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
|
|
115
|
+
// CMS is only needed during dev — skip all setup during build
|
|
116
|
+
if (command !== 'dev') return
|
|
117
|
+
|
|
99
118
|
// --- CMS Marker setup ---
|
|
100
119
|
idCounter.value = 0
|
|
101
120
|
manifestWriter.reset()
|
|
@@ -110,6 +129,14 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
110
129
|
componentDefinitions = registry.getComponents()
|
|
111
130
|
manifestWriter.setComponentDefinitions(componentDefinitions)
|
|
112
131
|
|
|
132
|
+
if (mdxComponentDirs) {
|
|
133
|
+
const normalizedDirs = mdxComponentDirs.map(dir => dir.endsWith('/') ? dir : dir + '/')
|
|
134
|
+
const mdxNames = Object.values(componentDefinitions)
|
|
135
|
+
.filter(def => normalizedDirs.some(dir => def.file.startsWith(dir)))
|
|
136
|
+
.map(def => def.name)
|
|
137
|
+
manifestWriter.setMdxComponents(mdxNames)
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
const componentCount = Object.keys(componentDefinitions).length
|
|
114
141
|
if (componentCount > 0) {
|
|
115
142
|
logger.info(`Found ${componentCount} component definitions`)
|
|
@@ -117,6 +144,21 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
const collectionDefinitions = await scanCollections(contentDir)
|
|
147
|
+
|
|
148
|
+
// Apply per-collection field overrides from astro config (highest priority)
|
|
149
|
+
if (options.collections) {
|
|
150
|
+
for (const [collectionName, overrides] of Object.entries(options.collections)) {
|
|
151
|
+
const def = collectionDefinitions[collectionName]
|
|
152
|
+
if (!def || !overrides.fields) continue
|
|
153
|
+
for (const field of def.fields) {
|
|
154
|
+
const fieldOverride = overrides.fields[field.name]
|
|
155
|
+
if (!fieldOverride) continue
|
|
156
|
+
if (fieldOverride.position) field.position = fieldOverride.position
|
|
157
|
+
if (fieldOverride.group) field.group = fieldOverride.group
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
120
162
|
manifestWriter.setCollectionDefinitions(collectionDefinitions)
|
|
121
163
|
|
|
122
164
|
const collectionCount = Object.keys(collectionDefinitions).length
|
|
@@ -256,27 +298,49 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
256
298
|
},
|
|
257
299
|
|
|
258
300
|
'astro:build:done': async ({ dir, logger }) => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const errorCollector = getErrorCollector()
|
|
264
|
-
if (errorCollector.hasWarnings()) {
|
|
265
|
-
const warnings = errorCollector.getWarnings()
|
|
266
|
-
logger.warn(`${warnings.length} warning(s) during processing:`)
|
|
267
|
-
for (const { context, message } of warnings) {
|
|
268
|
-
logger.warn(` - ${context}: ${message}`)
|
|
269
|
-
}
|
|
270
|
-
}
|
|
301
|
+
// Merge CMS-managed redirects (src/_redirects) into dist/_redirects
|
|
302
|
+
await mergeRedirects(dir, logger)
|
|
271
303
|
},
|
|
272
304
|
},
|
|
273
305
|
}
|
|
274
306
|
}
|
|
275
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Merge CMS-managed redirects from src/_redirects into the build output's dist/_redirects.
|
|
310
|
+
* This ensures both Astro config redirects (written by adapters) and CMS-managed redirects coexist.
|
|
311
|
+
*/
|
|
312
|
+
async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void }): Promise<void> {
|
|
313
|
+
const srcRedirectsPath = join(process.cwd(), 'src', '_redirects')
|
|
314
|
+
|
|
315
|
+
let cmsRedirects: string
|
|
316
|
+
try {
|
|
317
|
+
cmsRedirects = (await fs.readFile(srcRedirectsPath, 'utf-8')).trim()
|
|
318
|
+
} catch {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (!cmsRedirects) return
|
|
322
|
+
|
|
323
|
+
const distDir = fileURLToPath(dir)
|
|
324
|
+
const distRedirectsPath = join(distDir, '_redirects')
|
|
325
|
+
|
|
326
|
+
let existing = ''
|
|
327
|
+
try {
|
|
328
|
+
existing = await fs.readFile(distRedirectsPath, 'utf-8')
|
|
329
|
+
} catch {
|
|
330
|
+
// File doesn't exist yet — will be created
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const separator = existing ? '\n\n# CMS-managed redirects\n' : '# CMS-managed redirects\n'
|
|
334
|
+
await fs.writeFile(distRedirectsPath, existing + separator + cmsRedirects + '\n', 'utf-8')
|
|
335
|
+
|
|
336
|
+
const lineCount = cmsRedirects.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length
|
|
337
|
+
logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
|
|
338
|
+
}
|
|
339
|
+
|
|
276
340
|
export { createContemberStorageAdapter as contemberMedia } from './media/contember'
|
|
277
341
|
export { createLocalStorageAdapter as localMedia } from './media/local'
|
|
278
342
|
export { createS3StorageAdapter as s3Media } from './media/s3'
|
|
279
|
-
export type { MediaItem, MediaStorageAdapter } from './media/types'
|
|
343
|
+
export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
|
|
280
344
|
|
|
281
345
|
export { scanCollections } from './collection-scanner'
|
|
282
346
|
export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
|
package/src/manifest-writer.ts
CHANGED
|
@@ -38,6 +38,7 @@ export class ManifestWriter {
|
|
|
38
38
|
private collectionDefinitions: Record<string, CollectionDefinition> = {}
|
|
39
39
|
private availableColors: AvailableColors | undefined
|
|
40
40
|
private availableTextStyles: AvailableTextStyles | undefined
|
|
41
|
+
private mdxComponents: string[] | undefined
|
|
41
42
|
private writeQueue: Promise<void> = Promise.resolve()
|
|
42
43
|
|
|
43
44
|
constructor(manifestFile: string, componentDefinitions: Record<string, ComponentDefinition> = {}) {
|
|
@@ -67,6 +68,14 @@ export class ManifestWriter {
|
|
|
67
68
|
this.globalManifest.componentDefinitions = definitions
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Set the list of component names allowed in the MDX component picker
|
|
73
|
+
*/
|
|
74
|
+
setMdxComponents(names: string[]): void {
|
|
75
|
+
this.mdxComponents = names
|
|
76
|
+
this.globalManifest.mdxComponents = names
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
/**
|
|
71
80
|
* Load available Tailwind colors and text styles from the project's CSS config
|
|
72
81
|
*/
|
|
@@ -98,7 +107,15 @@ export class ManifestWriter {
|
|
|
98
107
|
*/
|
|
99
108
|
setCollectionDefinitions(definitions: Record<string, CollectionDefinition>): void {
|
|
100
109
|
this.collectionDefinitions = definitions
|
|
101
|
-
|
|
110
|
+
// Strip entry.data before publishing to the manifest — it's only needed
|
|
111
|
+
// server-side (for reference detection) and would bloat the browser payload.
|
|
112
|
+
const stripped: Record<string, CollectionDefinition> = {}
|
|
113
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
114
|
+
stripped[name] = def.entries
|
|
115
|
+
? { ...def, entries: def.entries.map(({ data, ...rest }) => rest) }
|
|
116
|
+
: def
|
|
117
|
+
}
|
|
118
|
+
this.globalManifest.collectionDefinitions = stripped
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
/**
|
|
@@ -325,6 +342,7 @@ export class ManifestWriter {
|
|
|
325
342
|
collectionDefinitions: this.collectionDefinitions,
|
|
326
343
|
availableColors: this.availableColors,
|
|
327
344
|
availableTextStyles: this.availableTextStyles,
|
|
345
|
+
mdxComponents: this.mdxComponents,
|
|
328
346
|
}
|
|
329
347
|
this.writeQueue = Promise.resolve()
|
|
330
348
|
}
|
package/src/media/contember.ts
CHANGED
|
@@ -42,7 +42,8 @@ export function createContemberStorageAdapter(options: ContemberStorageOptions):
|
|
|
42
42
|
throw new Error(`Failed to list media (${res.status}): ${await res.text()}`)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const data = await res.json()
|
|
46
|
+
return { folders: [], ...data } as MediaListResult
|
|
46
47
|
},
|
|
47
48
|
|
|
48
49
|
async upload(file, filename, contentType) {
|