@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.
- package/dist/types/collection-scanner.d.ts +12 -0
- package/dist/types/collection-scanner.d.ts.map +1 -0
- package/dist/types/component-registry.d.ts +15 -0
- package/dist/types/component-registry.d.ts.map +1 -0
- package/dist/types/content-config-ast.d.ts +45 -0
- package/dist/types/content-config-ast.d.ts.map +1 -0
- package/dist/types/core.d.ts +44 -0
- package/dist/types/core.d.ts.map +1 -0
- package/dist/types/fs/glob.d.ts +3 -0
- package/dist/types/fs/glob.d.ts.map +1 -0
- package/dist/types/fs/node-fs.d.ts +7 -0
- package/dist/types/fs/node-fs.d.ts.map +1 -0
- package/dist/types/fs/types.d.ts +33 -0
- package/dist/types/fs/types.d.ts.map +1 -0
- package/dist/types/handlers/entry-ops.d.ts +69 -0
- package/dist/types/handlers/entry-ops.d.ts.map +1 -0
- package/dist/types/handlers/page-ops.d.ts +14 -0
- package/dist/types/handlers/page-ops.d.ts.map +1 -0
- package/dist/types/handlers/redirect-ops.d.ts +10 -0
- package/dist/types/handlers/redirect-ops.d.ts.map +1 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/media/contember.d.ts +18 -0
- package/dist/types/media/contember.d.ts.map +1 -0
- package/dist/types/media/index.d.ts +5 -0
- package/dist/types/media/index.d.ts.map +1 -0
- package/dist/types/media/local.d.ts +12 -0
- package/dist/types/media/local.d.ts.map +1 -0
- package/dist/types/media/project-images.d.ts +15 -0
- package/dist/types/media/project-images.d.ts.map +1 -0
- package/dist/types/media/s3.d.ts +12 -0
- package/dist/types/media/s3.d.ts.map +1 -0
- package/dist/types/shared.d.ts +24 -0
- package/dist/types/shared.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/collection-scanner.ts +935 -0
- package/src/component-registry.ts +308 -0
- package/src/content-config-ast.ts +536 -0
- package/src/core.ts +167 -0
- package/src/fs/glob.ts +32 -0
- package/src/fs/node-fs.ts +138 -0
- package/src/fs/types.ts +26 -0
- package/src/handlers/entry-ops.ts +528 -0
- package/src/handlers/page-ops.ts +203 -0
- package/src/handlers/redirect-ops.ts +139 -0
- package/src/index.ts +41 -0
- package/src/media/contember.ts +90 -0
- package/src/media/index.ts +4 -0
- package/src/media/local.ts +147 -0
- package/src/media/project-images.ts +82 -0
- package/src/media/s3.ts +151 -0
- package/src/shared.ts +65 -0
- 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
|
+
}
|
package/src/media/s3.ts
ADDED
|
@@ -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, '&')
|
|
41
|
+
.replace(/</g, '<')
|
|
42
|
+
.replace(/>/g, '>')
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|