@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
package/src/fs/glob.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Convert a glob pattern (supports `*`, `**`, `?`, `{a,b}`) to an anchored RegExp. */
|
|
2
|
+
export function globToRegExp(glob: string): RegExp {
|
|
3
|
+
let re = ''
|
|
4
|
+
for (let i = 0; i < glob.length; i++) {
|
|
5
|
+
const c = glob[i]!
|
|
6
|
+
if (c === '*') {
|
|
7
|
+
if (glob[i + 1] === '*') {
|
|
8
|
+
re += '.*'
|
|
9
|
+
i++
|
|
10
|
+
if (glob[i + 1] === '/') i++
|
|
11
|
+
} else {
|
|
12
|
+
re += '[^/]*'
|
|
13
|
+
}
|
|
14
|
+
} else if (c === '?') {
|
|
15
|
+
re += '[^/]'
|
|
16
|
+
} else if (c === '{') {
|
|
17
|
+
const end = glob.indexOf('}', i)
|
|
18
|
+
if (end === -1) {
|
|
19
|
+
re += '\\{'
|
|
20
|
+
} else {
|
|
21
|
+
const opts = glob.slice(i + 1, end).split(',').map(s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
|
|
22
|
+
re += `(?:${opts.join('|')})`
|
|
23
|
+
i = end
|
|
24
|
+
}
|
|
25
|
+
} else if ('.+^$()|[]\\'.includes(c)) {
|
|
26
|
+
re += `\\${c}`
|
|
27
|
+
} else {
|
|
28
|
+
re += c
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return new RegExp(`^${re}$`)
|
|
32
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Dirent } from 'node:fs'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { globToRegExp } from './glob'
|
|
5
|
+
import type { CmsFileSystem } from './types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a port-relative (or absolute) path against `root` and ensure it stays
|
|
9
|
+
* within `root`. Mirrors the previous `resolveAndValidatePath` behavior:
|
|
10
|
+
* - absolute filesystem paths under the root pass through;
|
|
11
|
+
* - a project-relative path with a leading slash (e.g. `/src/content/...`) has
|
|
12
|
+
* it stripped before joining with the root;
|
|
13
|
+
* - any path that escapes the root throws.
|
|
14
|
+
*/
|
|
15
|
+
function resolveWithinRoot(root: string, filePath: string): string {
|
|
16
|
+
const resolvedRoot = path.resolve(root)
|
|
17
|
+
const isAbsoluteFs = filePath.startsWith(resolvedRoot)
|
|
18
|
+
const normalizedPath = (!isAbsoluteFs && filePath.startsWith('/')) ? filePath.slice(1) : filePath
|
|
19
|
+
const fullPath = path.isAbsolute(normalizedPath) ? path.resolve(normalizedPath) : path.resolve(resolvedRoot, normalizedPath)
|
|
20
|
+
|
|
21
|
+
if (!fullPath.startsWith(resolvedRoot + path.sep) && fullPath !== resolvedRoot) {
|
|
22
|
+
throw new Error(`Path traversal detected: ${filePath}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return fullPath
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
29
|
+
return error instanceof Error && 'code' in error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Recursively list files under `absDir`, returning forward-slash paths relative to `absRoot`. */
|
|
33
|
+
async function walkFiles(absRoot: string, absDir: string): Promise<string[]> {
|
|
34
|
+
let dirEntries: Dirent[]
|
|
35
|
+
try {
|
|
36
|
+
dirEntries = await fs.readdir(absDir, { withFileTypes: true })
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
const out: string[] = []
|
|
41
|
+
for (const entry of dirEntries) {
|
|
42
|
+
const abs = path.join(absDir, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
out.push(...await walkFiles(absRoot, abs))
|
|
45
|
+
} else if (entry.isFile()) {
|
|
46
|
+
out.push(path.relative(absRoot, abs).split(path.sep).join('/'))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The longest leading directory of a glob pattern that contains no glob metacharacters. */
|
|
53
|
+
function staticPrefixDir(pattern: string): string {
|
|
54
|
+
const segments = pattern.split('/')
|
|
55
|
+
const staticSegments: string[] = []
|
|
56
|
+
for (const segment of segments) {
|
|
57
|
+
if (/[*?{}[\]]/.test(segment)) break
|
|
58
|
+
staticSegments.push(segment)
|
|
59
|
+
}
|
|
60
|
+
// Drop the last segment if it is the (potentially globbed) file part: a static prefix
|
|
61
|
+
// must be a directory, so only keep segments that precede the first globbed segment.
|
|
62
|
+
if (staticSegments.length === segments.length) {
|
|
63
|
+
staticSegments.pop()
|
|
64
|
+
}
|
|
65
|
+
return staticSegments.join('/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a `CmsFileSystem` backed by `node:fs`, rooted at `root`.
|
|
70
|
+
* Every path is resolved relative to `root` and validated to stay within it.
|
|
71
|
+
*/
|
|
72
|
+
export function createNodeFs(root: string): CmsFileSystem {
|
|
73
|
+
const resolvedRoot = path.resolve(root)
|
|
74
|
+
const resolve = (p: string) => resolveWithinRoot(resolvedRoot, p)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async readFile(filePath) {
|
|
78
|
+
return fs.readFile(resolve(filePath), 'utf-8')
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async writeFile(filePath, content) {
|
|
82
|
+
const fullPath = resolve(filePath)
|
|
83
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
84
|
+
// Atomic write: write to a unique temp file in the same directory, then rename.
|
|
85
|
+
// A killed write leaves only the temp file, never a half-written target.
|
|
86
|
+
const tempPath = `${fullPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`
|
|
87
|
+
try {
|
|
88
|
+
await fs.writeFile(tempPath, content, 'utf-8')
|
|
89
|
+
await fs.rename(tempPath, fullPath)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await fs.rm(tempPath, { force: true })
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async rename(from, to) {
|
|
97
|
+
const fullTo = resolve(to)
|
|
98
|
+
await fs.mkdir(path.dirname(fullTo), { recursive: true })
|
|
99
|
+
await fs.rename(resolve(from), fullTo)
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async remove(filePath) {
|
|
103
|
+
await fs.rm(resolve(filePath), { force: true })
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async exists(filePath) {
|
|
107
|
+
try {
|
|
108
|
+
await fs.access(resolve(filePath))
|
|
109
|
+
return true
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async list(dir) {
|
|
116
|
+
try {
|
|
117
|
+
const entries = await fs.readdir(resolve(dir), { withFileTypes: true })
|
|
118
|
+
return entries.map(entry => ({ name: entry.name, isDirectory: entry.isDirectory() }))
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isNodeError(error) && error.code === 'ENOENT') return []
|
|
121
|
+
throw error
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async glob(pattern) {
|
|
126
|
+
const baseRel = staticPrefixDir(pattern)
|
|
127
|
+
const absBase = resolve(baseRel)
|
|
128
|
+
const matcher = globToRegExp(pattern)
|
|
129
|
+
const files = await walkFiles(resolvedRoot, absBase)
|
|
130
|
+
return files.filter(rel => matcher.test(rel))
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async stat(filePath) {
|
|
134
|
+
const s = await fs.stat(resolve(filePath))
|
|
135
|
+
return { mtimeMs: s.mtimeMs, size: s.size }
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/fs/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FileSystem port: the only I/O boundary of cms-core.
|
|
3
|
+
*
|
|
4
|
+
* All paths are interpreted relative to the implementation's configured root.
|
|
5
|
+
* Implementations must resolve them under the root and reject any path that
|
|
6
|
+
* escapes it (path traversal). The same brain therefore runs unchanged over
|
|
7
|
+
* `node:fs` (sidecar + local dev) and, in principle, over a remote adapter.
|
|
8
|
+
*/
|
|
9
|
+
export interface CmsFileSystem {
|
|
10
|
+
/** Read a UTF-8 text file. Rejects when the file does not exist. */
|
|
11
|
+
readFile(path: string): Promise<string>
|
|
12
|
+
/** Write a UTF-8 text file atomically (write temp + rename). Creates parent dirs. */
|
|
13
|
+
writeFile(path: string, content: string): Promise<void>
|
|
14
|
+
/** Rename/move a file. Creates the destination's parent dir. */
|
|
15
|
+
rename(from: string, to: string): Promise<void>
|
|
16
|
+
/** Remove a file. No-op when it does not exist. */
|
|
17
|
+
remove(path: string): Promise<void>
|
|
18
|
+
/** Whether a file or directory exists. */
|
|
19
|
+
exists(path: string): Promise<boolean>
|
|
20
|
+
/** List the immediate children of a directory. Returns [] when it does not exist. */
|
|
21
|
+
list(dir: string): Promise<{ name: string; isDirectory: boolean }[]>
|
|
22
|
+
/** Resolve a glob pattern (supports `*`, `**`, `?`, `{a,b}`) to matching file paths, root-relative. */
|
|
23
|
+
glob(pattern: string): Promise<string[]>
|
|
24
|
+
/** File metadata. Rejects when the path does not exist. */
|
|
25
|
+
stat(path: string): Promise<{ mtimeMs: number; size: number }>
|
|
26
|
+
}
|