@nuasite/cms-core 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.
Files changed (54) hide show
  1. package/dist/types/collection-scanner.d.ts +12 -0
  2. package/dist/types/collection-scanner.d.ts.map +1 -0
  3. package/dist/types/component-registry.d.ts +15 -0
  4. package/dist/types/component-registry.d.ts.map +1 -0
  5. package/dist/types/content-config-ast.d.ts +45 -0
  6. package/dist/types/content-config-ast.d.ts.map +1 -0
  7. package/dist/types/core.d.ts +44 -0
  8. package/dist/types/core.d.ts.map +1 -0
  9. package/dist/types/fs/glob.d.ts +3 -0
  10. package/dist/types/fs/glob.d.ts.map +1 -0
  11. package/dist/types/fs/node-fs.d.ts +7 -0
  12. package/dist/types/fs/node-fs.d.ts.map +1 -0
  13. package/dist/types/fs/types.d.ts +33 -0
  14. package/dist/types/fs/types.d.ts.map +1 -0
  15. package/dist/types/handlers/entry-ops.d.ts +69 -0
  16. package/dist/types/handlers/entry-ops.d.ts.map +1 -0
  17. package/dist/types/handlers/page-ops.d.ts +14 -0
  18. package/dist/types/handlers/page-ops.d.ts.map +1 -0
  19. package/dist/types/handlers/redirect-ops.d.ts +10 -0
  20. package/dist/types/handlers/redirect-ops.d.ts.map +1 -0
  21. package/dist/types/index.d.ts +12 -0
  22. package/dist/types/index.d.ts.map +1 -0
  23. package/dist/types/media/contember.d.ts +18 -0
  24. package/dist/types/media/contember.d.ts.map +1 -0
  25. package/dist/types/media/index.d.ts +5 -0
  26. package/dist/types/media/index.d.ts.map +1 -0
  27. package/dist/types/media/local.d.ts +12 -0
  28. package/dist/types/media/local.d.ts.map +1 -0
  29. package/dist/types/media/project-images.d.ts +15 -0
  30. package/dist/types/media/project-images.d.ts.map +1 -0
  31. package/dist/types/media/s3.d.ts +12 -0
  32. package/dist/types/media/s3.d.ts.map +1 -0
  33. package/dist/types/shared.d.ts +24 -0
  34. package/dist/types/shared.d.ts.map +1 -0
  35. package/dist/types/tsconfig.tsbuildinfo +1 -0
  36. package/package.json +55 -0
  37. package/src/collection-scanner.ts +935 -0
  38. package/src/component-registry.ts +308 -0
  39. package/src/content-config-ast.ts +536 -0
  40. package/src/core.ts +167 -0
  41. package/src/fs/glob.ts +32 -0
  42. package/src/fs/node-fs.ts +138 -0
  43. package/src/fs/types.ts +26 -0
  44. package/src/handlers/entry-ops.ts +528 -0
  45. package/src/handlers/page-ops.ts +203 -0
  46. package/src/handlers/redirect-ops.ts +139 -0
  47. package/src/index.ts +41 -0
  48. package/src/media/contember.ts +90 -0
  49. package/src/media/index.ts +4 -0
  50. package/src/media/local.ts +147 -0
  51. package/src/media/project-images.ts +82 -0
  52. package/src/media/s3.ts +151 -0
  53. package/src/shared.ts +65 -0
  54. package/src/tsconfig.json +9 -0
@@ -0,0 +1,82 @@
1
+ import type { MediaItem } from '@nuasite/cms-types'
2
+ import type { CmsFileSystem } from '../fs/types'
3
+ import { MIME_BY_EXT, mimeFromExt } from './local'
4
+
5
+ const IMAGE_EXTENSIONS = new Set(Object.entries(MIME_BY_EXT).filter(([, mime]) => mime.startsWith('image/')).map(([ext]) => ext))
6
+
7
+ export interface ListProjectImagesOptions {
8
+ /** Root-relative directory to exclude (e.g. the media uploads dir), to avoid duplicates. */
9
+ excludeDir?: string
10
+ }
11
+
12
+ /**
13
+ * Scan the project for image files in `public/` and `src/` directories,
14
+ * excluding the media uploads directory, over the FileSystem port.
15
+ *
16
+ * - `public/` files are served from `/<path-relative-to-public>`.
17
+ * - `src/` files are served from `/<path-relative-to-project-root>`.
18
+ */
19
+ export async function listProjectImages(fs: CmsFileSystem, options?: ListProjectImagesOptions): Promise<MediaItem[]> {
20
+ const excludeDir = options?.excludeDir ? normalizeDir(options.excludeDir) : null
21
+
22
+ const scanDirs: Array<{ dir: string; relativeToRoot: boolean }> = [
23
+ { dir: 'public', relativeToRoot: false },
24
+ { dir: 'src', relativeToRoot: true },
25
+ ]
26
+
27
+ const results = await Promise.all(
28
+ scanDirs.map(async ({ dir, relativeToRoot }) => {
29
+ const items: MediaItem[] = []
30
+ await scanDirectory(fs, dir, dir, relativeToRoot, excludeDir, items)
31
+ return items
32
+ }),
33
+ )
34
+
35
+ const items = results.flat()
36
+ items.sort((a, b) => a.filename.localeCompare(b.filename))
37
+ return items
38
+ }
39
+
40
+ function normalizeDir(dir: string): string {
41
+ return dir.replace(/^\/+/, '').replace(/\/+$/, '')
42
+ }
43
+
44
+ async function scanDirectory(
45
+ fs: CmsFileSystem,
46
+ currentDir: string,
47
+ baseDir: string,
48
+ relativeToRoot: boolean,
49
+ excludeDir: string | null,
50
+ items: MediaItem[],
51
+ ): Promise<void> {
52
+ if (excludeDir && normalizeDir(currentDir) === excludeDir) return
53
+
54
+ const entries = await fs.list(currentDir)
55
+ const subdirs: Promise<void>[] = []
56
+
57
+ for (const entry of entries) {
58
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
59
+ const fullPath = `${currentDir}/${entry.name}`
60
+
61
+ if (entry.isDirectory) {
62
+ subdirs.push(scanDirectory(fs, fullPath, baseDir, relativeToRoot, excludeDir, items))
63
+ } else {
64
+ const dotIdx = entry.name.lastIndexOf('.')
65
+ const ext = dotIdx >= 0 ? entry.name.slice(dotIdx).toLowerCase() : ''
66
+ if (!IMAGE_EXTENSIONS.has(ext)) continue
67
+
68
+ const url = relativeToRoot
69
+ ? `/${fullPath}`
70
+ : `/${fullPath.slice(baseDir.length + 1)}`
71
+
72
+ items.push({
73
+ id: `project:${url}`,
74
+ url,
75
+ filename: entry.name,
76
+ contentType: mimeFromExt(ext),
77
+ })
78
+ }
79
+ }
80
+
81
+ await Promise.all(subdirs)
82
+ }
@@ -0,0 +1,151 @@
1
+ import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from '@nuasite/cms-types'
2
+ import { randomUUID } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import { getFileExtension, mimeFromExt } from './local'
5
+
6
+ export interface S3StorageOptions {
7
+ bucket: string
8
+ region: string
9
+ accessKeyId?: string
10
+ secretAccessKey?: string
11
+ endpoint?: string
12
+ cdnPrefix?: string
13
+ prefix?: string
14
+ }
15
+
16
+ // Dynamic import helper to avoid TS2307 for the optional `@aws-sdk/client-s3` peer dependency.
17
+ const s3Module = '@aws-sdk/client-s3'
18
+ async function loadS3(): Promise<any> {
19
+ return import(/* @vite-ignore */ s3Module)
20
+ }
21
+
22
+ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageAdapter {
23
+ const { bucket, region, accessKeyId, secretAccessKey, endpoint, cdnPrefix, prefix = 'uploads' } = options
24
+
25
+ let s3Client: any = null
26
+
27
+ async function getClient() {
28
+ if (s3Client) return s3Client
29
+ const { S3Client } = await loadS3()
30
+ s3Client = new S3Client({
31
+ region,
32
+ ...(endpoint ? { endpoint } : {}),
33
+ ...(accessKeyId && secretAccessKey
34
+ ? { credentials: { accessKeyId, secretAccessKey } }
35
+ : {}),
36
+ })
37
+ return s3Client
38
+ }
39
+
40
+ function getUrl(key: string): string {
41
+ if (cdnPrefix) {
42
+ return `${cdnPrefix.replace(/\/$/, '')}/${key}`
43
+ }
44
+ if (endpoint) {
45
+ return `${endpoint.replace(/\/$/, '')}/${bucket}/${key}`
46
+ }
47
+ return `https://${bucket}.s3.${region}.amazonaws.com/${key}`
48
+ }
49
+
50
+ return {
51
+ async list(opts) {
52
+ const { ListObjectsV2Command } = await loadS3()
53
+ const client = await getClient()
54
+
55
+ const limit = opts?.limit ?? 50
56
+ const folder = opts?.folder ?? ''
57
+
58
+ const listPrefix = [prefix, folder].filter(Boolean).join('/')
59
+ const delimiterPrefix = listPrefix ? `${listPrefix}/` : ''
60
+
61
+ const command = new ListObjectsV2Command({
62
+ Bucket: bucket,
63
+ Prefix: delimiterPrefix,
64
+ Delimiter: '/',
65
+ MaxKeys: limit + 1,
66
+ ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
67
+ })
68
+
69
+ const result = await client.send(command)
70
+
71
+ const folders: MediaFolderItem[] = (result.CommonPrefixes ?? []).map((cp: any) => {
72
+ const fullPrefix = String(cp.Prefix).replace(/\/$/, '')
73
+ const name = fullPrefix.split('/').pop() ?? fullPrefix
74
+ const relativePath = prefix ? fullPrefix.slice(prefix.length + 1) : fullPrefix
75
+ return { name, path: relativePath }
76
+ })
77
+
78
+ const contents = (result.Contents ?? []).filter((obj: any) => {
79
+ const key: string = obj.Key
80
+ return key !== delimiterPrefix
81
+ })
82
+
83
+ const hasMore = contents.length > limit
84
+ const items = contents.slice(0, limit).map((obj: any) => {
85
+ const key: string = obj.Key
86
+ const filename = key.split('/').pop() ?? key
87
+ return {
88
+ id: key,
89
+ url: getUrl(key),
90
+ filename,
91
+ contentType: mimeFromExt(path.extname(key).toLowerCase()),
92
+ uploadedAt: obj.LastModified?.toISOString(),
93
+ folder: folder || undefined,
94
+ }
95
+ })
96
+
97
+ return {
98
+ items,
99
+ folders,
100
+ hasMore,
101
+ cursor: hasMore ? result.NextContinuationToken : undefined,
102
+ } satisfies MediaListResult
103
+ },
104
+
105
+ async upload(file, filename, contentType, uploadOpts) {
106
+ const { PutObjectCommand } = await loadS3()
107
+ const client = await getClient()
108
+
109
+ const ext = getFileExtension(filename)
110
+ const uuid = randomUUID()
111
+ const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
112
+ const folder = uploadOpts?.folder ?? ''
113
+ const keyParts = [prefix, folder, newFilename].filter(Boolean)
114
+ const key = keyParts.join('/')
115
+
116
+ const command = new PutObjectCommand({
117
+ Bucket: bucket,
118
+ Key: key,
119
+ Body: file,
120
+ ContentType: contentType,
121
+ })
122
+
123
+ await client.send(command)
124
+
125
+ return {
126
+ success: true,
127
+ url: getUrl(key),
128
+ filename: newFilename,
129
+ id: key,
130
+ } satisfies MediaUploadResult
131
+ },
132
+
133
+ async delete(id) {
134
+ try {
135
+ const { DeleteObjectCommand } = await loadS3()
136
+ const client = await getClient()
137
+
138
+ const command = new DeleteObjectCommand({
139
+ Bucket: bucket,
140
+ Key: id,
141
+ })
142
+
143
+ await client.send(command)
144
+ return { success: true }
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error)
147
+ return { success: false, error: message }
148
+ }
149
+ },
150
+ }
151
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Slugify with diacritics normalization for href paths.
3
+ * "Lidé" → "lide", "Aktuálně z nezisku" → "aktualne-z-nezisku"
4
+ */
5
+ export function slugifyHref(text: string): string {
6
+ return '/' + text
7
+ .normalize('NFD')
8
+ .replace(/[\u0300-\u036f]/g, '')
9
+ .toLowerCase()
10
+ .trim()
11
+ .replace(/[^\w\s-]/g, '')
12
+ .replace(/[\s_]+/g, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ }
15
+
16
+ /** Runtime guard: a non-null, non-array object usable as a string-keyed record. */
17
+ export function isPlainRecord(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
19
+ }
20
+
21
+ /**
22
+ * Slugify text for URL paths.
23
+ * Lowercases, strips non-word characters, collapses whitespace/underscores to hyphens.
24
+ */
25
+ export function slugify(text: string): string {
26
+ return text
27
+ .toLowerCase()
28
+ .trim()
29
+ .replace(/[^\w\s\-/]/g, '')
30
+ .replace(/[\s_]+/g, '-')
31
+ .replace(/^[-/]+|[-/]+$/g, '')
32
+ }
33
+
34
+ /**
35
+ * Escape HTML special characters to prevent injection.
36
+ * Covers &, <, >, ", and ' — the full set needed for both text content and attribute values.
37
+ */
38
+ export function escapeHtml(text: string): string {
39
+ return text
40
+ .replace(/&/g, '&amp;')
41
+ .replace(/</g, '&lt;')
42
+ .replace(/>/g, '&gt;')
43
+ .replace(/"/g, '&quot;')
44
+ .replace(/'/g, '&#39;')
45
+ }
46
+
47
+ /**
48
+ * Compute a POSIX-style relative import path from one root-relative file to another.
49
+ * Both paths are forward-slash POSIX paths relative to the project root. Ensures
50
+ * the result starts with `./` or `../`.
51
+ */
52
+ export function relativeImportPath(fromFile: string, toFile: string): string {
53
+ const fromSegments = fromFile.split('/').slice(0, -1)
54
+ const toSegments = toFile.split('/')
55
+
56
+ let common = 0
57
+ while (common < fromSegments.length && common < toSegments.length - 1 && fromSegments[common] === toSegments[common]) {
58
+ common++
59
+ }
60
+
61
+ const up = fromSegments.slice(common).map(() => '..')
62
+ const down = toSegments.slice(common)
63
+ const joined = [...up, ...down].join('/')
64
+ return joined.startsWith('.') ? joined : `./${joined}`
65
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../tsconfig.settings.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/types"
5
+ },
6
+ "references": [
7
+ { "path": "../../cms-types/src" }
8
+ ]
9
+ }