@nuasite/cms 0.18.0 → 0.19.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.
Files changed (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
- import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
4
+ import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
5
5
 
6
6
  export interface LocalStorageOptions {
7
7
  /** Directory to store media files (relative to project root or absolute). Default: 'public/uploads' */
@@ -20,16 +20,29 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
20
20
  async list(opts) {
21
21
  const limit = opts?.limit ?? 50
22
22
  const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0
23
+ const folder = opts?.folder ?? ''
23
24
 
24
- await fs.mkdir(dir, { recursive: true })
25
+ const targetDir = folder ? path.join(dir, folder) : dir
26
+ await fs.mkdir(targetDir, { recursive: true })
25
27
 
26
- const entries = await fs.readdir(dir, { withFileTypes: true })
28
+ const entries = await fs.readdir(targetDir, { withFileTypes: true })
29
+
30
+ // Collect subfolders
31
+ const folders: MediaFolderItem[] = entries
32
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
33
+ .map((e) => ({
34
+ name: e.name,
35
+ path: folder ? `${folder}/${e.name}` : e.name,
36
+ }))
37
+ .sort((a, b) => a.name.localeCompare(b.name))
38
+
39
+ // Collect files
27
40
  const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
28
41
 
29
42
  // Get stats for sorting by mtime desc
30
43
  const withStats = await Promise.all(
31
44
  files.map(async (f) => {
32
- const filePath = path.join(dir, f.name)
45
+ const filePath = path.join(targetDir, f.name)
33
46
  const stat = await fs.stat(filePath)
34
47
  return { name: f.name, stat }
35
48
  }),
@@ -39,45 +52,55 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
39
52
  const slice = withStats.slice(offset, offset + limit)
40
53
  const hasMore = offset + limit < withStats.length
41
54
 
55
+ const urlFolder = folder ? `/${folder}` : ''
42
56
  const items = slice.map((f) => {
43
57
  const ext = path.extname(f.name).toLowerCase()
44
58
  const contentType = mimeFromExt(ext)
45
59
  return {
46
- id: f.name,
47
- url: `${urlPrefix}/${f.name}`,
60
+ id: folder ? `${folder}/${f.name}` : f.name,
61
+ url: `${urlPrefix}${urlFolder}/${f.name}`,
48
62
  filename: f.name,
49
63
  contentType,
50
64
  uploadedAt: f.stat.mtime.toISOString(),
65
+ folder: folder || undefined,
51
66
  }
52
67
  })
53
68
 
54
69
  return {
55
70
  items,
71
+ folders,
56
72
  hasMore,
57
73
  cursor: hasMore ? String(offset + limit) : undefined,
58
74
  } satisfies MediaListResult
59
75
  },
60
76
 
61
- async upload(file, filename, contentType) {
62
- await fs.mkdir(dir, { recursive: true })
77
+ async upload(file, filename, contentType, uploadOpts) {
78
+ const folder = uploadOpts?.folder ?? ''
79
+ const targetDir = folder ? path.join(dir, folder) : dir
80
+ await fs.mkdir(targetDir, { recursive: true })
63
81
 
64
82
  const ext = getFileExtension(filename)
65
83
  const uuid = randomUUID()
66
84
  const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
67
- const filePath = path.join(dir, newFilename)
85
+ const filePath = path.join(targetDir, newFilename)
68
86
 
69
87
  await fs.writeFile(filePath, file)
70
88
 
89
+ const urlFolder = folder ? `/${folder}` : ''
90
+ const id = folder ? `${folder}/${newFilename}` : newFilename
91
+
71
92
  return {
72
93
  success: true,
73
- url: `${urlPrefix}/${newFilename}`,
94
+ url: `${urlPrefix}${urlFolder}/${newFilename}`,
74
95
  filename: newFilename,
75
- id: newFilename,
96
+ id,
76
97
  } satisfies MediaUploadResult
77
98
  },
78
99
 
79
100
  async delete(id) {
80
- const filePath = path.join(dir, path.basename(id))
101
+ // id may contain folder path — resolve safely within dir
102
+ const safePath = id.split('/').map((s) => path.basename(s)).join('/')
103
+ const filePath = path.join(dir, safePath)
81
104
  try {
82
105
  await fs.unlink(filePath)
83
106
  return { success: true }
@@ -86,29 +109,44 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
86
109
  return { success: false, error: message }
87
110
  }
88
111
  },
112
+
113
+ async createFolder(folder) {
114
+ const segments = folder.split('/').filter(Boolean)
115
+ if (segments.some((s) => s === '..' || s === '.')) {
116
+ return { success: false, error: 'Invalid folder name' }
117
+ }
118
+ try {
119
+ await fs.mkdir(path.join(dir, ...segments), { recursive: true })
120
+ return { success: true }
121
+ } catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error)
123
+ return { success: false, error: message }
124
+ }
125
+ },
89
126
  }
90
127
  }
91
128
 
92
- function getFileExtension(filename: string): string {
129
+ export function getFileExtension(filename: string): string {
93
130
  const parts = filename.split('.')
94
131
  const ext = parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
95
132
  // Only allow alphanumeric extensions to prevent injection
96
133
  return /^[a-z0-9]+$/.test(ext) ? ext : ''
97
134
  }
98
135
 
99
- function mimeFromExt(ext: string): string {
100
- const map: Record<string, string> = {
101
- '.jpg': 'image/jpeg',
102
- '.jpeg': 'image/jpeg',
103
- '.png': 'image/png',
104
- '.gif': 'image/gif',
105
- '.webp': 'image/webp',
106
- '.avif': 'image/avif',
107
- '.svg': 'image/svg+xml',
108
- '.ico': 'image/x-icon',
109
- '.mp4': 'video/mp4',
110
- '.webm': 'video/webm',
111
- '.pdf': 'application/pdf',
112
- }
113
- return map[ext] ?? 'application/octet-stream'
136
+ export const MIME_BY_EXT: Record<string, string> = {
137
+ '.jpg': 'image/jpeg',
138
+ '.jpeg': 'image/jpeg',
139
+ '.png': 'image/png',
140
+ '.gif': 'image/gif',
141
+ '.webp': 'image/webp',
142
+ '.avif': 'image/avif',
143
+ '.svg': 'image/svg+xml',
144
+ '.ico': 'image/x-icon',
145
+ '.mp4': 'video/mp4',
146
+ '.webm': 'video/webm',
147
+ '.pdf': 'application/pdf',
148
+ }
149
+
150
+ export function mimeFromExt(ext: string): string {
151
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream'
114
152
  }
@@ -0,0 +1,81 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { getProjectRoot } from '../config'
5
+ import { MIME_BY_EXT, mimeFromExt } from './local'
6
+ import type { MediaItem } from './types'
7
+
8
+ const IMAGE_EXTENSIONS = new Set(Object.entries(MIME_BY_EXT).filter(([, mime]) => mime.startsWith('image/')).map(([ext]) => ext))
9
+
10
+ /**
11
+ * Scan the project for image files in `public/` and `src/` directories,
12
+ * excluding the media uploads directory to avoid duplicates.
13
+ */
14
+ export async function listProjectImages(options?: {
15
+ excludeDir?: string
16
+ }): Promise<MediaItem[]> {
17
+ const root = getProjectRoot()
18
+ const excludeDir = options?.excludeDir ? path.resolve(options.excludeDir) : null
19
+
20
+ const scanDirs = [
21
+ { dir: path.join(root, 'public'), urlPrefix: '' },
22
+ { dir: path.join(root, 'src'), urlPrefix: null },
23
+ ]
24
+
25
+ const results = await Promise.all(
26
+ scanDirs.map(({ dir, urlPrefix }) => {
27
+ const items: MediaItem[] = []
28
+ return scanDirectory(dir, dir, urlPrefix, excludeDir, items).then(() => items)
29
+ }),
30
+ )
31
+
32
+ const items = results.flat()
33
+ items.sort((a, b) => a.filename.localeCompare(b.filename))
34
+ return items
35
+ }
36
+
37
+ async function scanDirectory(
38
+ currentDir: string,
39
+ baseDir: string,
40
+ urlPrefix: string | null,
41
+ excludeDir: string | null,
42
+ items: MediaItem[],
43
+ ): Promise<void> {
44
+ if (excludeDir && path.resolve(currentDir) === excludeDir) return
45
+
46
+ let entries: Dirent[]
47
+ try {
48
+ entries = await fs.readdir(currentDir, { withFileTypes: true }) as Dirent[]
49
+ } catch {
50
+ return
51
+ }
52
+
53
+ const subdirs: Promise<void>[] = []
54
+
55
+ for (const entry of entries) {
56
+ const name = String(entry.name)
57
+ if (name.startsWith('.') || name === 'node_modules') continue
58
+ const fullPath = path.join(currentDir, name)
59
+
60
+ if (entry.isDirectory()) {
61
+ subdirs.push(scanDirectory(fullPath, baseDir, urlPrefix, excludeDir, items))
62
+ } else if (entry.isFile()) {
63
+ const ext = path.extname(name).toLowerCase()
64
+ if (!IMAGE_EXTENSIONS.has(ext)) continue
65
+
66
+ const relativePath = path.relative(baseDir, fullPath).split(path.sep).join('/')
67
+ const url = urlPrefix !== null
68
+ ? `/${relativePath}`
69
+ : `/${path.relative(getProjectRoot(), fullPath).split(path.sep).join('/')}`
70
+
71
+ items.push({
72
+ id: `project:${url}`,
73
+ url,
74
+ filename: name,
75
+ contentType: mimeFromExt(ext),
76
+ })
77
+ }
78
+ }
79
+
80
+ await Promise.all(subdirs)
81
+ }
package/src/media/s3.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import path from 'node:path'
3
- import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
3
+ import { getFileExtension, mimeFromExt } from './local'
4
+ import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
4
5
 
5
6
  export interface S3StorageOptions {
6
7
  bucket: string
@@ -52,15 +53,36 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
52
53
  const client = await getClient()
53
54
 
54
55
  const limit = opts?.limit ?? 50
56
+ const folder = opts?.folder ?? ''
57
+
58
+ // Build the S3 prefix: base prefix + subfolder
59
+ const listPrefix = [prefix, folder].filter(Boolean).join('/')
60
+ const delimiterPrefix = listPrefix ? `${listPrefix}/` : ''
61
+
55
62
  const command = new ListObjectsV2Command({
56
63
  Bucket: bucket,
57
- Prefix: prefix,
64
+ Prefix: delimiterPrefix,
65
+ Delimiter: '/',
58
66
  MaxKeys: limit + 1,
59
67
  ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
60
68
  })
61
69
 
62
70
  const result = await client.send(command)
63
- const contents = result.Contents ?? []
71
+
72
+ // Extract subfolders from CommonPrefixes
73
+ const folders: MediaFolderItem[] = (result.CommonPrefixes ?? []).map((cp: any) => {
74
+ const fullPrefix = (cp.Prefix as string).replace(/\/$/, '')
75
+ const name = fullPrefix.split('/').pop() ?? fullPrefix
76
+ const relativePath = prefix ? fullPrefix.slice(prefix.length + 1) : fullPrefix
77
+ return { name, path: relativePath }
78
+ })
79
+
80
+ // Extract files (exclude folder marker keys)
81
+ const contents = (result.Contents ?? []).filter((obj: any) => {
82
+ const key = obj.Key as string
83
+ return key !== delimiterPrefix
84
+ })
85
+
64
86
  const hasMore = contents.length > limit
65
87
  const items = contents.slice(0, limit).map((obj: any) => {
66
88
  const key = obj.Key as string
@@ -69,26 +91,30 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
69
91
  id: key,
70
92
  url: getUrl(key),
71
93
  filename,
72
- contentType: 'application/octet-stream',
94
+ contentType: mimeFromExt(path.extname(key).toLowerCase()),
73
95
  uploadedAt: obj.LastModified?.toISOString(),
96
+ folder: folder || undefined,
74
97
  }
75
98
  })
76
99
 
77
100
  return {
78
101
  items,
102
+ folders,
79
103
  hasMore,
80
104
  cursor: hasMore ? result.NextContinuationToken : undefined,
81
105
  } satisfies MediaListResult
82
106
  },
83
107
 
84
- async upload(file, filename, contentType) {
108
+ async upload(file, filename, contentType, uploadOpts) {
85
109
  const { PutObjectCommand } = await loadS3()
86
110
  const client = await getClient()
87
111
 
88
112
  const ext = getFileExtension(filename)
89
113
  const uuid = randomUUID()
90
114
  const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
91
- const key = prefix ? `${prefix}/${newFilename}` : newFilename
115
+ const folder = uploadOpts?.folder ?? ''
116
+ const keyParts = [prefix, folder, newFilename].filter(Boolean)
117
+ const key = keyParts.join('/')
92
118
 
93
119
  const command = new PutObjectCommand({
94
120
  Bucket: bucket,
@@ -126,8 +152,3 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
126
152
  },
127
153
  }
128
154
  }
129
-
130
- function getFileExtension(filename: string): string {
131
- const parts = filename.split('.')
132
- return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
133
- }
@@ -7,10 +7,30 @@ export interface MediaItem {
7
7
  width?: number
8
8
  height?: number
9
9
  uploadedAt?: string
10
+ /** Folder path relative to media root (e.g. 'photos') */
11
+ folder?: string
12
+ }
13
+
14
+ export interface MediaFolderItem {
15
+ /** Folder name (last segment) */
16
+ name: string
17
+ /** Full relative path from media root (e.g. 'photos/vacation') */
18
+ path: string
19
+ }
20
+
21
+ export type MediaTypeFilter = 'all' | 'photo' | 'graphic' | 'video' | 'document'
22
+
23
+ export interface MediaListOptions {
24
+ limit?: number
25
+ cursor?: string
26
+ /** List contents of this subfolder (relative to media root) */
27
+ folder?: string
10
28
  }
11
29
 
12
30
  export interface MediaListResult {
13
31
  items: MediaItem[]
32
+ /** Subfolders in the current directory */
33
+ folders: MediaFolderItem[]
14
34
  hasMore: boolean
15
35
  cursor?: string
16
36
  }
@@ -25,9 +45,11 @@ export interface MediaUploadResult {
25
45
  }
26
46
 
27
47
  export interface MediaStorageAdapter {
28
- list(options?: { limit?: number; cursor?: string }): Promise<MediaListResult>
29
- upload(file: Buffer, filename: string, contentType: string): Promise<MediaUploadResult>
48
+ list(options?: MediaListOptions): Promise<MediaListResult>
49
+ upload(file: Buffer, filename: string, contentType: string, options?: { folder?: string }): Promise<MediaUploadResult>
30
50
  delete(id: string): Promise<{ success: boolean; error?: string }>
51
+ /** Create an empty folder. Folders are also created implicitly on upload. */
52
+ createFolder?(folder: string): Promise<{ success: boolean; error?: string }>
31
53
  /** Local filesystem info for direct file serving in dev (bypasses Vite's public dir cache) */
32
54
  staticFiles?: { urlPrefix: string; dir: string }
33
55
  }
package/src/shared.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Slugify text for URL paths.
3
+ * Lowercases, strips non-word characters, collapses whitespace/underscores to hyphens.
4
+ */
5
+ export function slugify(text: string): string {
6
+ return text
7
+ .toLowerCase()
8
+ .trim()
9
+ .replace(/[^\w\s\-/]/g, '')
10
+ .replace(/[\s_]+/g, '-')
11
+ .replace(/^[-/]+|[-/]+$/g, '')
12
+ }
13
+
14
+ /**
15
+ * Slugify with diacritics normalization for href paths.
16
+ * "Lidé" → "lide", "Aktuálně z nezisku" → "aktualne-z-nezisku"
17
+ */
18
+ export function slugifyHref(text: string): string {
19
+ return '/' + text
20
+ .normalize('NFD')
21
+ .replace(/[\u0300-\u036f]/g, '')
22
+ .toLowerCase()
23
+ .trim()
24
+ .replace(/[^\w\s-]/g, '')
25
+ .replace(/[\s_]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ }