@nuasite/cms 0.42.1 → 0.43.0-beta.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 +1 -1
- package/package.json +3 -1
- package/src/dev-middleware.ts +12 -1
- package/src/handlers/api-routes.ts +192 -48
- package/src/handlers/page-ops.ts +4 -189
- package/src/index.ts +9 -4
- package/src/media/types.ts +11 -55
- package/src/tsconfig.json +5 -1
- package/src/types.ts +31 -225
- package/src/handlers/markdown-ops.ts +0 -474
- package/src/handlers/redirect-ops.ts +0 -163
- package/src/media/contember.ts +0 -85
- package/src/media/local.ts +0 -152
- package/src/media/project-images.ts +0 -81
- package/src/media/s3.ts +0 -154
|
@@ -1,474 +0,0 @@
|
|
|
1
|
-
import type { Dirent } from 'node:fs'
|
|
2
|
-
import fs from 'node:fs/promises'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import yaml from 'yaml'
|
|
5
|
-
import { getProjectRoot } from '../config'
|
|
6
|
-
import { parseContentConfig } from '../content-config-ast'
|
|
7
|
-
import type { ComponentDefinition } from '../types'
|
|
8
|
-
import { acquireFileLock, isNodeError, relativeImportPath, resolveAndValidatePath, slugify } from '../utils'
|
|
9
|
-
|
|
10
|
-
export interface BlogFrontmatter {
|
|
11
|
-
title: string
|
|
12
|
-
date: string
|
|
13
|
-
author?: string
|
|
14
|
-
categories?: string[]
|
|
15
|
-
excerpt?: string
|
|
16
|
-
featuredImage?: string
|
|
17
|
-
draft?: boolean
|
|
18
|
-
[key: string]: unknown
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface CreateMarkdownRequest {
|
|
22
|
-
collection: string
|
|
23
|
-
title: string
|
|
24
|
-
slug: string
|
|
25
|
-
frontmatter?: Partial<BlogFrontmatter>
|
|
26
|
-
content?: string
|
|
27
|
-
/** File extension override for data collections (e.g. 'json', 'yaml') */
|
|
28
|
-
fileExtension?: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface CreateMarkdownResponse {
|
|
32
|
-
success: boolean
|
|
33
|
-
filePath?: string
|
|
34
|
-
slug?: string
|
|
35
|
-
error?: string
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface UpdateMarkdownRequest {
|
|
39
|
-
filePath: string
|
|
40
|
-
frontmatter?: Partial<BlogFrontmatter>
|
|
41
|
-
content?: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface UpdateMarkdownResponse {
|
|
45
|
-
success: boolean
|
|
46
|
-
error?: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface DeleteMarkdownRequest {
|
|
50
|
-
filePath: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface DeleteMarkdownResponse {
|
|
54
|
-
success: boolean
|
|
55
|
-
error?: string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface GetMarkdownContentResponse {
|
|
59
|
-
content: string
|
|
60
|
-
frontmatter: BlogFrontmatter
|
|
61
|
-
filePath: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function handleGetMarkdownContent(
|
|
65
|
-
filePath: string,
|
|
66
|
-
): Promise<GetMarkdownContentResponse | null> {
|
|
67
|
-
try {
|
|
68
|
-
const fullPath = resolveAndValidatePath(filePath)
|
|
69
|
-
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
70
|
-
|
|
71
|
-
if (isDataFile(filePath)) {
|
|
72
|
-
const data = filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
|
|
73
|
-
return {
|
|
74
|
-
content: '',
|
|
75
|
-
frontmatter: (data && typeof data === 'object' ? data : {}) as BlogFrontmatter,
|
|
76
|
-
filePath,
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const { frontmatter, content } = parseFrontmatter(raw)
|
|
81
|
-
return {
|
|
82
|
-
content,
|
|
83
|
-
frontmatter: frontmatter as BlogFrontmatter,
|
|
84
|
-
filePath,
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export async function handleUpdateMarkdown(
|
|
92
|
-
request: UpdateMarkdownRequest,
|
|
93
|
-
componentDefinitions?: Record<string, ComponentDefinition>,
|
|
94
|
-
): Promise<UpdateMarkdownResponse> {
|
|
95
|
-
try {
|
|
96
|
-
const fullPath = resolveAndValidatePath(request.filePath)
|
|
97
|
-
const release = await acquireFileLock(fullPath)
|
|
98
|
-
try {
|
|
99
|
-
if (isDataFile(request.filePath)) {
|
|
100
|
-
// Data collections: merge and write JSON/YAML directly
|
|
101
|
-
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
102
|
-
const existing = request.filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
|
|
103
|
-
const merged = { ...(existing ?? {}), ...request.frontmatter }
|
|
104
|
-
|
|
105
|
-
const output = request.filePath.endsWith('.json')
|
|
106
|
-
? JSON.stringify(merged, null, 2) + '\n'
|
|
107
|
-
: yaml.stringify(merged)
|
|
108
|
-
await fs.writeFile(fullPath, output, 'utf-8')
|
|
109
|
-
} else {
|
|
110
|
-
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
111
|
-
const existing = parseFrontmatter(raw)
|
|
112
|
-
|
|
113
|
-
const mergedFrontmatter: BlogFrontmatter = {
|
|
114
|
-
...(existing.frontmatter as BlogFrontmatter),
|
|
115
|
-
...request.frontmatter,
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let finalContent = request.content ?? existing.content
|
|
119
|
-
|
|
120
|
-
if (request.filePath.endsWith('.mdx') && componentDefinitions) {
|
|
121
|
-
finalContent = ensureMdxImports(finalContent, request.filePath, componentDefinitions)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const markdownContent = serializeFrontmatter(mergedFrontmatter, finalContent)
|
|
125
|
-
await fs.writeFile(fullPath, markdownContent, 'utf-8')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { success: true }
|
|
129
|
-
} finally {
|
|
130
|
-
release()
|
|
131
|
-
}
|
|
132
|
-
} catch (error) {
|
|
133
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
134
|
-
return { success: false, error: message }
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function handleCreateMarkdown(
|
|
139
|
-
request: CreateMarkdownRequest,
|
|
140
|
-
): Promise<CreateMarkdownResponse> {
|
|
141
|
-
const { collection, title, slug, frontmatter = {}, content = '' } = request
|
|
142
|
-
|
|
143
|
-
const normalizedSlug = slugify(slug || title)
|
|
144
|
-
if (!normalizedSlug) {
|
|
145
|
-
return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const allowedExtensions = ['md', 'mdx', 'json', 'yaml', 'yml']
|
|
149
|
-
const ext = request.fileExtension ?? 'md'
|
|
150
|
-
if (!allowedExtensions.includes(ext)) {
|
|
151
|
-
return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
|
|
152
|
-
}
|
|
153
|
-
const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
|
|
154
|
-
const layout = isData ? 'flat' : await detectCollectionMarkdownLayout(collection)
|
|
155
|
-
const filePath = layout === 'index'
|
|
156
|
-
? `src/content/${collection}/${normalizedSlug}/index.${ext}`
|
|
157
|
-
: `src/content/${collection}/${normalizedSlug}.${ext}`
|
|
158
|
-
const fullPath = resolveAndValidatePath(filePath)
|
|
159
|
-
|
|
160
|
-
let fileContent: string
|
|
161
|
-
if (isData) {
|
|
162
|
-
const data = { ...frontmatter }
|
|
163
|
-
fileContent = ext === 'json'
|
|
164
|
-
? JSON.stringify(data, null, 2) + '\n'
|
|
165
|
-
: yaml.stringify(data)
|
|
166
|
-
} else {
|
|
167
|
-
const fullFrontmatter: BlogFrontmatter = {
|
|
168
|
-
title,
|
|
169
|
-
date: new Date().toISOString().split('T')[0]!,
|
|
170
|
-
...frontmatter,
|
|
171
|
-
}
|
|
172
|
-
fileContent = serializeFrontmatter(fullFrontmatter, content)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
177
|
-
await fs.writeFile(fullPath, fileContent, { encoding: 'utf-8', flag: 'wx' })
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
success: true,
|
|
181
|
-
filePath,
|
|
182
|
-
slug: normalizedSlug,
|
|
183
|
-
}
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
186
|
-
return { success: false, error: `File already exists: ${filePath}` }
|
|
187
|
-
}
|
|
188
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
189
|
-
return { success: false, error: message }
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export async function handleDeleteMarkdown(
|
|
194
|
-
request: DeleteMarkdownRequest,
|
|
195
|
-
): Promise<DeleteMarkdownResponse> {
|
|
196
|
-
try {
|
|
197
|
-
const fullPath = resolveAndValidatePath(request.filePath)
|
|
198
|
-
|
|
199
|
-
// Verify the file exists before deleting
|
|
200
|
-
await fs.access(fullPath)
|
|
201
|
-
await fs.unlink(fullPath)
|
|
202
|
-
|
|
203
|
-
return { success: true }
|
|
204
|
-
} catch (error) {
|
|
205
|
-
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
206
|
-
return { success: false, error: `File not found: ${request.filePath}` }
|
|
207
|
-
}
|
|
208
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
209
|
-
return { success: false, error: message }
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface RenameMarkdownRequest {
|
|
214
|
-
filePath: string
|
|
215
|
-
newSlug: string
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export interface RenameMarkdownResponse {
|
|
219
|
-
success: boolean
|
|
220
|
-
newFilePath?: string
|
|
221
|
-
newSlug?: string
|
|
222
|
-
error?: string
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function handleRenameMarkdown(
|
|
226
|
-
request: RenameMarkdownRequest,
|
|
227
|
-
): Promise<RenameMarkdownResponse> {
|
|
228
|
-
try {
|
|
229
|
-
const fullPath = resolveAndValidatePath(request.filePath)
|
|
230
|
-
const normalizedSlug = slugify(request.newSlug)
|
|
231
|
-
if (!normalizedSlug) {
|
|
232
|
-
return { success: false, error: 'Invalid slug' }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const dir = path.dirname(fullPath)
|
|
236
|
-
const ext = path.extname(fullPath)
|
|
237
|
-
const newFullPath = path.join(dir, `${normalizedSlug}${ext}`)
|
|
238
|
-
|
|
239
|
-
if (fullPath === newFullPath) {
|
|
240
|
-
return { success: true, newFilePath: request.filePath, newSlug: normalizedSlug }
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Acquire lock to prevent concurrent access during rename
|
|
244
|
-
const release = await acquireFileLock(fullPath)
|
|
245
|
-
try {
|
|
246
|
-
// Use link+unlink for atomic rename that fails if target exists
|
|
247
|
-
try {
|
|
248
|
-
await fs.link(fullPath, newFullPath)
|
|
249
|
-
} catch (err) {
|
|
250
|
-
if (isNodeError(err, 'EEXIST')) {
|
|
251
|
-
return { success: false, error: `File already exists: ${normalizedSlug}${ext}` }
|
|
252
|
-
}
|
|
253
|
-
throw err
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
await fs.unlink(fullPath)
|
|
257
|
-
} catch (err) {
|
|
258
|
-
// Clean up the new file if unlink of original fails
|
|
259
|
-
await fs.unlink(newFullPath).catch(() => {})
|
|
260
|
-
throw err
|
|
261
|
-
}
|
|
262
|
-
} finally {
|
|
263
|
-
release()
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Build project-relative path (normalize to forward slashes)
|
|
267
|
-
const projectRoot = getProjectRoot()
|
|
268
|
-
const newFilePath = path.relative(projectRoot, newFullPath).split(path.sep).join('/')
|
|
269
|
-
|
|
270
|
-
return { success: true, newFilePath, newSlug: normalizedSlug }
|
|
271
|
-
} catch (error) {
|
|
272
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
273
|
-
return { success: false, error: message }
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// --- Internal helpers ---
|
|
278
|
-
|
|
279
|
-
function isDataFile(filePath: string): boolean {
|
|
280
|
-
return filePath.endsWith('.json') || filePath.endsWith('.yaml') || filePath.endsWith('.yml')
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
type MarkdownCollectionLayout = 'flat' | 'index'
|
|
284
|
-
|
|
285
|
-
async function detectCollectionMarkdownLayout(collection: string): Promise<MarkdownCollectionLayout> {
|
|
286
|
-
const existingLayout = await inferLayoutFromExistingEntries(collection)
|
|
287
|
-
if (existingLayout) return existingLayout
|
|
288
|
-
|
|
289
|
-
const configLayout = await inferLayoutFromContentConfig(collection)
|
|
290
|
-
if (configLayout) return configLayout
|
|
291
|
-
|
|
292
|
-
return 'flat'
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function inferLayoutFromExistingEntries(collection: string): Promise<MarkdownCollectionLayout | null> {
|
|
296
|
-
const collectionPath = path.join(getProjectRoot(), 'src', 'content', collection)
|
|
297
|
-
|
|
298
|
-
let dirEntries: Dirent[]
|
|
299
|
-
try {
|
|
300
|
-
dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
|
|
301
|
-
} catch {
|
|
302
|
-
return null
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let flatCount = 0
|
|
306
|
-
let indexCount = 0
|
|
307
|
-
const flatSlugs = new Set<string>()
|
|
308
|
-
|
|
309
|
-
for (const entry of dirEntries) {
|
|
310
|
-
if (!entry.isFile()) continue
|
|
311
|
-
const match = entry.name.match(/^(.+)\.(md|mdx)$/)
|
|
312
|
-
if (!match) continue
|
|
313
|
-
flatCount++
|
|
314
|
-
flatSlugs.add(match[1]!)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const subdirs = dirEntries.filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
|
|
318
|
-
const indexLookups = await Promise.all(subdirs.map(async dir => {
|
|
319
|
-
if (flatSlugs.has(dir.name)) return false
|
|
320
|
-
for (const ext of ['md', 'mdx'] as const) {
|
|
321
|
-
try {
|
|
322
|
-
await fs.access(path.join(collectionPath, dir.name, `index.${ext}`))
|
|
323
|
-
return true
|
|
324
|
-
} catch {
|
|
325
|
-
// try next extension
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return false
|
|
329
|
-
}))
|
|
330
|
-
indexCount = indexLookups.filter(Boolean).length
|
|
331
|
-
|
|
332
|
-
if (indexCount > flatCount) return 'index'
|
|
333
|
-
if (flatCount > 0) return 'flat'
|
|
334
|
-
return null
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function inferLayoutFromContentConfig(collection: string): Promise<MarkdownCollectionLayout | null> {
|
|
338
|
-
try {
|
|
339
|
-
const parsed = await parseContentConfig()
|
|
340
|
-
const pattern = parsed.get(collection)?.loaderPattern
|
|
341
|
-
if (!pattern) return null
|
|
342
|
-
return isIndexStyleGlobPattern(pattern) ? 'index' : 'flat'
|
|
343
|
-
} catch {
|
|
344
|
-
return null
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function isIndexStyleGlobPattern(pattern: string): boolean {
|
|
349
|
-
return pattern.includes('index.{') || pattern.includes('*/index') || pattern.includes('**/index')
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
|
|
353
|
-
const trimmed = raw.trimStart()
|
|
354
|
-
if (!trimmed.startsWith('---')) {
|
|
355
|
-
return { frontmatter: {}, content: raw }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Find closing --- on its own line (not inside YAML values)
|
|
359
|
-
const lines = trimmed.split('\n')
|
|
360
|
-
let endLineIndex = -1
|
|
361
|
-
for (let i = 1; i < lines.length; i++) {
|
|
362
|
-
if (lines[i]!.trimEnd() === '---') {
|
|
363
|
-
endLineIndex = i
|
|
364
|
-
break
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
if (endLineIndex === -1) {
|
|
368
|
-
return { frontmatter: {}, content: raw }
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const yamlStr = lines.slice(1, endLineIndex).join('\n').trim()
|
|
372
|
-
const content = lines.slice(endLineIndex + 1).join('\n').replace(/^\r?\n/, '')
|
|
373
|
-
|
|
374
|
-
let frontmatter: Record<string, unknown> = {}
|
|
375
|
-
try {
|
|
376
|
-
frontmatter = (yaml.parse(yamlStr) as Record<string, unknown>) ?? {}
|
|
377
|
-
} catch {
|
|
378
|
-
// Invalid YAML, return empty frontmatter
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return { frontmatter, content }
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** Pattern for strings that YAML auto-parses as Date objects */
|
|
385
|
-
const YAML_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
|
|
386
|
-
|
|
387
|
-
function serializeFrontmatter(frontmatter: Record<string, unknown>, content: string): string {
|
|
388
|
-
const doc = new yaml.Document(frontmatter)
|
|
389
|
-
// Force-quote strings that YAML would auto-parse as dates
|
|
390
|
-
yaml.visit(doc, {
|
|
391
|
-
Scalar(_key, node) {
|
|
392
|
-
if (typeof node.value === 'string' && YAML_DATE_PATTERN.test(node.value)) {
|
|
393
|
-
node.type = yaml.Scalar.QUOTE_SINGLE
|
|
394
|
-
}
|
|
395
|
-
},
|
|
396
|
-
})
|
|
397
|
-
const yamlStr = doc.toString().trim()
|
|
398
|
-
return `---\n${yamlStr}\n---\n${content}`
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Ensure MDX content has import statements for all components used in the body.
|
|
403
|
-
* Scans for `<ComponentName` tags, checks for existing imports, and prepends missing ones.
|
|
404
|
-
*/
|
|
405
|
-
/** @internal Exported for testing */
|
|
406
|
-
export function ensureMdxImports(
|
|
407
|
-
content: string,
|
|
408
|
-
filePath: string,
|
|
409
|
-
componentDefinitions: Record<string, ComponentDefinition>,
|
|
410
|
-
): string {
|
|
411
|
-
// Find all component-like tags (capitalized names)
|
|
412
|
-
const usedComponents = new Set<string>()
|
|
413
|
-
const tagRegex = /<([A-Z][A-Za-z0-9]*)\b/g
|
|
414
|
-
let match
|
|
415
|
-
while ((match = tagRegex.exec(content)) !== null) {
|
|
416
|
-
if (match[1]) usedComponents.add(match[1])
|
|
417
|
-
}
|
|
418
|
-
if (usedComponents.size === 0) return content
|
|
419
|
-
|
|
420
|
-
// Find already-imported names and track the last import position in a single pass
|
|
421
|
-
const importedNames = new Set<string>()
|
|
422
|
-
const importLineRegex = /^import\s+(.+)\s+from\s+/gm
|
|
423
|
-
let lastImportEnd = -1
|
|
424
|
-
while ((match = importLineRegex.exec(content)) !== null) {
|
|
425
|
-
lastImportEnd = match.index + match[0].length
|
|
426
|
-
// Advance past the `from '...'` portion to find the true line end
|
|
427
|
-
const fromRest = content.slice(lastImportEnd)
|
|
428
|
-
const lineEnd = fromRest.indexOf('\n')
|
|
429
|
-
if (lineEnd >= 0) lastImportEnd += lineEnd
|
|
430
|
-
else lastImportEnd = content.length
|
|
431
|
-
|
|
432
|
-
const clause = match[1]!
|
|
433
|
-
// Extract named imports from braces: { A, B as C }
|
|
434
|
-
const braceMatch = clause.match(/\{([^}]+)\}/)
|
|
435
|
-
if (braceMatch?.[1]) {
|
|
436
|
-
for (const name of braceMatch[1].split(',')) {
|
|
437
|
-
const parts = name.trim().split(/\s+as\s+/)
|
|
438
|
-
const imported = (parts[1] ?? parts[0])?.trim()
|
|
439
|
-
if (imported) importedNames.add(imported)
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
// Extract default import and namespace import (* as X)
|
|
443
|
-
const withoutBraces = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim()
|
|
444
|
-
for (const token of withoutBraces.split(/\s+/)) {
|
|
445
|
-
if (token === '*' || token === 'as' || token === '') continue
|
|
446
|
-
importedNames.add(token)
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const root = getProjectRoot()
|
|
451
|
-
const mdxFullPath = path.join(root, filePath)
|
|
452
|
-
const missingImports: string[] = []
|
|
453
|
-
|
|
454
|
-
for (const name of usedComponents) {
|
|
455
|
-
if (importedNames.has(name)) continue
|
|
456
|
-
const def = componentDefinitions[name]
|
|
457
|
-
if (!def) continue
|
|
458
|
-
|
|
459
|
-
const componentAbsPath = path.join(root, def.file)
|
|
460
|
-
const rel = relativeImportPath(mdxFullPath, componentAbsPath)
|
|
461
|
-
missingImports.push(`import ${name} from '${rel}'`)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (missingImports.length === 0) return content
|
|
465
|
-
|
|
466
|
-
// Place after any existing import block, or at the top
|
|
467
|
-
const importBlock = missingImports.join('\n')
|
|
468
|
-
|
|
469
|
-
if (lastImportEnd >= 0) {
|
|
470
|
-
return content.slice(0, lastImportEnd) + '\n' + importBlock + content.slice(lastImportEnd)
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return importBlock + '\n\n' + content
|
|
474
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
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
|
-
}
|